回放叠加改用展开底图,并统一 2D/3D 投递。
箭头/采集图像与 live 的 processed_frame 对齐;deliver_measurement 先画 2D 再提交同一包给 3D。已用 overlay、scene3d 与 replay 相关测试验证。
This commit is contained in:
@@ -17,9 +17,11 @@ from orisys_app_shared.acquisition.display_orientation import (
|
|||||||
)
|
)
|
||||||
from orisys_app_shared.acquisition.image_backend import (
|
from orisys_app_shared.acquisition.image_backend import (
|
||||||
DEPTH,
|
DEPTH,
|
||||||
|
SOURCE_ANALYSIS,
|
||||||
SOURCE_LIVE,
|
SOURCE_LIVE,
|
||||||
SOURCE_PREVIEW,
|
SOURCE_PREVIEW,
|
||||||
SOURCE_RECOMPUTE,
|
SOURCE_RECOMPUTE,
|
||||||
|
SOURCE_REPLAY,
|
||||||
DomainMaskLatch,
|
DomainMaskLatch,
|
||||||
ImageFrame,
|
ImageFrame,
|
||||||
plan_image,
|
plan_image,
|
||||||
@@ -210,7 +212,6 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.session = SessionController(
|
self.session = SessionController(
|
||||||
recording=self.recording,
|
recording=self.recording,
|
||||||
packet_sink=self._on_packet_recorded,
|
packet_sink=self._on_packet_recorded,
|
||||||
packet_for_3d=self._on_packet_for_3d,
|
|
||||||
sensor_type="box",
|
sensor_type="box",
|
||||||
)
|
)
|
||||||
self.session.tick.connect(self._on_run_tick)
|
self.session.tick.connect(self._on_run_tick)
|
||||||
@@ -555,7 +556,25 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._show_image_frame(frame)
|
self._show_image_frame(frame)
|
||||||
|
|
||||||
def show_analysis_packet(self, packet) -> None:
|
def show_analysis_packet(self, packet) -> None:
|
||||||
self._show_analysis_frame(packet)
|
self.deliver_measurement(packet, advance="flush", paint_2d=False)
|
||||||
|
|
||||||
|
def deliver_measurement(
|
||||||
|
self,
|
||||||
|
packet,
|
||||||
|
*,
|
||||||
|
advance: str = "tick",
|
||||||
|
arrow_policy=None,
|
||||||
|
image_source: str | None = None,
|
||||||
|
paint_2d: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""One packet to 2D then 3D: live and replay share this exit."""
|
||||||
|
self._deliver_measurement(
|
||||||
|
packet,
|
||||||
|
advance=advance,
|
||||||
|
arrow_policy=arrow_policy,
|
||||||
|
image_source=image_source,
|
||||||
|
paint_2d=paint_2d,
|
||||||
|
)
|
||||||
|
|
||||||
def drop_render_marker(self) -> None:
|
def drop_render_marker(self) -> None:
|
||||||
self._drop_render_marker()
|
self._drop_render_marker()
|
||||||
@@ -1975,19 +1994,6 @@ class StudioMainWindow(QMainWindow):
|
|||||||
|
|
||||||
return describe
|
return describe
|
||||||
|
|
||||||
def _on_packet_for_3d(self, packet) -> None:
|
|
||||||
"""Runs on the **acquisition thread**.
|
|
||||||
|
|
||||||
The pane copies what it needs into a read-only frame and offers it to a
|
|
||||||
capacity-one mailbox; it never touches the recording, the session store or the
|
|
||||||
camera. The view is the one that decides it is switched off, so this stays a
|
|
||||||
reference read and a method call.
|
|
||||||
"""
|
|
||||||
view = self.mesh3d_panel.view
|
|
||||||
if view is None:
|
|
||||||
return
|
|
||||||
view.submit_packet(packet)
|
|
||||||
|
|
||||||
def _on_scene3d_settings(self) -> None:
|
def _on_scene3d_settings(self) -> None:
|
||||||
"""Open the 3D settings dialog — or say why there is nothing to configure."""
|
"""Open the 3D settings dialog — or say why there is nothing to configure."""
|
||||||
if not self.mesh3d_panel.open_settings():
|
if not self.mesh3d_panel.open_settings():
|
||||||
@@ -2346,12 +2352,12 @@ class StudioMainWindow(QMainWindow):
|
|||||||
# A new session invalidates the previous recording.
|
# A new session invalidates the previous recording.
|
||||||
self._stop_recording(wait=True)
|
self._stop_recording(wait=True)
|
||||||
self.session.select_worker(sensor_type)
|
self.session.select_worker(sensor_type)
|
||||||
# Both sinks are on the worker we are about to start (``select_worker`` wires
|
# The record channel is the only acquisition-thread sink; 3D is submitted on
|
||||||
# them, and ``add_sink`` is idempotent): the record channel and the 3D pane.
|
# the GUI deliver path with 2D. The 3D pane still has to know that a *new*
|
||||||
# The 3D pane has to know that a *new* session is starting: its frames carry
|
# session is starting: its frames carry this session's id, and to the
|
||||||
# this session's id, and to the coordinator an unfamiliar id is otherwise a
|
# coordinator an unfamiliar id is otherwise a stale frame from a previous
|
||||||
# stale frame from a previous session — which is how the pane ended up frozen
|
# session — which is how the pane ended up frozen on the first run's last
|
||||||
# on the first run's last picture for every run after it.
|
# picture for every run after it.
|
||||||
self.mesh3d_panel.begin_session()
|
self.mesh3d_panel.begin_session()
|
||||||
# The filters for this session, before it opens the device: the chain runs
|
# The filters for this session, before it opens the device: the chain runs
|
||||||
# in the SDK, so this is the one moment the choice can be handed over.
|
# in the SDK, so this is the one moment the choice can be handed over.
|
||||||
@@ -2585,12 +2591,13 @@ class StudioMainWindow(QMainWindow):
|
|||||||
|
|
||||||
Errors, end reasons and a stuck connect stay here — they call stop. A live
|
Errors, end reasons and a stuck connect stay here — they call stop. A live
|
||||||
packet's charts, calibration sampling and 2D/3D paint live in the presenter
|
packet's charts, calibration sampling and 2D/3D paint live in the presenter
|
||||||
so a replay owning the image cannot be overwritten by accident.
|
so a replay owning the image cannot be overwritten by accident. 3D is
|
||||||
|
submitted with 2D on ``deliver_measurement``; the timer then composes.
|
||||||
"""
|
"""
|
||||||
presenter = self.acquire_presenter
|
presenter = self.acquire_presenter
|
||||||
|
if self._active_worker is None:
|
||||||
if presenter is not None:
|
if presenter is not None:
|
||||||
presenter.tick_3d(display_paused=self._display_paused)
|
presenter.tick_3d(display_paused=self._display_paused)
|
||||||
if self._active_worker is None:
|
|
||||||
return
|
return
|
||||||
# Record bookkeeping happens before anything that can return early: a
|
# Record bookkeeping happens before anything that can return early: a
|
||||||
# recording that failed while the device was quiet still has to update
|
# recording that failed while the device was quiet still has to update
|
||||||
@@ -2635,6 +2642,8 @@ class StudioMainWindow(QMainWindow):
|
|||||||
if packet is None or presenter is None:
|
if packet is None or presenter is None:
|
||||||
return
|
return
|
||||||
presenter.present(packet, display_paused=self._display_paused)
|
presenter.present(packet, display_paused=self._display_paused)
|
||||||
|
# Compose after deliver_measurement submitted this tick's packet.
|
||||||
|
presenter.tick_3d(display_paused=self._display_paused)
|
||||||
|
|
||||||
# ------------------------------------------------------------ depth scale
|
# ------------------------------------------------------------ depth scale
|
||||||
def _depth_range(self) -> tuple[float, float] | None:
|
def _depth_range(self) -> tuple[float, float] | None:
|
||||||
@@ -2708,20 +2717,45 @@ class StudioMainWindow(QMainWindow):
|
|||||||
return self._domain_mask.update(frame.valid_mask)
|
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."""
|
||||||
if packet is None or self._replay_owns_image():
|
if packet is None or self._replay_owns_image():
|
||||||
return
|
return
|
||||||
|
self._deliver_measurement(packet, advance="tick")
|
||||||
|
|
||||||
|
def _deliver_measurement(
|
||||||
|
self,
|
||||||
|
packet,
|
||||||
|
*,
|
||||||
|
advance: str = "tick",
|
||||||
|
arrow_policy=None,
|
||||||
|
image_source: str | None = None,
|
||||||
|
paint_2d: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""Paint 2D from a packet, then submit that same packet to 3D.
|
||||||
|
|
||||||
|
Live and replay differ only in how the packet was built and whether the
|
||||||
|
3D mailbox advances on the run timer (``tick``) or must ``flush`` now.
|
||||||
|
"""
|
||||||
|
if packet is None:
|
||||||
|
return
|
||||||
|
if paint_2d:
|
||||||
flow_valid = getattr(packet, "flow_valid", None)
|
flow_valid = getattr(packet, "flow_valid", None)
|
||||||
depth_valid = getattr(packet, "depth_valid", None)
|
depth_valid = getattr(packet, "depth_valid", None)
|
||||||
stale = bool(getattr(packet, "stale_forces", False)) or (
|
stale = bool(getattr(packet, "stale_forces", False)) or (
|
||||||
flow_valid is False and depth_valid is False
|
flow_valid is False and depth_valid is False
|
||||||
)
|
)
|
||||||
|
source = image_source
|
||||||
|
if source is None:
|
||||||
|
source = (
|
||||||
|
SOURCE_RECOMPUTE if self._recompute_running else SOURCE_LIVE
|
||||||
|
)
|
||||||
self._show_image_frame(
|
self._show_image_frame(
|
||||||
ImageFrame(
|
ImageFrame(
|
||||||
getattr(packet, "processed_frame", None),
|
getattr(packet, "processed_frame", None),
|
||||||
flow=getattr(packet, "flow", None),
|
flow=getattr(packet, "flow", None),
|
||||||
depth=getattr(packet, "depth", None),
|
depth=getattr(packet, "depth", None),
|
||||||
valid_mask=getattr(packet, "valid_mask", None),
|
valid_mask=getattr(packet, "valid_mask", None),
|
||||||
source=SOURCE_RECOMPUTE if self._recompute_running else SOURCE_LIVE,
|
source=source,
|
||||||
frame_id=int(getattr(packet, "frame_id", 0) or 0),
|
frame_id=int(getattr(packet, "frame_id", 0) or 0),
|
||||||
t_rel_s=float(getattr(packet, "mono_t", 0.0) or 0.0),
|
t_rel_s=float(getattr(packet, "mono_t", 0.0) or 0.0),
|
||||||
stale=stale,
|
stale=stale,
|
||||||
@@ -2729,8 +2763,13 @@ class StudioMainWindow(QMainWindow):
|
|||||||
flow_frame_id=getattr(packet, "flow_frame_id", None),
|
flow_frame_id=getattr(packet, "flow_frame_id", None),
|
||||||
depth_valid=depth_valid,
|
depth_valid=depth_valid,
|
||||||
depth_frame_id=getattr(packet, "depth_frame_id", None),
|
depth_frame_id=getattr(packet, "depth_frame_id", None),
|
||||||
|
result_available=source in (SOURCE_ANALYSIS, SOURCE_REPLAY),
|
||||||
|
arrow_policy=arrow_policy,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self._submit_measurement_3d(
|
||||||
|
packet, advance=advance, overlay_policy=arrow_policy
|
||||||
|
)
|
||||||
|
|
||||||
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.
|
||||||
@@ -2934,19 +2973,16 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._analysis_reference_override = setup.reference_frame
|
self._analysis_reference_override = setup.reference_frame
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _show_analysis_frame(self, packet) -> None:
|
def _submit_measurement_3d(
|
||||||
"""Hand one stored analysis frame to the 3D pane's producer.
|
self, packet, *, advance: str = "tick", overlay_policy=None
|
||||||
|
) -> None:
|
||||||
|
"""Submit one packet to the 3D producer after 2D has been painted.
|
||||||
|
|
||||||
The pane snapshots a packet through its ordinary path, so a replay's figures go
|
Replay (``advance=\"flush\"``) applies the result's ``paint_policy`` and the
|
||||||
through the same adapter, domain latch and mask intersection as a live run. The
|
sensor-type gate, then flushes compose because the run timer is stopped.
|
||||||
producer is O(1) and Qt-free, and it is the pane that decides whether it is on.
|
Live (``advance=\"tick\"``) only submits; the 16 ms timer composes.
|
||||||
|
|
||||||
Live acquisition advances the mailbox on the session run timer. Replay stops
|
|
||||||
that timer, so after submit the pane must be ticked here — otherwise the frame
|
|
||||||
sits pending and later submits are skipped (``has_pending_frame``). The overlay
|
|
||||||
policy is the open session's ``paint_policy``, so 3D arrows share the 2D canvas's
|
|
||||||
grid and threshold (including the box painter's 2 px floor).
|
|
||||||
"""
|
"""
|
||||||
|
if advance == "flush":
|
||||||
session = self.replay_ctl.available_result()
|
session = self.replay_ctl.available_result()
|
||||||
provenance = getattr(getattr(session, "reader", None), "provenance", None)
|
provenance = getattr(getattr(session, "reader", None), "provenance", None)
|
||||||
result_type = str(getattr(provenance, "sensor_type", "") or "").strip()
|
result_type = str(getattr(provenance, "sensor_type", "") or "").strip()
|
||||||
@@ -2957,13 +2993,18 @@ class StudioMainWindow(QMainWindow):
|
|||||||
view = self.mesh3d_panel.view
|
view = self.mesh3d_panel.view
|
||||||
if view is None:
|
if view is None:
|
||||||
return
|
return
|
||||||
|
if advance == "flush":
|
||||||
self._connect_analysis_display(self.mesh3d_panel)
|
self._connect_analysis_display(self.mesh3d_panel)
|
||||||
policy = None
|
policy = overlay_policy
|
||||||
|
if policy is None:
|
||||||
replay = getattr(self, "replay_ctl", None)
|
replay = getattr(self, "replay_ctl", None)
|
||||||
if replay is not None:
|
if replay is not None:
|
||||||
policy = replay._session_policy()
|
policy = replay._session_policy()
|
||||||
view.set_overlay_policy(policy)
|
view.set_overlay_policy(policy)
|
||||||
|
elif overlay_policy is not None:
|
||||||
|
view.set_overlay_policy(overlay_policy)
|
||||||
view.submit_packet(packet)
|
view.submit_packet(packet)
|
||||||
|
if advance == "flush":
|
||||||
self._tick_analysis_3d()
|
self._tick_analysis_3d()
|
||||||
|
|
||||||
def _tick_analysis_3d(self) -> None:
|
def _tick_analysis_3d(self) -> None:
|
||||||
|
|||||||
@@ -195,6 +195,9 @@ class AnalysisSession:
|
|||||||
self._domain_hw: tuple[int, int] | None = None
|
self._domain_hw: tuple[int, int] | None = None
|
||||||
self._cache_id: int | None = None
|
self._cache_id: int | None = None
|
||||||
self._cache: AnalysedFrame | None = None
|
self._cache: AnalysedFrame | None = None
|
||||||
|
#: Session-scoped UV unroll / box stitch for overlay bases (lazy).
|
||||||
|
self._overlay = None
|
||||||
|
self._overlay_failed = False
|
||||||
|
|
||||||
# ------------------------------------------------------------------ identity
|
# ------------------------------------------------------------------ identity
|
||||||
@property
|
@property
|
||||||
@@ -381,8 +384,46 @@ class AnalysisSession:
|
|||||||
yield self.frame(int(frame_id))
|
yield self.frame(int(frame_id))
|
||||||
|
|
||||||
# ------------------------------------------------------------------ packets
|
# ------------------------------------------------------------------ packets
|
||||||
def packet(self, frame: AnalysedFrame, *, t_rel_s: float | None = None) -> SdkPacket:
|
def overlay_base(
|
||||||
"""One frame in the shape the 3D pane's producer consumes.
|
self, video_bgr: np.ndarray, *, frame_id: int | None = None
|
||||||
|
) -> np.ndarray | None:
|
||||||
|
"""The live-shaped processed image for this camera frame, or ``None``.
|
||||||
|
|
||||||
|
Opens a session-scoped projector from the result's provenance on first use.
|
||||||
|
Failure is sticky for the session: overlay views then report capture
|
||||||
|
unavailable rather than falling back to a resized fisheye.
|
||||||
|
"""
|
||||||
|
if self._overlay_failed:
|
||||||
|
return None
|
||||||
|
if self._overlay is None:
|
||||||
|
from orisys_app_shared.analysis.overlay_base import OverlayBaseProjector
|
||||||
|
|
||||||
|
self._overlay = OverlayBaseProjector.open_from_provenance(
|
||||||
|
self.reader.provenance
|
||||||
|
)
|
||||||
|
if self._overlay is None:
|
||||||
|
self._overlay_failed = True
|
||||||
|
return None
|
||||||
|
return self._overlay.project(video_bgr, frame_id=frame_id)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Release the overlay projector; the reader is closed by :class:`ResultLibrary`."""
|
||||||
|
overlay, self._overlay = self._overlay, None
|
||||||
|
if overlay is not None:
|
||||||
|
try:
|
||||||
|
overlay.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.debug("overlay projector close skipped", exc_info=True)
|
||||||
|
|
||||||
|
def packet(
|
||||||
|
self,
|
||||||
|
frame: AnalysedFrame,
|
||||||
|
*,
|
||||||
|
t_rel_s: float | None = None,
|
||||||
|
processed_frame: np.ndarray | None = None,
|
||||||
|
raw_frame: np.ndarray | None = None,
|
||||||
|
) -> SdkPacket:
|
||||||
|
"""One frame in the shape the 2D sink and 3D producer both consume.
|
||||||
|
|
||||||
The pane is fed through its ordinary path — it snapshots a packet — so a
|
The pane is fed through its ordinary path — it snapshots a packet — so a
|
||||||
replay's figures go through the same adapter, the same domain latch and the
|
replay's figures go through the same adapter, the same domain latch and the
|
||||||
@@ -390,14 +431,20 @@ class AnalysisSession:
|
|||||||
else would make "the pane looks the same in every mode" an accident of two code
|
else would make "the pane looks the same in every mode" an accident of two code
|
||||||
paths happening to agree.
|
paths happening to agree.
|
||||||
|
|
||||||
|
``processed_frame`` is the UV-unrolled or stitched base when the overlay view
|
||||||
|
has one; without it the stored black placeholder remains (pure-colour arrows
|
||||||
|
replace that canvas anyway).
|
||||||
|
|
||||||
The forces are zero and ``forces_valid`` is ``False``: the result stores no
|
The forces are zero and ``forces_valid`` is ``False``: the result stores no
|
||||||
force, ``forces.csv`` is where that measurement lives, and a zero here would be
|
force, ``forces.csv`` is where that measurement lives, and a zero here would be
|
||||||
a measurement nothing made.
|
a measurement nothing made.
|
||||||
"""
|
"""
|
||||||
moment = float(frame.t_rel_s if t_rel_s is None else t_rel_s)
|
moment = float(frame.t_rel_s if t_rel_s is None else t_rel_s)
|
||||||
|
processed = frame.base_bgr if processed_frame is None else processed_frame
|
||||||
|
raw = frame.base_bgr if raw_frame is None else raw_frame
|
||||||
return SdkPacket(
|
return SdkPacket(
|
||||||
raw_frame=frame.base_bgr,
|
raw_frame=raw,
|
||||||
processed_frame=frame.base_bgr,
|
processed_frame=processed,
|
||||||
timestamp=moment,
|
timestamp=moment,
|
||||||
mono_t=moment,
|
mono_t=moment,
|
||||||
fps=0.0,
|
fps=0.0,
|
||||||
@@ -597,6 +644,10 @@ class ResultLibrary:
|
|||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
session, self._session = self._session, None
|
session, self._session = self._session, None
|
||||||
if session is not None:
|
if session is not None:
|
||||||
|
try:
|
||||||
|
session.close()
|
||||||
|
except Exception: # noqa: BLE001 - releasing is best effort
|
||||||
|
logger.debug("could not close the analysis session", exc_info=True)
|
||||||
try:
|
try:
|
||||||
session.reader.close()
|
session.reader.close()
|
||||||
except Exception: # noqa: BLE001 - releasing is best effort
|
except Exception: # noqa: BLE001 - releasing is best effort
|
||||||
|
|||||||
@@ -1037,8 +1037,8 @@ class ReplayController(QObject):
|
|||||||
picture stays in the same selector, one step away, and the recompute switch is
|
picture stays in the same selector, one step away, and the recompute switch is
|
||||||
not involved.
|
not involved.
|
||||||
|
|
||||||
The first processed view is arrows over the captured frame: the camera picture
|
The first processed view is arrows over the unrolled / stitched base: the same
|
||||||
gives immediate context. A derived selection the operator already chose is left
|
texture live uses. A derived selection the operator already chose is left
|
||||||
alone — that mode is not this method's to override.
|
alone — that mode is not this method's to override.
|
||||||
"""
|
"""
|
||||||
current = str(getattr(self._image_panel, "view_mode", lambda: "")() or "")
|
current = str(getattr(self._image_panel, "view_mode", lambda: "")() or "")
|
||||||
@@ -1249,32 +1249,18 @@ class ReplayController(QObject):
|
|||||||
return held
|
return held
|
||||||
|
|
||||||
# ------------------------------------------------------------------ drawing
|
# ------------------------------------------------------------------ drawing
|
||||||
def _captured_base_for(self, frame) -> np.ndarray | None:
|
def _processed_base_for(self, frame, session) -> np.ndarray | None:
|
||||||
"""The camera picture at the result's domain size, or ``None`` when unavailable.
|
"""The live-shaped processed image under the arrows, or ``None``.
|
||||||
|
|
||||||
Stored analysis frames keep a black placeholder as ``base_bgr``: the pass does
|
Stored analysis frames keep a black placeholder as ``base_bgr``: the pass does
|
||||||
not embed the video. Overlay views need the decoded frame resized onto the
|
not embed the video. Overlay views need the same UV unroll / box stitch live
|
||||||
measurement grid — painting the stitch grid onto a larger camera frame would
|
publishes as ``processed_frame`` — a plain resize of the fisheye leaves arrows
|
||||||
leave the arrows in a corner (see :meth:`AnalysisSession.domain_hw`).
|
on the wrong texture.
|
||||||
"""
|
"""
|
||||||
video = self._engine.video_frame_bgr()
|
video = self._engine.video_frame_bgr()
|
||||||
if video is None:
|
if video is None or session is None:
|
||||||
return None
|
return None
|
||||||
height, width = (int(frame.flow.shape[0]), int(frame.flow.shape[1]))
|
return session.overlay_base(video, frame_id=int(frame.frame_id))
|
||||||
image = np.asarray(video)
|
|
||||||
if image.ndim == 2:
|
|
||||||
import cv2
|
|
||||||
|
|
||||||
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
|
|
||||||
elif image.ndim == 3 and image.shape[2] == 4:
|
|
||||||
import cv2
|
|
||||||
|
|
||||||
image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
|
|
||||||
if int(image.shape[0]) == height and int(image.shape[1]) == width:
|
|
||||||
return np.ascontiguousarray(image)
|
|
||||||
import cv2
|
|
||||||
|
|
||||||
return cv2.resize(image, (width, height), interpolation=cv2.INTER_AREA)
|
|
||||||
|
|
||||||
def _analysis_image_frame(
|
def _analysis_image_frame(
|
||||||
self, frame, *, source: str, base_bgr: np.ndarray | None = None
|
self, frame, *, source: str, base_bgr: np.ndarray | None = None
|
||||||
@@ -1364,23 +1350,36 @@ class ReplayController(QObject):
|
|||||||
else:
|
else:
|
||||||
frame = None
|
frame = None
|
||||||
if frame is not None:
|
if frame is not None:
|
||||||
captured = self._captured_base_for(frame)
|
processed = self._processed_base_for(frame, session)
|
||||||
if wanted == ARROW_STITCHED and captured is None:
|
if wanted == ARROW_STITCHED and processed is None:
|
||||||
# Overlay without a camera picture is a black canvas with dots — say
|
# Overlay without the live-shaped base is a black canvas with dots —
|
||||||
# so instead of implying the capture is missing from the result.
|
# say so instead of painting a resized fisheye under UV-domain arrows.
|
||||||
self._image_panel.clear_preview(t("panels.arrow_capture_unavailable"))
|
self._image_panel.clear_preview(t("panels.arrow_capture_unavailable"))
|
||||||
self._host.show_analysis_packet(session.packet(frame))
|
self._host.deliver_measurement(
|
||||||
|
session.packet(frame),
|
||||||
|
advance="flush",
|
||||||
|
arrow_policy=self._session_policy(),
|
||||||
|
image_source=(
|
||||||
|
SOURCE_ANALYSIS if self.analysis_active() else SOURCE_REPLAY
|
||||||
|
),
|
||||||
|
paint_2d=False,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
self._host.show_frame(
|
raw = self._engine.video_frame_bgr()
|
||||||
self._analysis_image_frame(
|
packet = session.packet(
|
||||||
frame,
|
frame,
|
||||||
source=SOURCE_ANALYSIS if self.analysis_active() else SOURCE_REPLAY,
|
processed_frame=processed,
|
||||||
base_bgr=captured,
|
raw_frame=None if raw is None else np.asarray(raw),
|
||||||
)
|
)
|
||||||
|
# One cursor, one packet: 2D then 3D on the shared deliver path.
|
||||||
|
self._host.deliver_measurement(
|
||||||
|
packet,
|
||||||
|
advance="flush",
|
||||||
|
arrow_policy=self._session_policy(),
|
||||||
|
image_source=(
|
||||||
|
SOURCE_ANALYSIS if self.analysis_active() else SOURCE_REPLAY
|
||||||
|
),
|
||||||
)
|
)
|
||||||
# The same frame, in the shape the 3D pane's producer consumes: one
|
|
||||||
# cursor, two figures, one frame id.
|
|
||||||
self._host.show_analysis_packet(session.packet(frame))
|
|
||||||
return
|
return
|
||||||
image = self._engine.video_frame_bgr()
|
image = self._engine.video_frame_bgr()
|
||||||
if image is None:
|
if image is None:
|
||||||
|
|||||||
@@ -58,12 +58,14 @@ class SessionController(QObject):
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
recording: Any,
|
recording: Any,
|
||||||
packet_for_3d: Callable[[Any], None],
|
|
||||||
packet_sink: Callable[[Any], None] | None = None,
|
packet_sink: Callable[[Any], None] | None = None,
|
||||||
sensor_type: str = "box",
|
sensor_type: str = "box",
|
||||||
|
packet_for_3d: Callable[[Any], None] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._recording = recording
|
self._recording = recording
|
||||||
|
#: Legacy optional second sink. Live 3D is delivered on the GUI tick with 2D;
|
||||||
|
#: keep the parameter so older call sites that still pass a noop do not break.
|
||||||
self._packet_for_3d = packet_for_3d
|
self._packet_for_3d = packet_for_3d
|
||||||
#: The record channel's sink. It defaults to this object's own method; the
|
#: The record channel's sink. It defaults to this object's own method; the
|
||||||
#: window may hand in its own bound method instead, because the tests and the
|
#: window may hand in its own bound method instead, because the tests and the
|
||||||
@@ -256,19 +258,20 @@ class SessionController(QObject):
|
|||||||
self.wire_sinks()
|
self.wire_sinks()
|
||||||
|
|
||||||
def wire_sinks(self) -> None:
|
def wire_sinks(self) -> None:
|
||||||
"""Register the record channel and the 3D pane on both workers (idempotent)."""
|
"""Register the record channel on both workers (idempotent).
|
||||||
|
|
||||||
|
3D shares the GUI deliver path with 2D (``deliver_measurement``), so it is
|
||||||
|
not a second acquisition-thread sink.
|
||||||
|
"""
|
||||||
for worker in (self._box_worker, self._finger_worker):
|
for worker in (self._box_worker, self._finger_worker):
|
||||||
if worker is None:
|
if worker is None:
|
||||||
continue
|
continue
|
||||||
worker.add_sink(self._packet_sink)
|
worker.add_sink(self._packet_sink)
|
||||||
# The 3D pane is a second sink on the same thread. It is registered even
|
if self._packet_for_3d is not None:
|
||||||
# while the pane is off, because a sink whose cost depends on the click
|
|
||||||
# order is worse than a view that is told to do nothing (the view's own
|
|
||||||
# enabled flag).
|
|
||||||
worker.add_sink(self._packet_for_3d)
|
worker.add_sink(self._packet_for_3d)
|
||||||
|
|
||||||
def detach_sinks(self) -> None:
|
def detach_sinks(self) -> None:
|
||||||
"""Unregister both sinks. Called on the way out, before Qt tears widgets down.
|
"""Unregister sinks. Called on the way out, before Qt tears widgets down.
|
||||||
|
|
||||||
A worker that is still winding down must not keep a reference to the window
|
A worker that is still winding down must not keep a reference to the window
|
||||||
(and through it to widgets that no longer exist).
|
(and through it to widgets that no longer exist).
|
||||||
@@ -277,6 +280,7 @@ class SessionController(QObject):
|
|||||||
if worker is None or not hasattr(worker, "remove_sink"):
|
if worker is None or not hasattr(worker, "remove_sink"):
|
||||||
continue
|
continue
|
||||||
worker.remove_sink(self._packet_sink)
|
worker.remove_sink(self._packet_sink)
|
||||||
|
if self._packet_for_3d is not None:
|
||||||
worker.remove_sink(self._packet_for_3d)
|
worker.remove_sink(self._packet_for_3d)
|
||||||
|
|
||||||
def worker_for(self, sensor_type: str) -> Any:
|
def worker_for(self, sensor_type: str) -> Any:
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ class ImageSink(Protocol):
|
|||||||
|
|
||||||
def show_analysis_packet(self, packet: Any) -> None: ...
|
def show_analysis_packet(self, packet: Any) -> None: ...
|
||||||
|
|
||||||
|
def deliver_measurement(
|
||||||
|
self,
|
||||||
|
packet: Any,
|
||||||
|
*,
|
||||||
|
advance: str = "tick",
|
||||||
|
arrow_policy: Any = None,
|
||||||
|
image_source: str | None = None,
|
||||||
|
paint_2d: bool = True,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
def drop_render_marker(self) -> None: ...
|
def drop_render_marker(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -465,6 +465,32 @@ class _Host:
|
|||||||
self.frames.append(frame)
|
self.frames.append(frame)
|
||||||
|
|
||||||
def show_analysis_packet(self, packet) -> None:
|
def show_analysis_packet(self, packet) -> None:
|
||||||
|
self.deliver_measurement(packet, advance="flush", paint_2d=False)
|
||||||
|
|
||||||
|
def deliver_measurement(
|
||||||
|
self,
|
||||||
|
packet,
|
||||||
|
*,
|
||||||
|
advance: str = "tick",
|
||||||
|
arrow_policy=None,
|
||||||
|
image_source: str | None = None,
|
||||||
|
paint_2d: bool = True,
|
||||||
|
) -> None:
|
||||||
|
if paint_2d:
|
||||||
|
from orisys_app_shared.acquisition.image_backend import ImageFrame
|
||||||
|
|
||||||
|
self.frames.append(
|
||||||
|
ImageFrame(
|
||||||
|
getattr(packet, "processed_frame", None),
|
||||||
|
flow=getattr(packet, "flow", None),
|
||||||
|
depth=getattr(packet, "depth", None),
|
||||||
|
valid_mask=getattr(packet, "valid_mask", None),
|
||||||
|
source=image_source or "replay",
|
||||||
|
frame_id=int(getattr(packet, "frame_id", 0) or 0),
|
||||||
|
arrow_policy=arrow_policy,
|
||||||
|
result_available=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
self.analysis_packets.append(packet)
|
self.analysis_packets.append(packet)
|
||||||
|
|
||||||
def drop_render_marker(self) -> None:
|
def drop_render_marker(self) -> None:
|
||||||
|
|||||||
@@ -262,6 +262,32 @@ class _Host:
|
|||||||
self.frames.append(frame)
|
self.frames.append(frame)
|
||||||
|
|
||||||
def show_analysis_packet(self, packet) -> None:
|
def show_analysis_packet(self, packet) -> None:
|
||||||
|
self.deliver_measurement(packet, advance="flush", paint_2d=False)
|
||||||
|
|
||||||
|
def deliver_measurement(
|
||||||
|
self,
|
||||||
|
packet,
|
||||||
|
*,
|
||||||
|
advance: str = "tick",
|
||||||
|
arrow_policy=None,
|
||||||
|
image_source: str | None = None,
|
||||||
|
paint_2d: bool = True,
|
||||||
|
) -> None:
|
||||||
|
if paint_2d:
|
||||||
|
from orisys_app_shared.acquisition.image_backend import ImageFrame
|
||||||
|
|
||||||
|
self.frames.append(
|
||||||
|
ImageFrame(
|
||||||
|
getattr(packet, "processed_frame", None),
|
||||||
|
flow=getattr(packet, "flow", None),
|
||||||
|
depth=getattr(packet, "depth", None),
|
||||||
|
valid_mask=getattr(packet, "valid_mask", None),
|
||||||
|
source=image_source or "replay",
|
||||||
|
frame_id=int(getattr(packet, "frame_id", 0) or 0),
|
||||||
|
arrow_policy=arrow_policy,
|
||||||
|
result_available=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
self.analysis_packets.append(packet)
|
self.analysis_packets.append(packet)
|
||||||
|
|
||||||
def drop_render_marker(self) -> None:
|
def drop_render_marker(self) -> None:
|
||||||
|
|||||||
@@ -388,8 +388,8 @@ def _run_session(make_window, record_root, *, with_3d: bool):
|
|||||||
if with_3d:
|
if with_3d:
|
||||||
view = _RecordingView()
|
view = _RecordingView()
|
||||||
window.mesh3d_panel.attach_view(view)
|
window.mesh3d_panel.attach_view(view)
|
||||||
# With no pane the sink still runs (it is registered on the worker) and does
|
# 3D is submitted on the GUI deliver path (same tick as 2D), not as an
|
||||||
# nothing, which is the case the comparison below is about.
|
# acquisition-thread sink — with or without a pane attached.
|
||||||
window.toolbar.btn_record.setChecked(True)
|
window.toolbar.btn_record.setChecked(True)
|
||||||
for index in range(96):
|
for index in range(96):
|
||||||
stuck.emit(ui_check._fake_packet(index, mono_t=index / 30.0))
|
stuck.emit(ui_check._fake_packet(index, mono_t=index / 30.0))
|
||||||
@@ -427,20 +427,20 @@ def test_the_3d_consumer_cannot_change_the_record(make_window, next_record_root)
|
|||||||
assert "t_rel_s" in header and len(with_3d_csv.splitlines()) > 10
|
assert "t_rel_s" in header and len(with_3d_csv.splitlines()) > 10
|
||||||
|
|
||||||
|
|
||||||
def test_the_3d_sink_is_registered_and_inert_when_the_pane_is_off(make_window, next_record_root):
|
def test_live_3d_is_not_an_acquisition_thread_sink(make_window, next_record_root):
|
||||||
"""A sink that has to be added and removed around a toggle is a state machine."""
|
"""3D shares the GUI deliver path with 2D; only the record channel is a sink."""
|
||||||
import orisys_studio.app_window as app_module
|
import orisys_studio.app_window as app_module
|
||||||
|
|
||||||
window, stuck = _running_window(make_window)
|
window, stuck = _running_window(make_window)
|
||||||
app_module.recordings_dir = lambda: next_record_root()
|
app_module.recordings_dir = lambda: next_record_root()
|
||||||
try:
|
try:
|
||||||
assert window._on_packet_recorded in stuck.sinks()
|
assert stuck.sinks() == (window._on_packet_recorded,), (
|
||||||
assert window._on_packet_for_3d in stuck.sinks(), (
|
"live 3D must not register a second acquisition-thread sink"
|
||||||
"the 3D sink must be registered with the worker, not attached per toggle"
|
|
||||||
)
|
)
|
||||||
assert window.mesh3d_panel.view is None, "a box sensor must not build a view"
|
assert window.mesh3d_panel.view is None, "a box sensor must not build a view"
|
||||||
for index in range(10):
|
for index in range(10):
|
||||||
stuck.emit(ui_check._fake_packet(index))
|
stuck.emit(ui_check._fake_packet(index))
|
||||||
|
window._on_run_tick()
|
||||||
assert window.session_store.count == 10
|
assert window.session_store.count == 10
|
||||||
assert window.mesh3d_panel.view is None
|
assert window.mesh3d_panel.view is None
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""Session-scoped overlay base: UV unroll or box stitch without re-running flow.
|
||||||
|
|
||||||
|
Replay stores flow and depth, not the processed image. Live paints arrows on
|
||||||
|
``SdkPacket.processed_frame`` (finger UV unroll or box stitch). This projector
|
||||||
|
rebuilds that same base from a decoded camera frame using the result's profile,
|
||||||
|
so scrubbing can match live without caching rendered pixels or reopening a video
|
||||||
|
per frame.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["OverlayBaseProjector"]
|
||||||
|
|
||||||
|
_CWD_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_bgr(image: np.ndarray) -> np.ndarray:
|
||||||
|
if image.ndim == 2:
|
||||||
|
return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
|
||||||
|
if image.ndim == 3 and image.shape[2] == 1:
|
||||||
|
return cv2.cvtColor(image[:, :, 0], cv2.COLOR_GRAY2BGR)
|
||||||
|
if image.ndim == 3 and image.shape[2] == 4:
|
||||||
|
return cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
|
||||||
|
return np.ascontiguousarray(image)
|
||||||
|
|
||||||
|
|
||||||
|
class OverlayBaseProjector:
|
||||||
|
"""Maps one camera frame onto the measurement domain for overlay views."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
sensor_type: str,
|
||||||
|
project,
|
||||||
|
close=None,
|
||||||
|
working: Path | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.sensor_type = str(sensor_type)
|
||||||
|
self._project = project
|
||||||
|
self._close = close
|
||||||
|
self._working = None if working is None else Path(working)
|
||||||
|
self._cache_id: int | None = None
|
||||||
|
self._cache: np.ndarray | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def open_from_provenance(cls, provenance: Any) -> OverlayBaseProjector | None:
|
||||||
|
"""Build a projector from an analysis result's provenance, or ``None``."""
|
||||||
|
kind = str(getattr(provenance, "sensor_type", "") or "").strip()
|
||||||
|
config_ref = str(getattr(provenance, "sensor_config_ref", "") or "").strip()
|
||||||
|
if kind == "finger":
|
||||||
|
return cls._open_finger(config_ref=config_ref or None)
|
||||||
|
if kind == "box":
|
||||||
|
return cls._open_box(config_ref=config_ref or None)
|
||||||
|
logger.warning("no overlay projector for sensor type %r", kind)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _open_finger(cls, *, config_ref: str | None) -> OverlayBaseProjector | None:
|
||||||
|
from orisys.finger.runtime import resolve_finger_geometry
|
||||||
|
from orisys.frame_processors import FrameUnwarper
|
||||||
|
from orisys.unroll import build_or_load_domain
|
||||||
|
from orisys_app_shared.analysis.service import resolve_finger_export_dir
|
||||||
|
|
||||||
|
if not config_ref:
|
||||||
|
logger.warning("finger overlay needs sensor_config_ref from provenance")
|
||||||
|
return None
|
||||||
|
export_dir = resolve_finger_export_dir(config_ref)
|
||||||
|
if export_dir is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
geometry = resolve_finger_geometry(
|
||||||
|
export_dir=export_dir,
|
||||||
|
config=str(config_ref),
|
||||||
|
)
|
||||||
|
mesh_path, intrinsics_path, pose_path = geometry.export_paths.as_tuple()
|
||||||
|
domain = build_or_load_domain(
|
||||||
|
mesh_path,
|
||||||
|
intrinsics_path,
|
||||||
|
pose_path,
|
||||||
|
geometry.map_cache,
|
||||||
|
geometry.source_K,
|
||||||
|
geometry.source_width,
|
||||||
|
geometry.source_height,
|
||||||
|
geometry.out_width,
|
||||||
|
geometry.out_height,
|
||||||
|
edge_expand=geometry.edge_expand,
|
||||||
|
include_surface=True,
|
||||||
|
verbose=False,
|
||||||
|
source_scale=geometry.source_scale,
|
||||||
|
)
|
||||||
|
domain.remap.ensure_fast_maps()
|
||||||
|
unwarper = FrameUnwarper(
|
||||||
|
domain.remap,
|
||||||
|
camera_matrix=(
|
||||||
|
geometry.source_K if geometry.dist_coeffs is not None else None
|
||||||
|
),
|
||||||
|
dist_coeffs=geometry.dist_coeffs,
|
||||||
|
source_width=geometry.source_width,
|
||||||
|
source_height=geometry.source_height,
|
||||||
|
flip_source_vertical=True,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 - missing assets become "capture unavailable"
|
||||||
|
logger.warning("could not open finger overlay projector", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def project(frame: np.ndarray) -> np.ndarray | None:
|
||||||
|
# Match live ``process_frame(..., color=False)``: grey unroll, then BGR.
|
||||||
|
prepared = unwarper.prepare_source_gray(frame)
|
||||||
|
unrolled = unwarper.flatten_prepared_gray(prepared)
|
||||||
|
if unrolled is None:
|
||||||
|
return None
|
||||||
|
return _ensure_bgr(unrolled)
|
||||||
|
|
||||||
|
return cls(sensor_type="finger", project=project)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _open_box(cls, *, config_ref: str | None) -> OverlayBaseProjector | None:
|
||||||
|
from orisys import Sensor
|
||||||
|
|
||||||
|
working = Path(tempfile.mkdtemp(prefix="orisys-overlay-box-"))
|
||||||
|
try:
|
||||||
|
with _CWD_LOCK:
|
||||||
|
previous = Path.cwd()
|
||||||
|
os.chdir(working)
|
||||||
|
try:
|
||||||
|
sensor = Sensor(
|
||||||
|
"external",
|
||||||
|
config_name=str(config_ref) if config_ref else "box",
|
||||||
|
verbose=False,
|
||||||
|
backend="auto",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
os.chdir(previous)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.warning("could not open box overlay projector", exc_info=True)
|
||||||
|
shutil.rmtree(working, ignore_errors=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def project(frame: np.ndarray) -> np.ndarray | None:
|
||||||
|
# Stitch only — no deformation / contact. Same grey domain live publishes.
|
||||||
|
processed = sensor.get_img(frame=frame)
|
||||||
|
if processed is None:
|
||||||
|
return None
|
||||||
|
return _ensure_bgr(processed)
|
||||||
|
|
||||||
|
def close() -> None:
|
||||||
|
try:
|
||||||
|
sensor.disconnect()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.debug("box overlay sensor disconnect skipped", exc_info=True)
|
||||||
|
shutil.rmtree(working, ignore_errors=True)
|
||||||
|
|
||||||
|
return cls(sensor_type="box", project=project, close=close, working=working)
|
||||||
|
|
||||||
|
def project(
|
||||||
|
self, video_bgr: np.ndarray, *, frame_id: int | None = None
|
||||||
|
) -> np.ndarray | None:
|
||||||
|
"""Return the measurement-domain BGR base, or ``None`` when remap fails."""
|
||||||
|
if frame_id is not None and self._cache is not None and self._cache_id == int(
|
||||||
|
frame_id
|
||||||
|
):
|
||||||
|
return self._cache
|
||||||
|
image = np.asarray(video_bgr)
|
||||||
|
if image.ndim == 2:
|
||||||
|
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
|
||||||
|
elif image.ndim == 3 and image.shape[2] == 4:
|
||||||
|
image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
|
||||||
|
try:
|
||||||
|
projected = self._project(image)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.warning("overlay projection failed", exc_info=True)
|
||||||
|
return None
|
||||||
|
if projected is None:
|
||||||
|
return None
|
||||||
|
result = np.ascontiguousarray(projected)
|
||||||
|
if frame_id is not None:
|
||||||
|
self._cache_id = int(frame_id)
|
||||||
|
self._cache = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._cache = None
|
||||||
|
self._cache_id = None
|
||||||
|
if self._close is not None:
|
||||||
|
try:
|
||||||
|
self._close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.debug("overlay projector close skipped", exc_info=True)
|
||||||
|
self._close = None
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Overlay base projector: session cache and no fisheye resize fallback."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from orisys_app_shared.analysis.overlay_base import OverlayBaseProjector
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_projector_reuses_the_same_frame_id() -> None:
|
||||||
|
calls: list[int] = []
|
||||||
|
|
||||||
|
def project(frame: np.ndarray) -> np.ndarray:
|
||||||
|
calls.append(1)
|
||||||
|
out = np.zeros((8, 8, 3), dtype=np.uint8)
|
||||||
|
out[..., 1] = 40
|
||||||
|
return out
|
||||||
|
|
||||||
|
projector = OverlayBaseProjector(sensor_type="finger", project=project)
|
||||||
|
first = projector.project(np.zeros((32, 32, 3), dtype=np.uint8), frame_id=7)
|
||||||
|
second = projector.project(np.ones((32, 32, 3), dtype=np.uint8) * 9, frame_id=7)
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert first is second
|
||||||
|
third = projector.project(np.zeros((32, 32, 3), dtype=np.uint8), frame_id=8)
|
||||||
|
assert len(calls) == 2
|
||||||
|
assert third is not second
|
||||||
|
projector.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_from_unknown_sensor_type_returns_none() -> None:
|
||||||
|
class _Prov:
|
||||||
|
sensor_type = "unknown"
|
||||||
|
sensor_config_ref = ""
|
||||||
|
|
||||||
|
assert OverlayBaseProjector.open_from_provenance(_Prov()) is None
|
||||||
Reference in New Issue
Block a user