The node showed a colour image only, so debugging a depth pipeline meant either trusting the numbers or writing a throwaway viewer. It now keeps one panel per frame stream -- image and depth -- and tiles them side by side, scaled to a common height. Wiring one is a plain viewer; wiring both shows them together. bbox and text stay overlays rather than panels. The contract follows what dora-zed already publishes: float32 [H, W] in millimetres with 0 for missing. It is sent there as a raw byte slice, so the payload arrives as UInt8 despite the Float32 contract and is reinterpreted rather than converted. Auto-ranging the colormap over the valid pixels' full extent, rather than over a percentile of them, is deliberate and was not the first attempt: trimming the tails collapsed on the scene the node exists for. A small object in front of a large wall is a few percent of the pixels, so both tails landed on the wall and the object clipped away. Rendering a frame and looking at it found that; every test passed either way. Verified with dora-cli 1.0.1: validate passes with both contracts injected, the dataflow runs both nodes to a clean exit, and a composed frame was rendered and inspected.
125 lines
4.0 KiB
Python
125 lines
4.0 KiB
Python
"""The display state: the panels, the overlays drawn on them, and the window."""
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
WINDOW = "Dora Node: dora-image-view"
|
|
GREEN = (0, 255, 0)
|
|
WHITE = (255, 255, 255)
|
|
FONT = cv2.FONT_HERSHEY_SIMPLEX
|
|
FONT_SCALE = 0.5
|
|
|
|
# Left to right in the composite. Fixed rather than insertion-ordered so a panel
|
|
# does not move when a stream drops a frame.
|
|
PANELS = ("image", "depth")
|
|
|
|
|
|
@dataclass
|
|
class Bboxes:
|
|
"""One overlay's worth of boxes, always stored as xyxy corners."""
|
|
|
|
corners: np.ndarray = field(default_factory=lambda: np.empty((0, 4)))
|
|
conf: np.ndarray = field(default_factory=lambda: np.empty(0))
|
|
labels: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=object))
|
|
|
|
|
|
@dataclass
|
|
class View:
|
|
"""Panel state, plus the window the panels are displayed in.
|
|
|
|
Each panel holds the most recent frame of one stream, as BGR. They are
|
|
tiled left to right, so wiring only `depth` shows only depth. `headless`
|
|
suppresses the window; the node still decodes every input.
|
|
"""
|
|
|
|
width: int | None = None
|
|
height: int | None = None
|
|
headless: bool = False
|
|
bboxes: Bboxes = field(default_factory=Bboxes)
|
|
text: str = ""
|
|
panels: dict[str, np.ndarray] = field(default_factory=dict)
|
|
|
|
def show(self) -> bool:
|
|
"""Draw the overlays, tile the panels, and display them.
|
|
|
|
Returns whether the viewer asked to quit by pressing `q`.
|
|
"""
|
|
frame = self.compose()
|
|
|
|
if frame is None:
|
|
return False
|
|
|
|
if self.width and self.height:
|
|
frame = cv2.resize(frame, (self.width, self.height))
|
|
|
|
if self.headless:
|
|
return False
|
|
|
|
cv2.imshow(WINDOW, frame)
|
|
return cv2.waitKey(1) & 0xFF == ord("q")
|
|
|
|
def compose(self) -> np.ndarray | None:
|
|
"""Tile the populated panels into one frame, tallest panel setting the height."""
|
|
panels = [(name, self.panels[name]) for name in PANELS if name in self.panels]
|
|
|
|
if not panels:
|
|
return None
|
|
|
|
# The boxes are in image pixels, so they are drawn before any scaling.
|
|
if "image" in self.panels:
|
|
self._draw_bboxes(self.panels["image"])
|
|
|
|
tallest = max(panel.shape[0] for _, panel in panels)
|
|
scaled = [self._label(_scale(panel, tallest), name) for name, panel in panels]
|
|
|
|
frame = scaled[0] if len(scaled) == 1 else cv2.hconcat(scaled)
|
|
|
|
# On the composite, not on a panel: the caption describes the frame as a
|
|
# whole, and a panel it was drawn on might not be the leftmost one.
|
|
cv2.putText(frame, self.text, (20, 20), FONT, FONT_SCALE, WHITE, 1)
|
|
return frame
|
|
|
|
def _label(self, panel: np.ndarray, name: str) -> np.ndarray:
|
|
"""Name a panel, so two side by side are not read as one picture."""
|
|
if len(self.panels) > 1:
|
|
cv2.putText(
|
|
panel,
|
|
name,
|
|
(10, panel.shape[0] - 10),
|
|
FONT,
|
|
FONT_SCALE,
|
|
WHITE,
|
|
1,
|
|
)
|
|
return panel
|
|
|
|
def _draw_bboxes(self, frame: np.ndarray) -> None:
|
|
for corners, conf, label in zip(
|
|
self.bboxes.corners,
|
|
self.bboxes.conf,
|
|
self.bboxes.labels,
|
|
strict=False,
|
|
):
|
|
min_x, min_y, max_x, max_y = (int(value) for value in corners)
|
|
cv2.rectangle(frame, (min_x, min_y), (max_x, max_y), GREEN, 2)
|
|
cv2.putText(
|
|
frame,
|
|
f"{label}, {conf:0.2f}",
|
|
(max_x - 120, max_y - 10),
|
|
FONT,
|
|
FONT_SCALE,
|
|
GREEN,
|
|
1,
|
|
)
|
|
|
|
|
|
def _scale(panel: np.ndarray, height: int) -> np.ndarray:
|
|
"""Match a panel to the composite height, keeping its aspect ratio."""
|
|
if panel.shape[0] == height:
|
|
return panel
|
|
|
|
width = max(round(panel.shape[1] * height / panel.shape[0]), 1)
|
|
return cv2.resize(panel, (width, height))
|