把 Studio 2D 画面与深度色标抽到 FrameView。

深度闩、域掩码与 _show_image_frame 离开主窗口;ImageSink 方法名保留为转发。朝向与深度闩测试、以及 monkeypatch render_image 的画面测试通过。
This commit is contained in:
ykcheungab
2026-10-08 10:39:40 +08:00
parent 63a45cfc1b
commit 7afd3169fd
2 changed files with 319 additions and 215 deletions
+35 -215
View File
@@ -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*
+284
View File
@@ -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))