fixed commetn from songxiao
This commit is contained in:
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
self.replay_ctl.is_playing()
|
||||
or self._recompute_running
|
||||
or self._session_active
|
||||
)
|
||||
# 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()
|
||||
else:
|
||||
self._stop_run(reason="user_stop")
|
||||
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._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,29 +2168,111 @@ 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.
|
||||
self.mesh3d_panel.tick()
|
||||
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
|
||||
# Record bookkeeping happens before anything that can return early: a
|
||||
@@ -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:
|
||||
|
||||
@@ -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"):
|
||||
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"]),
|
||||
)
|
||||
"""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]
|
||||
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),
|
||||
|
||||
@@ -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_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._fn_raw_min = float(fn_raw_min)
|
||||
self._fn_raw_max = max(0.0, float(fn_raw_max))
|
||||
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"))
|
||||
|
||||
@@ -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()
|
||||
# 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")
|
||||
playing = self.btn_run.isChecked()
|
||||
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:
|
||||
self.btn_record.setObjectName("dangerButton")
|
||||
self.btn_run.setObjectName("dangerOutline")
|
||||
# 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:
|
||||
self.btn_record.setObjectName("primaryButton")
|
||||
if running and self._run_phase != "connecting":
|
||||
self.btn_run.setObjectName("dangerOutline")
|
||||
elif running:
|
||||
self.btn_run.setObjectName("dangerButton")
|
||||
# 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.
|
||||
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("ghostButton")
|
||||
else:
|
||||
self.btn_run.setObjectName("primaryButton")
|
||||
for button in (self.btn_record, self.btn_run):
|
||||
self.btn_record.setObjectName("primaryButton")
|
||||
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"))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, ...]:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
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(
|
||||
{
|
||||
"positions": sensing.positions_sensor_m,
|
||||
"triangles": sensing.triangles,
|
||||
"indices": binding.sample_indices,
|
||||
"weights": binding.sample_weights,
|
||||
},
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,6 +734,48 @@ def scene_from_contents(
|
||||
)
|
||||
compatible = dict(manifest.get("compatible") or {})
|
||||
source_geometry = dict(manifest.get("source_geometry") or {})
|
||||
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", ())),
|
||||
target_sensor_m=tuple(float(v) for v in item.get("target", ())),
|
||||
up_sensor=tuple(float(v) for v in item.get("up", ())),
|
||||
vertical_fov_rad=float(item.get("fov", 0.6)),
|
||||
)
|
||||
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
|
||||
),
|
||||
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 ""),
|
||||
@@ -722,21 +783,8 @@ def scene_from_contents(
|
||||
geometry_id=str(manifest.get("geometry_id") or ""),
|
||||
parts=tuple(parts),
|
||||
binding=binding,
|
||||
camera_presets=tuple(
|
||||
CameraPreset(
|
||||
name=str(item.get("name")),
|
||||
eye_sensor_m=tuple(float(v) for v in item.get("eye", ())),
|
||||
target_sensor_m=tuple(float(v) for v in item.get("target", ())),
|
||||
up_sensor=tuple(float(v) for v in item.get("up", ())),
|
||||
vertical_fov_rad=float(item.get("fov", 0.6)),
|
||||
)
|
||||
for item in manifest.get("camera_presets", [])
|
||||
if isinstance(item, dict) and item.get("name")
|
||||
),
|
||||
bounds_sensor_m=freeze(
|
||||
np.asarray(manifest.get("bounds_sensor_m") or np.zeros((2, 3)), dtype=np.float32),
|
||||
dtype=np.float32,
|
||||
),
|
||||
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]:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user