diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index f953199..7b684a5 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -8,7 +8,6 @@ import sys from pathlib import Path from typing import Any -import numpy as np from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ from orisys_app_shared.acquisition.display_orientation import ( DEFAULT_FINGER_ORIENTATION, @@ -16,16 +15,13 @@ from orisys_app_shared.acquisition.display_orientation import ( normalise_orientation, ) from orisys_app_shared.acquisition.image_backend import ( - DEPTH, SOURCE_ANALYSIS, SOURCE_LIVE, SOURCE_PREVIEW, SOURCE_RECOMPUTE, SOURCE_REPLAY, - DomainMaskLatch, ImageFrame, - plan_image, - render_image, + render_image, # re-exported: tests monkeypatch ``app_window.render_image`` ) from orisys_app_shared.force.calibration_recorder import CalibrationRecorder 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.calibration_service import CalibrationService 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.replay import ReplayEngine 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 #: treats calibration as unknown. self.acquire_presenter: AcquirePresenter | None = None - self._depth_range_value: tuple[float, float] | None = None - self._last_depth_frame = 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() + #: Built once the panels exist; 2D paint and shared depth span live there. + self.frame_view: FrameView | None = None icon = QIcon(str(logo_path())) if not icon.isNull(): @@ -266,12 +258,13 @@ class StudioMainWindow(QMainWindow): self.content_splitter = QSplitter(Qt.Orientation.Horizontal) 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 # latched boundary, and the 3D pane's validity texture is that same mask. Two # views, one decision about where the measurement is — see # ``image_backend.DomainMaskLatch`` and ``Scene3DView.set_domain_latch``. - self.mesh3d_panel.set_domain_latch(self._domain_mask) - self.arrow_panel = ArrowPanel(theme=self._theme) + self.frame_view = FrameView(self.arrow_panel, self.mesh3d_panel) + self.mesh3d_panel.set_domain_latch(self.frame_view.domain_mask) self.force_plots = ForcePlotsPanel(theme=self._theme) # The charts browse the whole session, not just their rolling buffer. 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 # 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() # 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. @@ -2312,13 +2305,11 @@ class StudioMainWindow(QMainWindow): # starts empty and is filled from that confirmation — a path is not a # version, and the same path can hold different numbers. self._active_calibration_id = "" - self._depth_range_value = None - self._last_depth_frame = None - self._publish_shared_depth_scale(None) + self.frame_view.clear_depth_latch() # 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 # 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 # 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. @@ -2611,147 +2602,34 @@ class StudioMainWindow(QMainWindow): # Compose after deliver_measurement submitted this tick's packet. presenter.tick_3d(display_paused=self._display_paused) - # ------------------------------------------------------------ depth scale - def _depth_display_settings(self): - """The display settings that decide the shared depth colour span.""" - panel = getattr(self, "mesh3d_panel", None) - 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 + # ---------------------------------------------------- frame view (forwards) + # Depth colour span, domain latch and the 2D paint live in ``FrameView``. These + # keep the names tests and ``ImageSink`` callers already use. + @property + def _domain_mask(self): + return self.frame_view.domain_mask - 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 + @property + def _depth_range_value(self) -> tuple[float, float] | None: + return self.frame_view.depth_range_value - def _depth_scale_adaptive(self) -> bool: - settings = self._depth_display_settings() - return bool(getattr(settings, "depth_scale_adaptive", False)) + @_depth_range_value.setter + def _depth_range_value(self, value: tuple[float, float] | None) -> None: + self.frame_view.depth_range_value = value - 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" + @property + def _last_depth_frame(self): + return self.frame_view.last_depth_frame - 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 + @_last_depth_frame.setter + def _last_depth_frame(self, value) -> None: + self.frame_view.last_depth_frame = value 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 = 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 + return self.frame_view.depth_range() 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 _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) + self.frame_view.reset_domain_mask() def _render_run_image(self, packet) -> None: """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: - """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) + """The single 2D sink: plan the frame, render it, hand the panel one image.""" draw_style = self._draw_style() - # 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) - rendered = render_image( + presenter = self.acquire_presenter + self.frame_view.show( frame, - plan, - mode=mode, theme=self._theme, draw_style=draw_style, - depth_range=span, - 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). + arrow_policy=self._arrow_policy(), orientation=effective_orientation( 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) # What this window owes the replay controller, and nothing more. The *policy* diff --git a/apps/studio/src/orisys_studio/frame_view.py b/apps/studio/src/orisys_studio/frame_view.py new file mode 100644 index 0000000..08576f3 --- /dev/null +++ b/apps/studio/src/orisys_studio/frame_view.py @@ -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))