diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index fd79bd5..5692792 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -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}", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index 03f4426..13a9914 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -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}", diff --git a/apps/studio/config/preferences.default.json b/apps/studio/config/preferences.default.json index e7a1df5..3fc4043 100644 --- a/apps/studio/config/preferences.default.json +++ b/apps/studio/config/preferences.default.json @@ -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, diff --git a/apps/studio/config/sensors/finger-mems/sensor.json b/apps/studio/config/sensors/finger-mems/sensor.json new file mode 100644 index 0000000..4823daf --- /dev/null +++ b/apps/studio/config/sensors/finger-mems/sensor.json @@ -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 + } +} diff --git a/apps/studio/config/sensors/finger/assets/30fps-0703/housing.npz b/apps/studio/config/sensors/finger/assets/30fps-0703/housing.npz new file mode 100644 index 0000000..f036a8f Binary files /dev/null and b/apps/studio/config/sensors/finger/assets/30fps-0703/housing.npz differ diff --git a/apps/studio/config/sensors/finger/assets/30fps-0703/mesh.npz b/apps/studio/config/sensors/finger/assets/30fps-0703/mesh.npz index 4da6bb1..b00110c 100644 Binary files a/apps/studio/config/sensors/finger/assets/30fps-0703/mesh.npz and b/apps/studio/config/sensors/finger/assets/30fps-0703/mesh.npz differ diff --git a/apps/studio/config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz b/apps/studio/config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz deleted file mode 100644 index 995cee0..0000000 Binary files a/apps/studio/config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz and /dev/null differ diff --git a/apps/studio/config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.42.npz b/apps/studio/config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.42.npz new file mode 100644 index 0000000..b88abf6 Binary files /dev/null and b/apps/studio/config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.42.npz differ diff --git a/apps/studio/config/sensors/finger/assets/30fps-0703/world_to_cam.npy b/apps/studio/config/sensors/finger/assets/30fps-0703/world_to_cam.npy index 3d6f897..a4b39d3 100644 Binary files a/apps/studio/config/sensors/finger/assets/30fps-0703/world_to_cam.npy and b/apps/studio/config/sensors/finger/assets/30fps-0703/world_to_cam.npy differ diff --git a/apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene b/apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene index 4ce94c9..4611e48 100644 Binary files a/apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene and b/apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene differ diff --git a/apps/studio/config/sensors/finger/sensor.json b/apps/studio/config/sensors/finger/sensor.json index ebdb709..23e561a 100644 --- a/apps/studio/config/sensors/finger/sensor.json +++ b/apps/studio/config/sensors/finger/sensor.json @@ -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], diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 8bf952c..a95befe 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -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: diff --git a/apps/studio/src/orisys_studio/display_prefs.py b/apps/studio/src/orisys_studio/display_prefs.py index 3f10376..5b52b73 100644 --- a/apps/studio/src/orisys_studio/display_prefs.py +++ b/apps/studio/src/orisys_studio/display_prefs.py @@ -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: diff --git a/apps/studio/src/orisys_studio/scene3d/asset_source.py b/apps/studio/src/orisys_studio/scene3d/asset_source.py index 65393a8..f576362 100644 --- a/apps/studio/src/orisys_studio/scene3d/asset_source.py +++ b/apps/studio/src/orisys_studio/scene3d/asset_source.py @@ -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 --output " "config/sensors/finger/scene/finger-display-1.0.0.oryscene" ) diff --git a/apps/studio/src/orisys_studio/scene3d/panel.py b/apps/studio/src/orisys_studio/scene3d/panel.py index ae80b40..8ee5685 100644 --- a/apps/studio/src/orisys_studio/scene3d/panel.py +++ b/apps/studio/src/orisys_studio/scene3d/panel.py @@ -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) diff --git a/apps/studio/src/orisys_studio/scene3d/prefs.py b/apps/studio/src/orisys_studio/scene3d/prefs.py index b7041b0..0d17b62 100644 --- a/apps/studio/src/orisys_studio/scene3d/prefs.py +++ b/apps/studio/src/orisys_studio/scene3d/prefs.py @@ -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), diff --git a/apps/studio/src/orisys_studio/ui/force_plots.py b/apps/studio/src/orisys_studio/ui/force_plots.py index e5701f6..f5a187d 100644 --- a/apps/studio/src/orisys_studio/ui/force_plots.py +++ b/apps/studio/src/orisys_studio/ui/force_plots.py @@ -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] diff --git a/apps/studio/src/orisys_studio/ui/plot_settings_dialog.py b/apps/studio/src/orisys_studio/ui/plot_settings_dialog.py index aaaec78..40143d9 100644 --- a/apps/studio/src/orisys_studio/ui/plot_settings_dialog.py +++ b/apps/studio/src/orisys_studio/ui/plot_settings_dialog.py @@ -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")) diff --git a/apps/studio/src/orisys_studio/ui/toolbar.py b/apps/studio/src/orisys_studio/ui/toolbar.py index 9605566..0a0c636 100644 --- a/apps/studio/src/orisys_studio/ui/toolbar.py +++ b/apps/studio/src/orisys_studio/ui/toolbar.py @@ -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")) diff --git a/apps/studio/src/orisys_studio/view_layout.py b/apps/studio/src/orisys_studio/view_layout.py index 0742de7..5d99884 100644 --- a/apps/studio/src/orisys_studio/view_layout.py +++ b/apps/studio/src/orisys_studio/view_layout.py @@ -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)) diff --git a/apps/studio/tests/test_display_prefs.py b/apps/studio/tests/test_display_prefs.py index 45ea533..f5358ff 100644 --- a/apps/studio/tests/test_display_prefs.py +++ b/apps/studio/tests/test_display_prefs.py @@ -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 diff --git a/apps/studio/tests/test_force_plots_display.py b/apps/studio/tests/test_force_plots_display.py new file mode 100644 index 0000000..af48446 --- /dev/null +++ b/apps/studio/tests/test_force_plots_display.py @@ -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"), + ] diff --git a/apps/studio/tests/test_pause_controls.py b/apps/studio/tests/test_pause_controls.py new file mode 100644 index 0000000..ff41a5e --- /dev/null +++ b/apps/studio/tests/test_pause_controls.py @@ -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() diff --git a/apps/studio/tests/test_scene3d_adapters.py b/apps/studio/tests/test_scene3d_adapters.py index f72b4a8..cb8f9dc 100644 --- a/apps/studio/tests/test_scene3d_adapters.py +++ b/apps/studio/tests/test_scene3d_adapters.py @@ -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" ) diff --git a/apps/studio/tests/test_scene3d_gl_smoke.py b/apps/studio/tests/test_scene3d_gl_smoke.py index dcf30f7..7555d15 100644 --- a/apps/studio/tests/test_scene3d_gl_smoke.py +++ b/apps/studio/tests/test_scene3d_gl_smoke.py @@ -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)) diff --git a/apps/studio/tests/test_scene3d_skeleton.py b/apps/studio/tests/test_scene3d_skeleton.py index 7bcfb7f..87e5380 100644 --- a/apps/studio/tests/test_scene3d_skeleton.py +++ b/apps/studio/tests/test_scene3d_skeleton.py @@ -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" diff --git a/apps/studio/tests/test_sensor_types.py b/apps/studio/tests/test_sensor_types.py new file mode 100644 index 0000000..2d6ee58 --- /dev/null +++ b/apps/studio/tests/test_sensor_types.py @@ -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 diff --git a/apps/studio/tests/test_transport_review.py b/apps/studio/tests/test_transport_review.py new file mode 100644 index 0000000..5e8e0ec --- /dev/null +++ b/apps/studio/tests/test_transport_review.py @@ -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() diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index 053304e..3f3e472 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -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) + + diff --git a/apps/studio/tests/test_view_layout.py b/apps/studio/tests/test_view_layout.py index dd30d30..dceceba 100644 --- a/apps/studio/tests/test_view_layout.py +++ b/apps/studio/tests/test_view_layout.py @@ -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] diff --git a/apps/studio/tools/ui_check.py b/apps/studio/tools/ui_check.py index 38cafce..062a1c8 100644 --- a/apps/studio/tools/ui_check.py +++ b/apps/studio/tools/ui_check.py @@ -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(), diff --git a/apps/studio/tools/ui_snapshot.py b/apps/studio/tools/ui_snapshot.py index 89e3e61..8aa58ab 100644 --- a/apps/studio/tools/ui_snapshot.py +++ b/apps/studio/tools/ui_snapshot.py @@ -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="", diff --git a/build/finger-display-1.0.0.oryscene b/build/finger-display-1.0.0.oryscene new file mode 100644 index 0000000..1f2c26a Binary files /dev/null and b/build/finger-display-1.0.0.oryscene differ diff --git a/shared/src/orisys_app_shared/acquisition/finger_worker.py b/shared/src/orisys_app_shared/acquisition/finger_worker.py index 66d26d8..3d20ffe 100644 --- a/shared/src/orisys_app_shared/acquisition/finger_worker.py +++ b/shared/src/orisys_app_shared/acquisition/finger_worker.py @@ -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: diff --git a/shared/src/orisys_app_shared/acquisition/sdk_worker.py b/shared/src/orisys_app_shared/acquisition/sdk_worker.py index 021a4ba..4222fea 100644 --- a/shared/src/orisys_app_shared/acquisition/sdk_worker.py +++ b/shared/src/orisys_app_shared/acquisition/sdk_worker.py @@ -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: diff --git a/shared/src/orisys_app_shared/force/presentation.py b/shared/src/orisys_app_shared/force/presentation.py index 776d2c5..d95f221 100644 --- a/shared/src/orisys_app_shared/force/presentation.py +++ b/shared/src/orisys_app_shared/force/presentation.py @@ -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. diff --git a/shared/src/orisys_app_shared/prefs/store.py b/shared/src/orisys_app_shared/prefs/store.py index 8c4321a..e5fa3b1 100644 --- a/shared/src/orisys_app_shared/prefs/store.py +++ b/shared/src/orisys_app_shared/prefs/store.py @@ -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, diff --git a/shared/src/orisys_app_shared/scene3d/contracts.py b/shared/src/orisys_app_shared/scene3d/contracts.py index f3e8654..5c209ac 100644 --- a/shared/src/orisys_app_shared/scene3d/contracts.py +++ b/shared/src/orisys_app_shared/scene3d/contracts.py @@ -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, ...]: diff --git a/shared/src/orisys_app_shared/scene3d/legacy.py b/shared/src/orisys_app_shared/scene3d/legacy.py index 1c44e1b..6cb6adf 100644 --- a/shared/src/orisys_app_shared/scene3d/legacy.py +++ b/shared/src/orisys_app_shared/scene3d/legacy.py @@ -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), ) diff --git a/shared/src/orisys_app_shared/scene3d/package.py b/shared/src/orisys_app_shared/scene3d/package.py index ae3c8ec..95c3186 100644 --- a/shared/src/orisys_app_shared/scene3d/package.py +++ b/shared/src/orisys_app_shared/scene3d/package.py @@ -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.""" diff --git a/shared/src/orisys_app_shared/scene3d/synthetic.py b/shared/src/orisys_app_shared/scene3d/synthetic.py index ebde924..4bf9525 100644 --- a/shared/src/orisys_app_shared/scene3d/synthetic.py +++ b/shared/src/orisys_app_shared/scene3d/synthetic.py @@ -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) diff --git a/shared/src/orisys_app_shared/sensors/catalog.py b/shared/src/orisys_app_shared/sensors/catalog.py index 0caad8f..1c7e83f 100644 --- a/shared/src/orisys_app_shared/sensors/catalog.py +++ b/shared/src/orisys_app_shared/sensors/catalog.py @@ -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]: diff --git a/shared/tests/test_force_presentation.py b/shared/tests/test_force_presentation.py index 3d4a7d2..af46533 100644 --- a/shared/tests/test_force_presentation.py +++ b/shared/tests/test_force_presentation.py @@ -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" diff --git a/shared/tests/test_scene3d_assets.py b/shared/tests/test_scene3d_assets.py index 6ed9b50..4564486 100644 --- a/shared/tests/test_scene3d_assets.py +++ b/shared/tests/test_scene3d_assets.py @@ -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): + """``/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) diff --git a/shared/tests/test_scene3d_device_geometry.py b/shared/tests/test_scene3d_device_geometry.py index 2d7eafb..51d5986 100644 --- a/shared/tests/test_scene3d_device_geometry.py +++ b/shared/tests/test_scene3d_device_geometry.py @@ -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): diff --git a/shared/tests/test_scene3d_protect.py b/shared/tests/test_scene3d_protect.py index e630b7f..63b57a7 100644 --- a/shared/tests/test_scene3d_protect.py +++ b/shared/tests/test_scene3d_protect.py @@ -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 diff --git a/shared/tests/test_worker_frame_rate.py b/shared/tests/test_worker_frame_rate.py index b3747e1..d162fe3 100644 --- a/shared/tests/test_worker_frame_rate.py +++ b/shared/tests/test_worker_frame_rate.py @@ -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()): diff --git a/tools/scene_assets/README.md b/tools/scene_assets/README.md index 0b5e441..51e35b5 100644 --- a/tools/scene_assets/README.md +++ b/tools/scene_assets/README.md @@ -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 ` takes a `vertices`/`faces` file **in the same source frame and +units as the export's `mesh.npz`**. Omitted, `/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": ""` (an absolute path; also the way diff --git a/tools/scene_assets/align_housing.py b/tools/scene_assets/align_housing.py new file mode 100644 index 0000000..62a4ec0 --- /dev/null +++ b/tools/scene_assets/align_housing.py @@ -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()) diff --git a/tools/scene_assets/compare_uv_map.py b/tools/scene_assets/compare_uv_map.py new file mode 100644 index 0000000..c3406e2 --- /dev/null +++ b/tools/scene_assets/compare_uv_map.py @@ -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 \ + --against /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()) diff --git a/tools/scene_assets/compile_scene.py b/tools/scene_assets/compile_scene.py index 7791f13..72d79c1 100644 --- a/tools/scene_assets/compile_scene.py +++ b/tools/scene_assets/compile_scene.py @@ -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, /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 diff --git a/tools/scene_assets/fit_design_frame.py b/tools/scene_assets/fit_design_frame.py new file mode 100644 index 0000000..03d3cf8 --- /dev/null +++ b/tools/scene_assets/fit_design_frame.py @@ -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 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()) diff --git a/tools/scene_assets/housing/finger_r01_design_frame.json b/tools/scene_assets/housing/finger_r01_design_frame.json new file mode 100644 index 0000000..c43d057 --- /dev/null +++ b/tools/scene_assets/housing/finger_r01_design_frame.json @@ -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" + } + } +} diff --git a/tools/scene_assets/housing/finger_r01_design_to_sensor.npy b/tools/scene_assets/housing/finger_r01_design_to_sensor.npy new file mode 100644 index 0000000..01f8892 Binary files /dev/null and b/tools/scene_assets/housing/finger_r01_design_to_sensor.npy differ diff --git a/tools/scene_assets/housing/finger_r01_design_to_sensor_rigid_m.npy b/tools/scene_assets/housing/finger_r01_design_to_sensor_rigid_m.npy new file mode 100644 index 0000000..ed66f1e Binary files /dev/null and b/tools/scene_assets/housing/finger_r01_design_to_sensor_rigid_m.npy differ diff --git a/tools/scene_assets/housing/finger_r01_housing.npz b/tools/scene_assets/housing/finger_r01_housing.npz new file mode 100644 index 0000000..310e1ac Binary files /dev/null and b/tools/scene_assets/housing/finger_r01_housing.npz differ