fix Studio reset, 3D base colour and arrow-trigger defects
The tracker reset was decided by which panel was on screen rather than by the session, the automatic colour range moved the 3D surface's base colour on the first press, and the 2D canvas and the 3D overlay applied different flow thresholds (622 arrows against 432 on a real recording). ui_check 600/0; apps/studio 380 items and shared 390 items with no failures; real-GL 18 items, only the pre-existing colour-scale failure; AutoCal 358 passed; both views now select identical arrows on all 42 frames of a real video.
This commit is contained in:
@@ -1204,6 +1204,11 @@ class StudioMainWindow(QMainWindow):
|
||||
# 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)
|
||||
# The tracker reset belongs to a *device session*, so the session state
|
||||
# decides it and nothing else: not the panel on screen (a view switch used
|
||||
# to leave a running session's reset unavailable and never restore it) and
|
||||
# not the transport's checked state (a paused session still owns the device).
|
||||
self.toolbar.set_reset_enabled(self.session.reset_available)
|
||||
if not self._running:
|
||||
self.toolbar.refresh_labels()
|
||||
self._sync_shortcut_actions()
|
||||
@@ -1516,6 +1521,21 @@ class StudioMainWindow(QMainWindow):
|
||||
def _draw_style(self) -> str:
|
||||
return self.display_ctl.draw_style()
|
||||
|
||||
def _arrow_policy(self):
|
||||
"""The one flow-arrow policy the 2D canvas and the 3D surface both sample with.
|
||||
|
||||
The thresholds and the sample grid are display settings (the pane's), so the
|
||||
policy is built from them here and handed to the 2D renderer; the 3D composer
|
||||
builds the same value from the same settings object
|
||||
(``scene3d.composer`` via ``visual_policy.policy_for_settings``). That is what
|
||||
makes the two figures trigger at the same strength: previously each path built
|
||||
its own policy *and* the 3D one applied a hysteresis the canvas did not, so the
|
||||
same frame could carry arrows in one figure and none in the other.
|
||||
"""
|
||||
from orisys_app_shared.scene3d.visual_policy import policy_for_settings
|
||||
|
||||
return policy_for_settings(self.mesh3d_panel.display_settings())
|
||||
|
||||
def _on_rotate_arrows(self) -> None:
|
||||
self.display_ctl.on_rotate_arrows()
|
||||
|
||||
@@ -2087,8 +2107,9 @@ class StudioMainWindow(QMainWindow):
|
||||
self.cal_panel.set_running(True)
|
||||
self.toolbar.set_source_controls_enabled(False)
|
||||
self.sensor_picker.set_picker_enabled(False)
|
||||
self.toolbar.set_reset_enabled(False)
|
||||
# Keep the stop affordance consistent with the phase we just entered.
|
||||
# Keep the stop affordance (and the reset, which this connecting session does
|
||||
# not own yet) consistent with the phase we just entered: one rule, stated in
|
||||
# ``_update_run_enabled``.
|
||||
self._update_run_enabled()
|
||||
self._refresh_status_rail()
|
||||
self._refresh_record_state()
|
||||
@@ -2127,9 +2148,9 @@ class StudioMainWindow(QMainWindow):
|
||||
# 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)
|
||||
# The session is over: the reset goes with it, through the one rule that owns it.
|
||||
self._update_run_enabled()
|
||||
keep_frame = reason in ("ended", "disconnected")
|
||||
if not keep_frame:
|
||||
@@ -2331,8 +2352,10 @@ class StudioMainWindow(QMainWindow):
|
||||
is_new = packet_key != self._last_plot_packet_key
|
||||
|
||||
if is_new and self.session.note_first_frame():
|
||||
self.toolbar.set_reset_enabled(True)
|
||||
self._sync_shortcut_actions()
|
||||
# The phase just became ``running``, which is the session state the rail
|
||||
# reads (the reset keys off it): ask the one owner of that rule rather
|
||||
# than stating the enablement a second time here.
|
||||
self._update_run_enabled()
|
||||
# No status-bar line here: the rail's phase chip says 采集中 and the
|
||||
# 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.
|
||||
@@ -2561,6 +2584,7 @@ class StudioMainWindow(QMainWindow):
|
||||
depth_range=self._depth_range(),
|
||||
canvas_bgr=hex_to_bgr(color(self._theme, "arrow_canvas", "#0B1830")),
|
||||
domain_mask=boundary,
|
||||
arrow_policy=self._arrow_policy(),
|
||||
)
|
||||
if rendered is None:
|
||||
# Never substitute another picture here: a missing depth map that showed
|
||||
|
||||
@@ -263,12 +263,21 @@ def shader_source(profile: Profile, program: str) -> tuple[str, str]:
|
||||
return header + vertex, header + body
|
||||
|
||||
|
||||
def _valid_scalar_range(scalar, valid) -> tuple[float, float] | None:
|
||||
def _valid_scalar_range(
|
||||
scalar, valid, *, signed: bool = False
|
||||
) -> tuple[float, float] | None:
|
||||
"""Robust P5/P95 range of *valid* samples, or ``None`` when there are none.
|
||||
|
||||
Using min/max made empty-field noise paint the whole LUT. A percentile span
|
||||
with a minimum width keeps weak signals visible without amplifying floor
|
||||
noise into a full contact.
|
||||
keeps weak signals visible without amplifying floor noise into a full contact.
|
||||
|
||||
``signed`` is the *field's* own statement (``FieldDescriptor.signed``), and it is
|
||||
what anchors the range at the field's zero rather than at the data's current
|
||||
spread — see :func:`_scale_bounds`. The anchoring is the reason the surface's base
|
||||
colour is a property of the field rather than of the frame: without it a flat
|
||||
field was centred on its own midpoint (the middle of the ramp) and the first
|
||||
press moved it to the first colour of the ramp, re-colouring the resting surface
|
||||
and leaving it changed after the release.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
@@ -286,13 +295,19 @@ def _valid_scalar_range(scalar, valid) -> tuple[float, float] | None:
|
||||
lower = float(np.percentile(usable, 5.0))
|
||||
upper = float(np.percentile(usable, 95.0))
|
||||
# Minimum span: avoid collapsing nearly-constant fields into one colour.
|
||||
span = upper - lower
|
||||
min_span = max(abs(lower) * 0.05, abs(upper) * 0.05, 1e-3)
|
||||
if span < min_span:
|
||||
mid = 0.5 * (lower + upper)
|
||||
lower = mid - 0.5 * min_span
|
||||
upper = mid + 0.5 * min_span
|
||||
return lower, upper
|
||||
if signed:
|
||||
# A signed quantity is drawn with the diverging ramp, whose midpoint is the
|
||||
# field's zero: the range stays symmetric about zero so that zero keeps the
|
||||
# ramp's own middle colour however strong the response on screen is.
|
||||
extent = max(abs(lower), abs(upper), 0.5 * min_span)
|
||||
return -extent, extent
|
||||
# An unsigned quantity is drawn with the sequential ramp, whose first colour is
|
||||
# the field's zero ("no response"). Anchoring the lower bound there — rather than
|
||||
# at this frame's P5 — is what makes the base colour constant across a press; the
|
||||
# upper bound still follows the measurement.
|
||||
low = min(0.0, lower)
|
||||
return low, max(upper, low + min_span)
|
||||
|
||||
|
||||
def _blend_scale_range(
|
||||
@@ -321,6 +336,12 @@ def _scale_bounds(scale, automatic: tuple[float, float] | None) -> tuple[float,
|
||||
colour. An automatic range with no valid samples falls back to a fixed 0..1 so
|
||||
the shader always receives a usable interval — never NaN, and never a division
|
||||
by a span of zero.
|
||||
|
||||
The automatic range is anchored at the field's *zero* by the descriptor's own
|
||||
signedness (see :func:`_valid_scalar_range`): the ramp's first colour is "no
|
||||
response" for an unsigned field and the diverging ramp's middle is zero for a
|
||||
signed one. So the surface's base colour is decided by which quantity is drawn —
|
||||
a property of the field — and not by how strong the current frame's response is.
|
||||
"""
|
||||
if scale is None:
|
||||
fallback = automatic or (0.0, 1.0)
|
||||
@@ -768,7 +789,9 @@ class GLRenderer:
|
||||
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
|
||||
self._upload_field_textures(field)
|
||||
self._last_upload_ms = (time.perf_counter() - started) * 1000.0
|
||||
measured = _valid_scalar_range(field.scalar, field.valid)
|
||||
measured = _valid_scalar_range(
|
||||
field.scalar, field.valid, signed=bool(field.descriptor.signed)
|
||||
)
|
||||
self._scalar_range = _blend_scale_range(self._scalar_range, measured)
|
||||
self._upload_overlays(frame)
|
||||
|
||||
|
||||
@@ -813,9 +813,12 @@ class Scene3DView(QWidget):
|
||||
Skipped when it is the same descriptor as last time: this runs once per
|
||||
displayed frame, and the label and the settings only change when the claim
|
||||
does. When it does change, the display settings are re-issued so the colour
|
||||
scale's descriptor — hence the choice between a sequential and a diverging
|
||||
ramp — follows the live field rather than the one that was on screen when the
|
||||
operator last touched a control.
|
||||
scale describes the field actually on screen rather than the one that was
|
||||
there when the operator last touched a control: the descriptor decides which
|
||||
ramp is used and where the automatic range is anchored (the field's zero for an
|
||||
unsigned quantity, zero at the ramp's middle for a signed one), which is what
|
||||
keeps the surface's base colour a property of the *field* and not of the
|
||||
frame's current response.
|
||||
"""
|
||||
if descriptor == self._last_descriptor:
|
||||
return
|
||||
|
||||
@@ -171,6 +171,19 @@ class SessionController(QObject):
|
||||
worker = self._active_worker
|
||||
return worker is not None and bool(getattr(worker, "is_running", False))
|
||||
|
||||
@property
|
||||
def reset_available(self) -> bool:
|
||||
"""Whether the tracker reset has a device session to act on.
|
||||
|
||||
Zeroing is a **device** action, so it is offered exactly while a session has
|
||||
opened the device and is producing frames — the phase ``running``, the same
|
||||
moment the rail stops saying 连接中. It is deliberately not derived from the
|
||||
transport's checked state (a paused session still owns its device, so it can
|
||||
still be zeroed) nor from the view on screen, which is not a fact about the
|
||||
session at all.
|
||||
"""
|
||||
return self._phase == "running"
|
||||
|
||||
def set_phase(self, phase: str) -> str:
|
||||
"""Track the acquisition phase. An unknown phase is ``idle``, never a new one."""
|
||||
wanted = str(phase) if str(phase) in SESSION_PHASES else "idle"
|
||||
|
||||
@@ -468,13 +468,18 @@ class ToolbarWidget(QWidget):
|
||||
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``.
|
||||
|
||||
Reset is left alone here for the same reason ``_on_run_toggled`` leaves it
|
||||
alone: unchecked means "paused" as often as it means "stopped", and a paused
|
||||
session still owns its device. Whether the tracker can be zeroed is the
|
||||
session's answer (``StudioMainWindow._update_run_enabled``), not this
|
||||
control's, so a view switch that merely states the transport cannot take a
|
||||
device action away.
|
||||
"""
|
||||
self.btn_run.blockSignals(True)
|
||||
self.btn_run.setChecked(bool(checked))
|
||||
self.btn_run.blockSignals(False)
|
||||
self.refresh_labels()
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user