From cf86bd0702f05224c65c096370a09ebb3c57c4d9 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Sat, 10 Oct 2026 14:16:03 +0800 Subject: [PATCH] Merge branch 'main' of ssh://892887cvmk08.aicp.io:24928/orisys-tech/orisys-app --- apps/studio/config/locales/en_US.json | 4 ++- apps/studio/config/locales/zh_CN.json | 4 ++- apps/studio/src/orisys_studio/frame_view.py | 3 ++ .../src/orisys_studio/scene3d/gl_backend.py | 21 ++++++++++-- .../studio/src/orisys_studio/scene3d/panel.py | 16 ++++++++++ .../studio/src/orisys_studio/scene3d/prefs.py | 2 ++ apps/studio/tests/test_scene3d_gl_smoke.py | 32 +++++++++++++++++++ apps/studio/tests/test_scene3d_pane_ui.py | 9 ++++++ apps/studio/tests/test_scene3d_stage1.py | 1 + apps/studio/tests/test_scene3d_view.py | 3 ++ .../orisys_app_shared/acquisition/arrows.py | 6 ++++ .../acquisition/image_backend.py | 5 +++ .../orisys_app_shared/scene3d/contracts.py | 2 ++ shared/tests/test_image_backend.py | 14 ++++++++ 14 files changed, 118 insertions(+), 4 deletions(-) diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index cd41d54..1d03c0b 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -527,6 +527,8 @@ "contact_arrow_unavailable": "This scene cannot draw the contact arrow.", "flow_threshold": "Arrow threshold", "flow_threshold_tip": "Arrows below this displacement (px) are not drawn; shared by 2D and 3D.", + "depth_threshold": "Depth display threshold", + "depth_threshold_tip": "Values below this relative depth response use the base ramp colour; 0 disables it. Shared by 2D and 3D without changing measurements.", "quantity_flow_magnitude": "Optical flow magnitude", "quantity_depth_response": "Depth response", "preset_default": "Default view", @@ -544,7 +546,7 @@ "max_fps_tip": "How many frames per second the 3D view draws. A display budget only: the camera and the recording are unaffected, and a dropped frame here is never a dropped sample.", "reset_view": "Reset view", "settings_title": "3D display settings", - "settings_hint": "Per-frame adaptive applies to both 2D and 3D.", + "settings_hint": "Depth display threshold and per-frame adaptive apply to both 2D and 3D.", "legend": "{quantity} [{unit}] · {quality}", "legend_none": "No measurement on screen — the surface model is shown uncoloured", "quality_calibrated": "calibrated", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index b11984b..970ec78 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -527,6 +527,8 @@ "contact_arrow_unavailable": "当前场景画不了接触箭头。", "flow_threshold": "箭头阈值", "flow_threshold_tip": "低于此位移(像素)不画箭头;二维与三维共用。", + "depth_threshold": "深度显示阈值", + "depth_threshold_tip": "低于此相对深度响应的区域显示为色标基色;0 表示关闭。二维深度图与三维表面共用,不改变测量数据。", "quantity_flow_magnitude": "光流幅值", "quantity_depth_response": "深度响应", "preset_default": "默认视角", @@ -544,7 +546,7 @@ "max_fps_tip": "三维视图每秒绘制多少帧。这只是显示预算:摄像头与记录不受影响,这里丢的帧永远不会变成丢的样本。", "reset_view": "复位视角", "settings_title": "三维显示设置", - "settings_hint": "每帧自适应同时作用于二维和三维。", + "settings_hint": "深度显示阈值与每帧自适应同时作用于二维深度图和三维表面。", "legend": "{quantity} [{unit}] · {quality}", "legend_none": "屏幕上没有测量值 — 仅显示不着色的表面模型", "quality_calibrated": "已标定", diff --git a/apps/studio/src/orisys_studio/frame_view.py b/apps/studio/src/orisys_studio/frame_view.py index 8c8bf63..c1f90d1 100644 --- a/apps/studio/src/orisys_studio/frame_view.py +++ b/apps/studio/src/orisys_studio/frame_view.py @@ -251,6 +251,9 @@ class FrameView: theme=theme, draw_style=draw_style, depth_range=span, + depth_threshold=float( + getattr(self.depth_display_settings(), "depth_threshold", 0.0) + ), canvas_bgr=hex_to_bgr(color(theme, "arrow_canvas", "#0B1830")), domain_mask=boundary, # A frame read from a stored analysis result says which policy its *recovered* diff --git a/apps/studio/src/orisys_studio/scene3d/gl_backend.py b/apps/studio/src/orisys_studio/scene3d/gl_backend.py index aeb0868..64a9d86 100644 --- a/apps/studio/src/orisys_studio/scene3d/gl_backend.py +++ b/apps/studio/src/orisys_studio/scene3d/gl_backend.py @@ -164,6 +164,7 @@ uniform sampler2D u_valid; uniform vec2 u_inv_domain_size; uniform float u_lower; uniform float u_upper; +uniform float u_depth_threshold; uniform vec4 u_invalid_color; out vec4 frag_color; @@ -181,7 +182,11 @@ void main() { return; } float span = max(u_upper - u_lower, 1e-12); - float t = clamp((texture(u_field, texel).r - u_lower) / span, 0.0, 1.0); + float value = texture(u_field, texel).r; + float t = clamp((value - u_lower) / span, 0.0, 1.0); + if (u_depth_threshold > 0.0 && value < u_depth_threshold) { + t = 0.0; + } frag_color = vec4(texture(u_lut, vec2(t, 0.5)).rgb, 1.0); } """ @@ -717,6 +722,7 @@ class GLRenderer: #: Range of the valid samples of the field currently uploaded, for the #: automatic colour scale. ``None`` until a frame carries valid data. self._scalar_range: tuple[float, float] | None = None + self._field_quantity = "" #: The 2D canvas's latched depth ``(lo, hi)``, when the window has one. #: Depth automatic colouring uses this so both legends name one interval. self._shared_depth_scale: tuple[float, float] | None = None @@ -771,6 +777,7 @@ class GLRenderer: # longer on screen: keeping it made the first automatic draw of the new scene use # the previous scene's bounds. self._scalar_range = None + self._field_quantity = "" self._last_scale = None self._last_scale_mode = "" uvs = np.asarray(assets.binding.uvs, dtype=np.float32) @@ -847,6 +854,7 @@ class GLRenderer: previous=self._scalar_range, shared_depth_scale=self._shared_depth_scale, ) + self._field_quantity = str(getattr(field.descriptor, "quantity", "")) self._upload_overlays(frame) def _upload_field_textures(self, field: Any) -> None: @@ -1070,6 +1078,13 @@ class GLRenderer: if part.role == "sensing": _set_float(GL, locations.get("u_lower"), lower) _set_float(GL, locations.get("u_upper"), upper) + _set_float( + GL, + locations.get("u_depth_threshold"), + settings.depth_threshold + if self._field_quantity == "depth_response" + else 0.0, + ) _set_vec4(GL, locations.get("u_invalid_color"), INVALID_COLOR) self._bind_field_textures(GL, locations) else: @@ -1203,6 +1218,7 @@ class GLRenderer: self._initialized = False self._estimated_gpu_bytes = 0 self._scalar_range = None + self._field_quantity = "" self._last_scale = None self._last_scale_mode = "" self._paint_calls = 0 @@ -1339,7 +1355,8 @@ def _compile_program(GL: Any, profile: Profile, name: str) -> int: def _uniform_locations(GL: Any, program: int) -> dict[str, int]: names = ( "u_view", "u_projection", "u_lower", "u_upper", - "u_invalid_color", "u_lut", "u_field", "u_valid", "u_inv_domain_size", + "u_invalid_color", "u_depth_threshold", "u_lut", "u_field", + "u_valid", "u_inv_domain_size", "u_light_direction", "u_base_color", ) out: dict[str, int] = {} diff --git a/apps/studio/src/orisys_studio/scene3d/panel.py b/apps/studio/src/orisys_studio/scene3d/panel.py index dde3c95..cf2b281 100644 --- a/apps/studio/src/orisys_studio/scene3d/panel.py +++ b/apps/studio/src/orisys_studio/scene3d/panel.py @@ -265,6 +265,16 @@ class ThreeDPanel(QFrame): self.flow_threshold_spin.valueChanged.connect(self._emit_settings) form.addRow(self.flow_threshold_label, self.flow_threshold_spin) + self.depth_threshold_label = QLabel() + self.depth_threshold_spin = QDoubleSpinBox() + self.depth_threshold_spin.setRange(0.0, 1000.0) + self.depth_threshold_spin.setDecimals(4) + self.depth_threshold_spin.setSingleStep(0.005) + self.depth_threshold_spin.setValue(0.0) + self.depth_threshold_spin.setMaximumWidth(SPIN_WIDTH) + self.depth_threshold_spin.valueChanged.connect(self._emit_settings) + form.addRow(self.depth_threshold_label, self.depth_threshold_spin) + self.scale_label = QLabel() self.scale_combo = QComboBox() self.scale_combo.addItem("fixed", "fixed") @@ -701,6 +711,7 @@ class ThreeDPanel(QFrame): self.flow_arrows_check, self.contact_arrow_check, self.flow_threshold_spin, + self.depth_threshold_spin, self.scale_combo, self.fps_combo, self.reset_button, @@ -735,6 +746,7 @@ class ThreeDPanel(QFrame): self.flow_arrows_check.setChecked(bool(settings.flow_arrows_enabled)) self.contact_arrow_check.setChecked(bool(settings.contact_arrow_enabled)) self.flow_threshold_spin.setValue(float(settings.flow_on_px)) + self.depth_threshold_spin.setValue(float(settings.depth_threshold)) self._flow_grid_spacing = max(1, int(settings.flow_grid_spacing)) self._max_arrows = max(1, int(settings.max_arrows)) self.depth_adaptive_check.setChecked(bool(settings.depth_scale_adaptive)) @@ -791,6 +803,7 @@ class ThreeDPanel(QFrame): flow_grid_spacing=int(self._flow_grid_spacing), max_arrows=int(self._max_arrows), depth_scale_adaptive=bool(self.depth_adaptive_check.isChecked()), + depth_threshold=float(self.depth_threshold_spin.value()), ) # ------------------------------------------------------------------ signals @@ -863,6 +876,9 @@ class ThreeDPanel(QFrame): self.flow_threshold_label.setText(t("scene3d.flow_threshold")) self.flow_threshold_label.setToolTip(t("scene3d.flow_threshold_tip")) self.flow_threshold_spin.setToolTip(t("scene3d.flow_threshold_tip")) + self.depth_threshold_label.setText(t("scene3d.depth_threshold")) + self.depth_threshold_label.setToolTip(t("scene3d.depth_threshold_tip")) + self.depth_threshold_spin.setToolTip(t("scene3d.depth_threshold_tip")) self.scale_label.setText(t("scene3d.scale")) self.range_label.setText(t("scene3d.scale_range")) self.range_label.setToolTip(t("scene3d.scale_range_tip")) diff --git a/apps/studio/src/orisys_studio/scene3d/prefs.py b/apps/studio/src/orisys_studio/scene3d/prefs.py index 4630cfa..f54eb3a 100644 --- a/apps/studio/src/orisys_studio/scene3d/prefs.py +++ b/apps/studio/src/orisys_studio/scene3d/prefs.py @@ -89,6 +89,7 @@ def settings_to_prefs(settings: DisplaySettings) -> dict[str, Any]: "flow_grid_spacing": int(settings.flow_grid_spacing), "max_arrows": int(settings.max_arrows), "depth_scale_adaptive": bool(settings.depth_scale_adaptive), + "depth_threshold": float(settings.depth_threshold), } if scale is not None: block["color_scale"] = { @@ -171,4 +172,5 @@ def settings_from_prefs( flow_grid_spacing=spacing, max_arrows=max_arrows, depth_scale_adaptive=_flag(raw.get("depth_scale_adaptive"), False), + depth_threshold=min(max(_number(raw.get("depth_threshold"), 0.0), 0.0), 1000.0), ) diff --git a/apps/studio/tests/test_scene3d_gl_smoke.py b/apps/studio/tests/test_scene3d_gl_smoke.py index b4cada4..447d980 100644 --- a/apps/studio/tests/test_scene3d_gl_smoke.py +++ b/apps/studio/tests/test_scene3d_gl_smoke.py @@ -280,6 +280,38 @@ def _plate_mode_rgb(pixels: np.ndarray) -> np.ndarray: return values[int(np.argmax(counts))].astype(float) +def test_depth_threshold_only_changes_depth_colours(gl_widget, qt_app): + from orisys_studio.scene3d.gl_backend import lut_rgba + + widget, _, _ = gl_widget + assets = synthetic.poc_scene() + values = np.full(assets.shape_hw, 0.05, dtype=np.float32) + depth = FieldDescriptor("depth_response", "1", "approximate", False) + flow = FieldDescriptor("flow_magnitude", "px", "approximate", False) + + def settings(descriptor, threshold): + return DisplaySettings( + housing_visible=False, + depth_threshold=threshold, + color_scale=ColorScale( + descriptor=descriptor, lower=0.0, upper=1.0, lut_id="sequential" + ), + ) + + widget.set_settings(settings(depth, 0.0)) + widget.present(_frame(assets, values, descriptor=depth, frame_id=30)) + unfiltered = _plate_mean_rgb(_render(widget, qt_app)) + widget.set_settings(settings(depth, 0.1)) + filtered = _plate_mean_rgb(_render(widget, qt_app)) + base = lut_rgba("sequential")[0, 0, :3].astype(float) + assert np.abs(filtered - base).max() < 8.0 + assert np.abs(unfiltered - filtered).max() > 10.0 + + widget.set_settings(settings(flow, 0.1)) + widget.present(_frame(assets, values, descriptor=flow, frame_id=31)) + assert np.abs(_plate_mean_rgb(_render(widget, qt_app)) - unfiltered).max() < 8.0 + + def test_a_press_does_not_move_the_surfaces_base_colour(gl_widget, qt_app): """The base colour follows the field, not the frame's current response. diff --git a/apps/studio/tests/test_scene3d_pane_ui.py b/apps/studio/tests/test_scene3d_pane_ui.py index 02b33ff..ae631e7 100644 --- a/apps/studio/tests/test_scene3d_pane_ui.py +++ b/apps/studio/tests/test_scene3d_pane_ui.py @@ -57,6 +57,7 @@ def test_the_pane_holds_a_legend_and_no_settings(panel): panel.deform_check, panel.gain_spin, panel.flow_threshold_spin, + panel.depth_threshold_spin, panel.scale_combo, panel.depth_adaptive_check, panel.fps_combo, @@ -107,6 +108,14 @@ def test_adaptive_depth_scale_round_trips_through_the_dialog(panel): assert panel.settings().depth_scale_adaptive is False +def test_depth_threshold_round_trips_through_the_dialog(panel): + from orisys_app_shared.scene3d.contracts import DisplaySettings + + panel.apply_settings(DisplaySettings(depth_threshold=0.025)) + assert panel.depth_threshold_spin.value() == pytest.approx(0.025) + assert panel.settings().depth_threshold == pytest.approx(0.025) + + def test_arrow_threshold_round_trips_and_feeds_the_shared_policy(panel): """The threshold the dialog writes is the one both views sample with.""" from orisys_app_shared.scene3d.contracts import DisplaySettings diff --git a/apps/studio/tests/test_scene3d_stage1.py b/apps/studio/tests/test_scene3d_stage1.py index c3da292..757a15b 100644 --- a/apps/studio/tests/test_scene3d_stage1.py +++ b/apps/studio/tests/test_scene3d_stage1.py @@ -261,6 +261,7 @@ def test_shader_sources_are_two_dialects_of_one_body(): len(shader_header("gles30")): ] assert "texture(u_lut" in desktop_fragment and "texture(u_lut" in es_fragment + assert "value < u_depth_threshold" in desktop_fragment # The surface reads *two textures* and decides validity from one of them. There is # no per-vertex scalar or validity attribute, and no ``isnan`` rule: the NaN that # rule used to catch was produced by the CPU gather this path replaced. diff --git a/apps/studio/tests/test_scene3d_view.py b/apps/studio/tests/test_scene3d_view.py index 0c4ffe3..fe68077 100644 --- a/apps/studio/tests/test_scene3d_view.py +++ b/apps/studio/tests/test_scene3d_view.py @@ -474,6 +474,7 @@ def test_the_display_settings_round_trip(): flow_grid_spacing=12, max_arrows=250, depth_scale_adaptive=True, + depth_threshold=0.025, color_scale=ColorScale( descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False), lower=0.25, @@ -497,7 +498,9 @@ def test_the_display_settings_round_trip(): assert (restored.color_scale.lower, restored.color_scale.upper) == (0.25, 4.5) assert restored.color_scale.mode == "fixed" assert restored.depth_scale_adaptive is True + assert restored.depth_threshold == 0.025 assert scene3d_prefs.settings_from_prefs({}).depth_scale_adaptive is False + assert scene3d_prefs.settings_from_prefs({"depth_threshold": -1}).depth_threshold == 0.0 def test_a_saved_descriptor_is_never_restored_as_a_measurement(): diff --git a/shared/src/orisys_app_shared/acquisition/arrows.py b/shared/src/orisys_app_shared/acquisition/arrows.py index 152e033..6e5821a 100644 --- a/shared/src/orisys_app_shared/acquisition/arrows.py +++ b/shared/src/orisys_app_shared/acquisition/arrows.py @@ -598,6 +598,7 @@ def depth_map_to_bgr( *, value_range: tuple[float, float] | None = None, invalid_mask: np.ndarray | None = None, + threshold: float = 0.0, ) -> np.ndarray | None: """Convert a float depth/divergence map to a colormap BGR image. @@ -608,6 +609,9 @@ def depth_map_to_bgr( ``invalid_mask`` marks samples that carry no depth. They are painted with a neutral grey so "no data" can never be mistaken for "zero depth". + + ``threshold`` only changes valid pixels' colours: below it they use the + first ramp colour, while the source depth and the colour span stay intact. """ if depth is None: return None @@ -628,6 +632,8 @@ def depth_map_to_bgr( scaled = np.zeros(arr.shape, dtype=np.float64) scaled = np.clip(scaled, 0.0, 1.0) normalized = (scaled * 255.0).astype(np.uint8) + if threshold > 0.0: + normalized[finite & (arr < threshold)] = 0 image = cv2.applyColorMap( normalized, cv2.COLORMAP_JET if colormap is None else colormap ) diff --git a/shared/src/orisys_app_shared/acquisition/image_backend.py b/shared/src/orisys_app_shared/acquisition/image_backend.py index 14ad1ec..c852143 100644 --- a/shared/src/orisys_app_shared/acquisition/image_backend.py +++ b/shared/src/orisys_app_shared/acquisition/image_backend.py @@ -418,6 +418,7 @@ def render_image( theme: str = "dark", draw_style: str = "box", depth_range: tuple[float, float] | None = None, + depth_threshold: float = 0.0, canvas_bgr: Sequence[int] | None = None, domain_mask: np.ndarray | None = None, arrow_policy=None, @@ -441,6 +442,9 @@ def render_image( with, so both figures trigger at the same strength. ``None`` means the draw style's own defaults. + ``depth_threshold`` changes depth colouring only. It never changes the frame's + measured depth, its valid mask or the arrows drawn over it. + ``orientation`` is a **display** convention for the domain-grid views: the picture is turned or mirrored once it has been drawn, so the frame's own ``flow``, ``valid_mask`` and ``image_bgr`` are untouched — a recorded session's flow is stored in the domain's @@ -460,6 +464,7 @@ def render_image( frame.depth, flow_colormap(theme), value_range=depth_range, + threshold=depth_threshold, invalid_mask=boundary, ) if wanted == DEPTH: diff --git a/shared/src/orisys_app_shared/scene3d/contracts.py b/shared/src/orisys_app_shared/scene3d/contracts.py index 77d26ae..389075e 100644 --- a/shared/src/orisys_app_shared/scene3d/contracts.py +++ b/shared/src/orisys_app_shared/scene3d/contracts.py @@ -650,6 +650,8 @@ class DisplaySettings: #: refits both views to the current frame's finite minimum and maximum — the #: earlier per-frame colour bar. A fixed colour scale still wins over either. depth_scale_adaptive: bool = False + #: Paint depth response below this relative value with the base ramp colour. + depth_threshold: float = 0.0 @dataclass(frozen=True) diff --git a/shared/tests/test_image_backend.py b/shared/tests/test_image_backend.py index 49c40df..92bbb66 100644 --- a/shared/tests/test_image_backend.py +++ b/shared/tests/test_image_backend.py @@ -52,6 +52,20 @@ def test_depth_arrows_draws_the_flow_on_the_depth_picture(): assert not np.array_equal(over, plain) +def test_depth_display_threshold_uses_base_colour_without_changing_source(): + depth = np.array([[0.1, 0.4, 0.8]], dtype=np.float32) + original = depth.copy() + frame = backend.ImageFrame(_image(1, 3), depth=depth) + plain = backend.render_image(frame, mode="depth", depth_range=(0.0, 1.0)) + tuned = backend.render_image( + frame, mode="depth", depth_range=(0.0, 1.0), depth_threshold=0.5 + ) + assert plain is not None and tuned is not None + assert np.array_equal(tuned[0, 0], tuned[0, 1]) + assert np.array_equal(tuned[0, 2], plain[0, 2]) + assert np.array_equal(depth, original) + + def test_a_recorded_frame_is_raw_and_says_so(): """No flow and no depth: the frame *is* the picture, and the note explains it.""" plan = backend.plan_image(backend.ImageFrame(_image(), source=backend.SOURCE_REPLAY))