把 Studio 2D 画面与深度色标抽到 FrameView。
深度闩、域掩码与 _show_image_frame 离开主窗口;ImageSink 方法名保留为转发。朝向与深度闩测试、以及 monkeypatch render_image 的画面测试通过。
This commit is contained in:
@@ -8,7 +8,6 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
|
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
|
||||||
from orisys_app_shared.acquisition.display_orientation import (
|
from orisys_app_shared.acquisition.display_orientation import (
|
||||||
DEFAULT_FINGER_ORIENTATION,
|
DEFAULT_FINGER_ORIENTATION,
|
||||||
@@ -16,16 +15,13 @@ from orisys_app_shared.acquisition.display_orientation import (
|
|||||||
normalise_orientation,
|
normalise_orientation,
|
||||||
)
|
)
|
||||||
from orisys_app_shared.acquisition.image_backend import (
|
from orisys_app_shared.acquisition.image_backend import (
|
||||||
DEPTH,
|
|
||||||
SOURCE_ANALYSIS,
|
SOURCE_ANALYSIS,
|
||||||
SOURCE_LIVE,
|
SOURCE_LIVE,
|
||||||
SOURCE_PREVIEW,
|
SOURCE_PREVIEW,
|
||||||
SOURCE_RECOMPUTE,
|
SOURCE_RECOMPUTE,
|
||||||
SOURCE_REPLAY,
|
SOURCE_REPLAY,
|
||||||
DomainMaskLatch,
|
|
||||||
ImageFrame,
|
ImageFrame,
|
||||||
plan_image,
|
render_image, # re-exported: tests monkeypatch ``app_window.render_image``
|
||||||
render_image,
|
|
||||||
)
|
)
|
||||||
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
|
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
|
||||||
from orisys_app_shared.force.filters import FilterSettings
|
from orisys_app_shared.force.filters import FilterSettings
|
||||||
@@ -52,6 +48,7 @@ from orisys_studio import session_metadata
|
|||||||
from orisys_studio.acquire_presenter import AcquirePresenter
|
from orisys_studio.acquire_presenter import AcquirePresenter
|
||||||
from orisys_studio.calibration_service import CalibrationService
|
from orisys_studio.calibration_service import CalibrationService
|
||||||
from orisys_studio.display_prefs import DisplayPrefsController
|
from orisys_studio.display_prefs import DisplayPrefsController
|
||||||
|
from orisys_studio.frame_view import FrameView
|
||||||
from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs
|
from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs
|
||||||
from orisys_studio.replay import ReplayEngine
|
from orisys_studio.replay import ReplayEngine
|
||||||
from orisys_studio.replay_controller import ReplayController
|
from orisys_studio.replay_controller import ReplayController
|
||||||
@@ -228,13 +225,8 @@ class StudioMainWindow(QMainWindow):
|
|||||||
#: Built once the panels exist (see below); until then the status rail
|
#: Built once the panels exist (see below); until then the status rail
|
||||||
#: treats calibration as unknown.
|
#: treats calibration as unknown.
|
||||||
self.acquire_presenter: AcquirePresenter | None = None
|
self.acquire_presenter: AcquirePresenter | None = None
|
||||||
self._depth_range_value: tuple[float, float] | None = None
|
#: Built once the panels exist; 2D paint and shared depth span live there.
|
||||||
self._last_depth_frame = None
|
self.frame_view: FrameView | None = None
|
||||||
#: The 2D domain boundary, latched for the session the way the depth range is:
|
|
||||||
#: the island is the device's own geometry and is rebuilt from the same remap
|
|
||||||
#: every frame, so re-deriving the edge per frame redrew a boundary that had not
|
|
||||||
#: moved (see ``image_backend.DomainMaskLatch``).
|
|
||||||
self._domain_mask = DomainMaskLatch()
|
|
||||||
|
|
||||||
icon = QIcon(str(logo_path()))
|
icon = QIcon(str(logo_path()))
|
||||||
if not icon.isNull():
|
if not icon.isNull():
|
||||||
@@ -266,12 +258,13 @@ class StudioMainWindow(QMainWindow):
|
|||||||
|
|
||||||
self.content_splitter = QSplitter(Qt.Orientation.Horizontal)
|
self.content_splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||||
self.mesh3d_panel = Mesh3dPanel(theme=self._theme)
|
self.mesh3d_panel = Mesh3dPanel(theme=self._theme)
|
||||||
|
self.arrow_panel = ArrowPanel(theme=self._theme)
|
||||||
# The island latch is *upstream of both views*: the 2D canvas paints the
|
# The island latch is *upstream of both views*: the 2D canvas paints the
|
||||||
# latched boundary, and the 3D pane's validity texture is that same mask. Two
|
# latched boundary, and the 3D pane's validity texture is that same mask. Two
|
||||||
# views, one decision about where the measurement is — see
|
# views, one decision about where the measurement is — see
|
||||||
# ``image_backend.DomainMaskLatch`` and ``Scene3DView.set_domain_latch``.
|
# ``image_backend.DomainMaskLatch`` and ``Scene3DView.set_domain_latch``.
|
||||||
self.mesh3d_panel.set_domain_latch(self._domain_mask)
|
self.frame_view = FrameView(self.arrow_panel, self.mesh3d_panel)
|
||||||
self.arrow_panel = ArrowPanel(theme=self._theme)
|
self.mesh3d_panel.set_domain_latch(self.frame_view.domain_mask)
|
||||||
self.force_plots = ForcePlotsPanel(theme=self._theme)
|
self.force_plots = ForcePlotsPanel(theme=self._theme)
|
||||||
# The charts browse the whole session, not just their rolling buffer.
|
# The charts browse the whole session, not just their rolling buffer.
|
||||||
self.force_plots.set_store(self.session_store)
|
self.force_plots.set_store(self.session_store)
|
||||||
@@ -1999,7 +1992,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
# A different product is a different UV island; the previous latch must not
|
# A different product is a different UV island; the previous latch must not
|
||||||
# paint the new canvas (a box result has no mask and would inherit a finger's).
|
# paint the new canvas (a box result has no mask and would inherit a finger's).
|
||||||
self._domain_mask.reset()
|
self.frame_view.reset_domain_mask()
|
||||||
self.mesh3d_panel.show_reference_only()
|
self.mesh3d_panel.show_reference_only()
|
||||||
# The pane follows the sensor: a box sensor has no 3D scene, and a finger's
|
# The pane follows the sensor: a box sensor has no 3D scene, and a finger's
|
||||||
# missing package must not be reported while looking at a box.
|
# missing package must not be reported while looking at a box.
|
||||||
@@ -2312,13 +2305,11 @@ class StudioMainWindow(QMainWindow):
|
|||||||
# starts empty and is filled from that confirmation — a path is not a
|
# starts empty and is filled from that confirmation — a path is not a
|
||||||
# version, and the same path can hold different numbers.
|
# version, and the same path can hold different numbers.
|
||||||
self._active_calibration_id = ""
|
self._active_calibration_id = ""
|
||||||
self._depth_range_value = None
|
self.frame_view.clear_depth_latch()
|
||||||
self._last_depth_frame = None
|
|
||||||
self._publish_shared_depth_scale(None)
|
|
||||||
# The new session has not established a domain boundary yet; the previous
|
# The new session has not established a domain boundary yet; the previous
|
||||||
# session's island must not survive into it (a different sensor is a different
|
# session's island must not survive into it (a different sensor is a different
|
||||||
# island, and the latch compares against what it holds).
|
# island, and the latch compares against what it holds).
|
||||||
self._domain_mask.reset()
|
self.frame_view.reset_domain_mask()
|
||||||
# The session's own bookkeeping (its counter and telemetry) is the
|
# The session's own bookkeeping (its counter and telemetry) is the
|
||||||
# controller's: telling it a run starts is what drops the previous run's
|
# controller's: telling it a run starts is what drops the previous run's
|
||||||
# numbers, so none of them can be read as this run's.
|
# numbers, so none of them can be read as this run's.
|
||||||
@@ -2611,147 +2602,34 @@ class StudioMainWindow(QMainWindow):
|
|||||||
# Compose after deliver_measurement submitted this tick's packet.
|
# Compose after deliver_measurement submitted this tick's packet.
|
||||||
presenter.tick_3d(display_paused=self._display_paused)
|
presenter.tick_3d(display_paused=self._display_paused)
|
||||||
|
|
||||||
# ------------------------------------------------------------ depth scale
|
# ---------------------------------------------------- frame view (forwards)
|
||||||
def _depth_display_settings(self):
|
# Depth colour span, domain latch and the 2D paint live in ``FrameView``. These
|
||||||
"""The display settings that decide the shared depth colour span."""
|
# keep the names tests and ``ImageSink`` callers already use.
|
||||||
panel = getattr(self, "mesh3d_panel", None)
|
@property
|
||||||
if panel is None or not hasattr(panel, "display_settings"):
|
def _domain_mask(self):
|
||||||
return None
|
return self.frame_view.domain_mask
|
||||||
try:
|
|
||||||
return panel.display_settings()
|
|
||||||
except Exception: # noqa: BLE001 - a missing pane must not blank the 2D view
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _fixed_depth_span(self) -> tuple[float, float] | None:
|
@property
|
||||||
"""Typed colour bounds, when the operator chose a fixed scale."""
|
def _depth_range_value(self) -> tuple[float, float] | None:
|
||||||
settings = self._depth_display_settings()
|
return self.frame_view.depth_range_value
|
||||||
scale = getattr(settings, "color_scale", None) if settings is not None else None
|
|
||||||
if scale is None or str(getattr(scale, "mode", "")) != "fixed":
|
|
||||||
return None
|
|
||||||
lo = float(scale.lower)
|
|
||||||
hi = float(scale.upper)
|
|
||||||
if hi <= lo:
|
|
||||||
hi = lo + 1.0
|
|
||||||
return lo, hi
|
|
||||||
|
|
||||||
def _depth_scale_adaptive(self) -> bool:
|
@_depth_range_value.setter
|
||||||
settings = self._depth_display_settings()
|
def _depth_range_value(self, value: tuple[float, float] | None) -> None:
|
||||||
return bool(getattr(settings, "depth_scale_adaptive", False))
|
self.frame_view.depth_range_value = value
|
||||||
|
|
||||||
def _depth_scale_mode(self) -> str:
|
@property
|
||||||
"""``fixed``, ``adaptive`` or ``latched`` — the rule both views follow."""
|
def _last_depth_frame(self):
|
||||||
if self._fixed_depth_span() is not None:
|
return self.frame_view.last_depth_frame
|
||||||
return "fixed"
|
|
||||||
if self._depth_scale_adaptive():
|
|
||||||
return "adaptive"
|
|
||||||
return "latched"
|
|
||||||
|
|
||||||
def _frame_depth_span(self, depth) -> tuple[float, float] | None:
|
@_last_depth_frame.setter
|
||||||
"""Finite minimum and maximum of one depth map."""
|
def _last_depth_frame(self, value) -> None:
|
||||||
if depth is None:
|
self.frame_view.last_depth_frame = value
|
||||||
return None
|
|
||||||
arr = np.asarray(depth, dtype=np.float64)
|
|
||||||
finite = np.isfinite(arr)
|
|
||||||
if arr.size == 0 or not np.any(finite):
|
|
||||||
return None
|
|
||||||
lo = float(np.min(arr[finite]))
|
|
||||||
hi = float(np.max(arr[finite]))
|
|
||||||
if hi <= lo:
|
|
||||||
hi = lo + 1.0
|
|
||||||
return lo, hi
|
|
||||||
|
|
||||||
def _depth_range(self) -> tuple[float, float] | None:
|
def _depth_range(self) -> tuple[float, float] | None:
|
||||||
"""The session latch: the first frame that carries finite depth.
|
return self.frame_view.depth_range()
|
||||||
|
|
||||||
Re-fitting the range on every frame made colours drift, so two frames
|
|
||||||
could look identical while holding different depths. Latching keeps
|
|
||||||
frames comparable for the whole session. The per-frame colour bar is
|
|
||||||
the adaptive setting; this method is only the latch.
|
|
||||||
"""
|
|
||||||
if self._depth_range_value is not None:
|
|
||||||
return self._depth_range_value
|
|
||||||
span = self._frame_depth_span(self._last_depth_frame)
|
|
||||||
if span is None:
|
|
||||||
return None
|
|
||||||
self._depth_range_value = span
|
|
||||||
return self._depth_range_value
|
|
||||||
|
|
||||||
def _active_depth_span(self) -> tuple[float, float] | None:
|
|
||||||
"""The span both views colour with for the depth now on screen.
|
|
||||||
|
|
||||||
Leaving the adaptive or fixed rule re-latches on this frame, so the
|
|
||||||
next comparison does not keep a range from the other mode.
|
|
||||||
"""
|
|
||||||
mode = self._depth_scale_mode()
|
|
||||||
previous = getattr(self, "_depth_scale_mode_applied", "latched")
|
|
||||||
if mode == "latched" and previous != "latched":
|
|
||||||
self._depth_range_value = None
|
|
||||||
self._depth_scale_mode_applied = mode
|
|
||||||
if mode == "fixed":
|
|
||||||
return self._fixed_depth_span()
|
|
||||||
if mode == "adaptive":
|
|
||||||
return self._frame_depth_span(self._last_depth_frame)
|
|
||||||
return self._depth_range()
|
|
||||||
|
|
||||||
def _depth_scale_tip_key(self) -> str:
|
|
||||||
mode = getattr(self, "_depth_scale_mode_applied", "latched")
|
|
||||||
if mode == "fixed":
|
|
||||||
return "panels.depth_scale_tip_fixed"
|
|
||||||
if mode == "adaptive":
|
|
||||||
return "panels.depth_scale_tip_adaptive"
|
|
||||||
return "panels.depth_scale_tip"
|
|
||||||
|
|
||||||
def _publish_shared_depth_scale(self, span: tuple[float, float] | None) -> None:
|
|
||||||
"""Hand the depth colour span to the 3D pane, when one exists."""
|
|
||||||
panel = getattr(self, "mesh3d_panel", None)
|
|
||||||
if panel is not None and hasattr(panel, "set_shared_depth_scale"):
|
|
||||||
panel.set_shared_depth_scale(span)
|
|
||||||
|
|
||||||
def _depth_scale_text(self, frame: ImageFrame | None = None) -> str:
|
|
||||||
"""The colour scale, plus — when the frame's depth is exactly zero — why it is flat.
|
|
||||||
|
|
||||||
A depth view of an all-zero map is a flat island in the ramp's low colour, which
|
|
||||||
looks the same as a broken view. The frame knows which it is: the pass stored the
|
|
||||||
channel, its non-zero count and its range, so "0 of 84,000 pixels" can be said
|
|
||||||
instead of leaving the operator to wonder. It is not the same statement as
|
|
||||||
``panels.depth_unavailable``, which is for a frame that carries no depth at all.
|
|
||||||
"""
|
|
||||||
span = self._active_depth_span()
|
|
||||||
text = "" if span is None else t(
|
|
||||||
"panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}"
|
|
||||||
)
|
|
||||||
if frame is None or frame.depth is None:
|
|
||||||
return text
|
|
||||||
array = np.asarray(frame.depth)
|
|
||||||
if array.size and not bool(np.any(array)):
|
|
||||||
empty = t("panels.depth_empty", pixels=int(array.size))
|
|
||||||
return f"{text} · {empty}" if text else empty
|
|
||||||
return text
|
|
||||||
|
|
||||||
def reset_domain_mask(self) -> None:
|
def reset_domain_mask(self) -> None:
|
||||||
"""Drop the latched UV island: a new recording or sensor has its own geometry."""
|
self.frame_view.reset_domain_mask()
|
||||||
self._domain_mask.reset()
|
|
||||||
|
|
||||||
def _latched_domain_mask(self, frame: ImageFrame):
|
|
||||||
"""The 2D domain boundary to draw, latched for the session.
|
|
||||||
|
|
||||||
Exactly the treatment ``_depth_range`` gives the colour scale, and for the same
|
|
||||||
reason: the boundary is not a per-frame measurement. It is the device's own UV
|
|
||||||
island — geometry, rebuilt from the same remap on every frame — and the depth
|
|
||||||
view paints it as its "no depth" grey, so rebuilding it per frame redrew an edge
|
|
||||||
that had not moved while any difference in ``frame.valid_mask`` repainted the
|
|
||||||
whole boundary, which reads as lag. The latch holds the boundary until a frame
|
|
||||||
no longer describes the same island (see ``DomainMaskLatch``).
|
|
||||||
|
|
||||||
A frame with no mask (the box chain) must not keep a previous finger island: that
|
|
||||||
shape fails the domain check and left a silent black arrow canvas on reload.
|
|
||||||
"""
|
|
||||||
held = self._domain_mask.mask
|
|
||||||
if frame.valid_mask is None and held is not None and frame.image_bgr is not None:
|
|
||||||
image_hw = tuple(int(v) for v in np.asarray(frame.image_bgr).shape[:2])
|
|
||||||
if tuple(int(v) for v in held.shape[:2]) != image_hw:
|
|
||||||
self._domain_mask.reset()
|
|
||||||
return self._domain_mask.update(frame.valid_mask)
|
|
||||||
|
|
||||||
def _render_run_image(self, packet) -> None:
|
def _render_run_image(self, packet) -> None:
|
||||||
"""Live / recompute entry into the shared 2D→3D deliver path."""
|
"""Live / recompute entry into the shared 2D→3D deliver path."""
|
||||||
@@ -2809,77 +2687,19 @@ class StudioMainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _show_image_frame(self, frame: ImageFrame) -> None:
|
def _show_image_frame(self, frame: ImageFrame) -> None:
|
||||||
"""The single 2D sink: plan the frame, render it, hand the panel one image.
|
"""The single 2D sink: plan the frame, render it, hand the panel one image."""
|
||||||
|
|
||||||
Live, recomputed, replayed and previewed frames all arrive here, so the mode
|
|
||||||
set and the annotations follow the frame's own fields rather than which view
|
|
||||||
happens to be on screen.
|
|
||||||
"""
|
|
||||||
from orisys_app_shared.acquisition.arrows import arrow_display_scale, hex_to_bgr
|
|
||||||
|
|
||||||
plan = plan_image(frame)
|
|
||||||
self.arrow_panel.set_plan(plan)
|
|
||||||
mode = plan.resolve(self.arrow_panel.view_mode())
|
|
||||||
self.arrow_panel.set_view_mode(mode)
|
|
||||||
if self.acquire_presenter is not None:
|
|
||||||
self.acquire_presenter.note_rendered_mode(mode)
|
|
||||||
draw_style = self._draw_style()
|
draw_style = self._draw_style()
|
||||||
# The legend is decided by the same inputs as the picture: the arrow
|
presenter = self.acquire_presenter
|
||||||
# magnification of this sensor's draw style, and whether this frame
|
self.frame_view.show(
|
||||||
# carries a valid-area mask. It stays the *frame's* statement rather than the
|
|
||||||
# latched boundary's, because that is what the chip claims: the arrows are
|
|
||||||
# gated by this frame's own mask on every path, latched boundary or not.
|
|
||||||
boundary = self._latched_domain_mask(frame)
|
|
||||||
self.arrow_panel.set_arrow_scale(arrow_display_scale(draw_style))
|
|
||||||
self.arrow_panel.set_valid_area(frame.valid_mask is not None)
|
|
||||||
if frame.depth is not None:
|
|
||||||
# Remember every depth-bearing frame, whichever 2D mode is showing,
|
|
||||||
# so a later depth view and the 3D surface name the same interval.
|
|
||||||
self._last_depth_frame = frame.depth
|
|
||||||
span = self._active_depth_span()
|
|
||||||
self._publish_shared_depth_scale(span)
|
|
||||||
if mode == DEPTH:
|
|
||||||
self.arrow_panel.set_depth_scale(
|
|
||||||
self._depth_scale_text(frame),
|
|
||||||
tip_key=self._depth_scale_tip_key(),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.arrow_panel.set_depth_scale(None)
|
|
||||||
rendered = render_image(
|
|
||||||
frame,
|
frame,
|
||||||
plan,
|
|
||||||
mode=mode,
|
|
||||||
theme=self._theme,
|
theme=self._theme,
|
||||||
draw_style=draw_style,
|
draw_style=draw_style,
|
||||||
depth_range=span,
|
arrow_policy=self._arrow_policy(),
|
||||||
canvas_bgr=hex_to_bgr(color(self._theme, "arrow_canvas", "#0B1830")),
|
|
||||||
domain_mask=boundary,
|
|
||||||
# A frame read from a stored analysis result says which policy its *recovered*
|
|
||||||
# field can be sampled with; a live field is dense and takes the display's own.
|
|
||||||
arrow_policy=frame.arrow_policy or self._arrow_policy(),
|
|
||||||
# The 2D presentation of the finger's unrolled canvas. Display only: the
|
|
||||||
# frame's own flow/mask/image are handed over untouched, and the 3D pane is
|
|
||||||
# not involved at all (its frame is the design export's).
|
|
||||||
orientation=effective_orientation(
|
orientation=effective_orientation(
|
||||||
self._finger_orientation, draw_style=draw_style
|
self._finger_orientation, draw_style=draw_style
|
||||||
),
|
),
|
||||||
|
on_mode=None if presenter is None else presenter.note_rendered_mode,
|
||||||
)
|
)
|
||||||
diagnostic = (
|
|
||||||
None if rendered is None else getattr(rendered, "frame_diagnostic", None)
|
|
||||||
)
|
|
||||||
if rendered is None or diagnostic is not None:
|
|
||||||
# Never substitute another picture here: a missing depth map that showed
|
|
||||||
# the processed frame made "no depth" look like a depth reading. A draw
|
|
||||||
# failure used to return the black base image; say so instead.
|
|
||||||
if mode == DEPTH and rendered is None:
|
|
||||||
message = t("panels.depth_unavailable")
|
|
||||||
elif diagnostic is not None:
|
|
||||||
message = t("panels.arrow_render_failed")
|
|
||||||
else:
|
|
||||||
message = t("panels.arrow_empty")
|
|
||||||
self.arrow_panel.clear_preview(message)
|
|
||||||
return
|
|
||||||
self.arrow_panel.show_frame(bgr_to_qimage(rendered))
|
|
||||||
|
|
||||||
# ------------------------------------------------------ analysis (Part B)
|
# ------------------------------------------------------ analysis (Part B)
|
||||||
# What this window owes the replay controller, and nothing more. The *policy*
|
# What this window owes the replay controller, and nothing more. The *policy*
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"""2D paint path and shared depth colour span for Studio.
|
||||||
|
|
||||||
|
Live, recomputed, replayed and previewed frames all arrive at
|
||||||
|
:meth:`FrameView.show`. The shell keeps the ImageSink method names and hands
|
||||||
|
each call the display facts (theme, draw style, arrow policy, orientation);
|
||||||
|
this module owns the latch state those paints share with the 3D pane.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from orisys_app_shared.acquisition.arrows import arrow_display_scale, hex_to_bgr
|
||||||
|
from orisys_app_shared.acquisition.image_backend import (
|
||||||
|
DEPTH,
|
||||||
|
DomainMaskLatch,
|
||||||
|
ImageFrame,
|
||||||
|
plan_image,
|
||||||
|
)
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from orisys_app_shared.theme import color
|
||||||
|
|
||||||
|
from orisys_studio.ui.arrow_panel import bgr_to_qimage
|
||||||
|
|
||||||
|
|
||||||
|
class FrameView:
|
||||||
|
"""One 2D sink: depth colour span, domain latch, and the arrow canvas paint."""
|
||||||
|
|
||||||
|
def __init__(self, arrow_panel: Any, mesh_panel: Any) -> None:
|
||||||
|
self._arrow_panel = arrow_panel
|
||||||
|
self._mesh_panel = mesh_panel
|
||||||
|
self._depth_range_value: tuple[float, float] | None = None
|
||||||
|
self._last_depth_frame = None
|
||||||
|
self._depth_scale_mode_applied = "latched"
|
||||||
|
#: The 2D domain boundary, latched for the session the way the depth range is:
|
||||||
|
#: the island is the device's own geometry and is rebuilt from the same remap
|
||||||
|
#: every frame, so re-deriving the edge per frame redrew a boundary that had not
|
||||||
|
#: moved (see ``image_backend.DomainMaskLatch``).
|
||||||
|
self.domain_mask = DomainMaskLatch()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def depth_range_value(self) -> tuple[float, float] | None:
|
||||||
|
return self._depth_range_value
|
||||||
|
|
||||||
|
@depth_range_value.setter
|
||||||
|
def depth_range_value(self, value: tuple[float, float] | None) -> None:
|
||||||
|
self._depth_range_value = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_depth_frame(self):
|
||||||
|
return self._last_depth_frame
|
||||||
|
|
||||||
|
@last_depth_frame.setter
|
||||||
|
def last_depth_frame(self, value) -> None:
|
||||||
|
self._last_depth_frame = value
|
||||||
|
|
||||||
|
def clear_depth_latch(self) -> None:
|
||||||
|
"""Drop the session colour span and tell the 3D pane nothing is shared yet."""
|
||||||
|
self._depth_range_value = None
|
||||||
|
self._last_depth_frame = None
|
||||||
|
self.publish_shared_depth_scale(None)
|
||||||
|
|
||||||
|
def reset_domain_mask(self) -> None:
|
||||||
|
"""Drop the latched UV island: a new recording or sensor has its own geometry."""
|
||||||
|
self.domain_mask.reset()
|
||||||
|
|
||||||
|
def depth_display_settings(self):
|
||||||
|
"""The display settings that decide the shared depth colour span."""
|
||||||
|
panel = self._mesh_panel
|
||||||
|
if panel is None or not hasattr(panel, "display_settings"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return panel.display_settings()
|
||||||
|
except Exception: # noqa: BLE001 - a missing pane must not blank the 2D view
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fixed_depth_span(self) -> tuple[float, float] | None:
|
||||||
|
"""Typed colour bounds, when the operator chose a fixed scale."""
|
||||||
|
settings = self.depth_display_settings()
|
||||||
|
scale = getattr(settings, "color_scale", None) if settings is not None else None
|
||||||
|
if scale is None or str(getattr(scale, "mode", "")) != "fixed":
|
||||||
|
return None
|
||||||
|
lo = float(scale.lower)
|
||||||
|
hi = float(scale.upper)
|
||||||
|
if hi <= lo:
|
||||||
|
hi = lo + 1.0
|
||||||
|
return lo, hi
|
||||||
|
|
||||||
|
def depth_scale_adaptive(self) -> bool:
|
||||||
|
settings = self.depth_display_settings()
|
||||||
|
return bool(getattr(settings, "depth_scale_adaptive", False))
|
||||||
|
|
||||||
|
def depth_scale_mode(self) -> str:
|
||||||
|
"""``fixed``, ``adaptive`` or ``latched`` — the rule both views follow."""
|
||||||
|
if self.fixed_depth_span() is not None:
|
||||||
|
return "fixed"
|
||||||
|
if self.depth_scale_adaptive():
|
||||||
|
return "adaptive"
|
||||||
|
return "latched"
|
||||||
|
|
||||||
|
def frame_depth_span(self, depth) -> tuple[float, float] | None:
|
||||||
|
"""Finite minimum and maximum of one depth map."""
|
||||||
|
if depth is None:
|
||||||
|
return None
|
||||||
|
arr = np.asarray(depth, dtype=np.float64)
|
||||||
|
finite = np.isfinite(arr)
|
||||||
|
if arr.size == 0 or not np.any(finite):
|
||||||
|
return None
|
||||||
|
lo = float(np.min(arr[finite]))
|
||||||
|
hi = float(np.max(arr[finite]))
|
||||||
|
if hi <= lo:
|
||||||
|
hi = lo + 1.0
|
||||||
|
return lo, hi
|
||||||
|
|
||||||
|
def depth_range(self) -> tuple[float, float] | None:
|
||||||
|
"""The session latch: the first frame that carries finite depth.
|
||||||
|
|
||||||
|
Re-fitting the range on every frame made colours drift, so two frames
|
||||||
|
could look identical while holding different depths. Latching keeps
|
||||||
|
frames comparable for the whole session. The per-frame colour bar is
|
||||||
|
the adaptive setting; this method is only the latch.
|
||||||
|
"""
|
||||||
|
if self._depth_range_value is not None:
|
||||||
|
return self._depth_range_value
|
||||||
|
span = self.frame_depth_span(self._last_depth_frame)
|
||||||
|
if span is None:
|
||||||
|
return None
|
||||||
|
self._depth_range_value = span
|
||||||
|
return self._depth_range_value
|
||||||
|
|
||||||
|
def active_depth_span(self) -> tuple[float, float] | None:
|
||||||
|
"""The span both views colour with for the depth now on screen.
|
||||||
|
|
||||||
|
Leaving the adaptive or fixed rule re-latches on this frame, so the
|
||||||
|
next comparison does not keep a range from the other mode.
|
||||||
|
"""
|
||||||
|
mode = self.depth_scale_mode()
|
||||||
|
previous = self._depth_scale_mode_applied
|
||||||
|
if mode == "latched" and previous != "latched":
|
||||||
|
self._depth_range_value = None
|
||||||
|
self._depth_scale_mode_applied = mode
|
||||||
|
if mode == "fixed":
|
||||||
|
return self.fixed_depth_span()
|
||||||
|
if mode == "adaptive":
|
||||||
|
return self.frame_depth_span(self._last_depth_frame)
|
||||||
|
return self.depth_range()
|
||||||
|
|
||||||
|
def depth_scale_tip_key(self) -> str:
|
||||||
|
mode = self._depth_scale_mode_applied
|
||||||
|
if mode == "fixed":
|
||||||
|
return "panels.depth_scale_tip_fixed"
|
||||||
|
if mode == "adaptive":
|
||||||
|
return "panels.depth_scale_tip_adaptive"
|
||||||
|
return "panels.depth_scale_tip"
|
||||||
|
|
||||||
|
def publish_shared_depth_scale(self, span: tuple[float, float] | None) -> None:
|
||||||
|
"""Hand the depth colour span to the 3D pane, when one exists."""
|
||||||
|
panel = self._mesh_panel
|
||||||
|
if panel is not None and hasattr(panel, "set_shared_depth_scale"):
|
||||||
|
panel.set_shared_depth_scale(span)
|
||||||
|
|
||||||
|
def depth_scale_text(self, frame: ImageFrame | None = None) -> str:
|
||||||
|
"""The colour scale, plus — when the frame's depth is exactly zero — why it is flat.
|
||||||
|
|
||||||
|
A depth view of an all-zero map is a flat island in the ramp's low colour, which
|
||||||
|
looks the same as a broken view. The frame knows which it is: the pass stored the
|
||||||
|
channel, its non-zero count and its range, so "0 of 84,000 pixels" can be said
|
||||||
|
instead of leaving the operator to wonder. It is not the same statement as
|
||||||
|
``panels.depth_unavailable``, which is for a frame that carries no depth at all.
|
||||||
|
"""
|
||||||
|
span = self.active_depth_span()
|
||||||
|
text = "" if span is None else t(
|
||||||
|
"panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}"
|
||||||
|
)
|
||||||
|
if frame is None or frame.depth is None:
|
||||||
|
return text
|
||||||
|
array = np.asarray(frame.depth)
|
||||||
|
if array.size and not bool(np.any(array)):
|
||||||
|
empty = t("panels.depth_empty", pixels=int(array.size))
|
||||||
|
return f"{text} · {empty}" if text else empty
|
||||||
|
return text
|
||||||
|
|
||||||
|
def latched_domain_mask(self, frame: ImageFrame):
|
||||||
|
"""The 2D domain boundary to draw, latched for the session.
|
||||||
|
|
||||||
|
Exactly the treatment :meth:`depth_range` gives the colour scale, and for the same
|
||||||
|
reason: the boundary is not a per-frame measurement. It is the device's own UV
|
||||||
|
island — geometry, rebuilt from the same remap on every frame — and the depth
|
||||||
|
view paints it as its "no depth" grey, so rebuilding it per frame redrew an edge
|
||||||
|
that had not moved while any difference in ``frame.valid_mask`` repainted the
|
||||||
|
whole boundary, which reads as lag. The latch holds the boundary until a frame
|
||||||
|
no longer describes the same island (see ``DomainMaskLatch``).
|
||||||
|
|
||||||
|
A frame with no mask (the box chain) must not keep a previous finger island: that
|
||||||
|
shape fails the domain check and left a silent black arrow canvas on reload.
|
||||||
|
"""
|
||||||
|
held = self.domain_mask.mask
|
||||||
|
if frame.valid_mask is None and held is not None and frame.image_bgr is not None:
|
||||||
|
image_hw = tuple(int(v) for v in np.asarray(frame.image_bgr).shape[:2])
|
||||||
|
if tuple(int(v) for v in held.shape[:2]) != image_hw:
|
||||||
|
self.domain_mask.reset()
|
||||||
|
return self.domain_mask.update(frame.valid_mask)
|
||||||
|
|
||||||
|
def show(
|
||||||
|
self,
|
||||||
|
frame: ImageFrame,
|
||||||
|
*,
|
||||||
|
theme: str,
|
||||||
|
draw_style: str,
|
||||||
|
arrow_policy: Any = None,
|
||||||
|
orientation: str | None = None,
|
||||||
|
on_mode: Callable[[str], None] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""The single 2D sink: plan the frame, render it, hand the panel one image.
|
||||||
|
|
||||||
|
Live, recomputed, replayed and previewed frames all arrive here, so the mode
|
||||||
|
set and the annotations follow the frame's own fields rather than which view
|
||||||
|
happens to be on screen.
|
||||||
|
"""
|
||||||
|
plan = plan_image(frame)
|
||||||
|
self._arrow_panel.set_plan(plan)
|
||||||
|
mode = plan.resolve(self._arrow_panel.view_mode())
|
||||||
|
self._arrow_panel.set_view_mode(mode)
|
||||||
|
if on_mode is not None:
|
||||||
|
on_mode(mode)
|
||||||
|
# The legend is decided by the same inputs as the picture: the arrow
|
||||||
|
# magnification of this sensor's draw style, and whether this frame
|
||||||
|
# carries a valid-area mask. It stays the *frame's* statement rather than the
|
||||||
|
# latched boundary's, because that is what the chip claims: the arrows are
|
||||||
|
# gated by this frame's own mask on every path, latched boundary or not.
|
||||||
|
boundary = self.latched_domain_mask(frame)
|
||||||
|
self._arrow_panel.set_arrow_scale(arrow_display_scale(draw_style))
|
||||||
|
self._arrow_panel.set_valid_area(frame.valid_mask is not None)
|
||||||
|
if frame.depth is not None:
|
||||||
|
# Remember every depth-bearing frame, whichever 2D mode is showing,
|
||||||
|
# so a later depth view and the 3D surface name the same interval.
|
||||||
|
self._last_depth_frame = frame.depth
|
||||||
|
span = self.active_depth_span()
|
||||||
|
self.publish_shared_depth_scale(span)
|
||||||
|
if mode == DEPTH:
|
||||||
|
self._arrow_panel.set_depth_scale(
|
||||||
|
self.depth_scale_text(frame),
|
||||||
|
tip_key=self.depth_scale_tip_key(),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._arrow_panel.set_depth_scale(None)
|
||||||
|
# Look up through the shell module so tests that monkeypatch
|
||||||
|
# ``app_window.render_image`` still see every paint.
|
||||||
|
from orisys_studio.app_window import render_image
|
||||||
|
|
||||||
|
rendered = render_image(
|
||||||
|
frame,
|
||||||
|
plan,
|
||||||
|
mode=mode,
|
||||||
|
theme=theme,
|
||||||
|
draw_style=draw_style,
|
||||||
|
depth_range=span,
|
||||||
|
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*
|
||||||
|
# field can be sampled with; a live field is dense and takes the display's own.
|
||||||
|
arrow_policy=frame.arrow_policy or arrow_policy,
|
||||||
|
# The 2D presentation of the finger's unrolled canvas. Display only: the
|
||||||
|
# frame's own flow/mask/image are handed over untouched, and the 3D pane is
|
||||||
|
# not involved at all (its frame is the design export's).
|
||||||
|
orientation=orientation,
|
||||||
|
)
|
||||||
|
diagnostic = (
|
||||||
|
None if rendered is None else getattr(rendered, "frame_diagnostic", None)
|
||||||
|
)
|
||||||
|
if rendered is None or diagnostic is not None:
|
||||||
|
# Never substitute another picture here: a missing depth map that showed
|
||||||
|
# the processed frame made "no depth" look like a depth reading. A draw
|
||||||
|
# failure used to return the black base image; say so instead.
|
||||||
|
if mode == DEPTH and rendered is None:
|
||||||
|
message = t("panels.depth_unavailable")
|
||||||
|
elif diagnostic is not None:
|
||||||
|
message = t("panels.arrow_render_failed")
|
||||||
|
else:
|
||||||
|
message = t("panels.arrow_empty")
|
||||||
|
self._arrow_panel.clear_preview(message)
|
||||||
|
return
|
||||||
|
self._arrow_panel.show_frame(bgr_to_qimage(rendered))
|
||||||
Reference in New Issue
Block a user