hilaus-arm's picture
Sync from arm/Optimized-Runtimes via hub-sync
9422383 verified
Raw History Blame Contribute Delete
14.5 kB
"""Rendering and rolling performance metrics for the Picamera2 live demo."""
from __future__ import annotations
import threading
from collections import deque
from collections.abc import Callable, Sequence
from time import perf_counter
from typing import Protocol
import cv2
import numpy as np
class DetectionLike(Protocol):
"""The portion of ``Detection`` needed by the overlay renderer."""
box: tuple[float, float, float, float]
confidence: float
class_id: int
class_name: str
class RollingPerformance:
"""Rolling detection throughput and mean inference latency."""
def __init__(self, window_seconds: float = 1.5) -> None:
if window_seconds <= 0:
raise ValueError("window_seconds must be positive")
self.window_seconds = window_seconds
self._samples: deque[tuple[float, float]] = deque()
def add(self, completed_at: float, inference_ms: float) -> None:
if self._samples and completed_at < self._samples[-1][0]:
raise ValueError("completed_at must be monotonic")
if inference_ms < 0:
raise ValueError("inference_ms must be non-negative")
self._samples.append((completed_at, inference_ms))
cutoff = completed_at - self.window_seconds
while self._samples and self._samples[0][0] < cutoff:
self._samples.popleft()
@property
def detection_fps(self) -> float | None:
if len(self._samples) < 2:
return None
elapsed = self._samples[-1][0] - self._samples[0][0]
return (len(self._samples) - 1) / elapsed if elapsed > 0 else None
@property
def inference_mean_ms(self) -> float | None:
if not self._samples:
return None
return sum(sample[1] for sample in self._samples) / len(self._samples)
class LatestOverlayUploader:
"""Render and upload only the newest requested overlay off-thread."""
def __init__(
self,
renderer: OverlayRenderer,
width: int,
height: int,
upload: Callable[[np.ndarray], None],
) -> None:
if width <= 0 or height <= 0:
raise ValueError("overlay dimensions must be positive")
self._renderer = renderer
self._upload = upload
self._buffer = np.empty((height, width, 4), dtype=np.uint8)
self._requested = threading.Event()
self._closed = threading.Event()
self._metrics_lock = threading.Lock()
self._upload_count = 0
self._upload_seconds = 0.0
self._error: BaseException | None = None
self._thread = threading.Thread(
target=self._run,
name="picamera2-overlay-upload",
daemon=True,
)
self._thread.start()
def request(self) -> None:
"""Request an upload of the newest renderer state without blocking."""
self._raise_if_failed()
if self._closed.is_set():
raise RuntimeError("overlay uploader is closed")
self._requested.set()
def close(self) -> None:
"""Stop after the active upload, discarding any pending request."""
self._closed.set()
self._requested.set()
self._thread.join()
self._raise_if_failed()
@property
def upload_mean_ms(self) -> float:
with self._metrics_lock:
if not self._upload_count:
return 0.0
return self._upload_seconds * 1000 / self._upload_count
@property
def upload_count(self) -> int:
with self._metrics_lock:
return self._upload_count
def _run(self) -> None:
try:
while True:
self._requested.wait()
self._requested.clear()
if self._closed.is_set():
return
self._renderer.draw_rgba(self._buffer)
started_at = perf_counter()
self._upload(self._buffer)
elapsed = perf_counter() - started_at
with self._metrics_lock:
self._upload_count += 1
self._upload_seconds += elapsed
except BaseException as error:
self._error = error
self._closed.set()
def _raise_if_failed(self) -> None:
if self._error is not None:
raise RuntimeError("overlay upload worker failed") from self._error
# OpenCV writes these bytes directly into Picamera2's XRGB8888 buffer, whose
# NumPy representation is BGRX on this little-endian platform.
_COLORS_BGRX = (
(255, 200, 70, 0),
(90, 120, 255, 0),
(145, 235, 105, 0),
(255, 130, 220, 0),
(75, 210, 255, 0),
(225, 220, 90, 0),
(190, 135, 255, 0),
(80, 220, 165, 0),
)
class OverlayRenderer:
"""Draw the latest detections into a Picamera2 XRGB8888 preview buffer."""
def __init__(
self,
width: int,
height: int,
*,
show_confidence: bool = True,
) -> None:
if width <= 0 or height <= 0:
raise ValueError("overlay dimensions must be positive")
self.width = width
self.height = height
self.show_confidence = show_confidence
self._lock = threading.Lock()
self._detections: tuple[DetectionLike, ...] = ()
self._status = self._format_status("--", "--")
self._render_count = 0
self._render_seconds = 0.0
def update(
self,
detections: Sequence[DetectionLike],
performance: RollingPerformance,
) -> None:
"""Publish one immutable result for subsequent preview frames."""
fps = performance.detection_fps
inference_ms = performance.inference_mean_ms
fps_text = "--" if fps is None else f"{fps:.1f}"
inference_text = "--" if inference_ms is None else f"{inference_ms:.1f}"
with self._lock:
self._detections = tuple(detections)
self._status = self._format_status(fps_text, inference_text)
def _format_status(self, fps_text: str, inference_text: str) -> str:
return f"Detect {fps_text} FPS | Inference {inference_text} ms"
def draw(self, image: np.ndarray) -> None:
"""Draw the published result in place on one camera preview frame."""
if image.dtype != np.uint8 or image.shape != (self.height, self.width, 4):
raise ValueError(
"preview image must be a uint8 XRGB8888 array with shape "
f"[{self.height}, {self.width}, 4]"
)
started_at = perf_counter()
with self._lock:
detections = self._detections
status = self._status
for detection in detections:
x1, y1, x2, y2 = detection.box
left = min(max(int(round(x1)), 0), self.width - 1)
top = min(max(int(round(y1)), 0), self.height - 1)
right = min(max(int(round(x2)), 0), self.width - 1)
bottom = min(max(int(round(y2)), 0), self.height - 1)
if right <= left or bottom <= top:
continue
color = _COLORS_BGRX[detection.class_id % len(_COLORS_BGRX)]
cv2.rectangle(image, (left, top), (right, bottom), color, 2)
label = detection.class_name
if self.show_confidence:
label += f" {detection.confidence:.2f}"
self._draw_label(image, label, left, top, color)
self._draw_status(image, status)
self._record_render_time(perf_counter() - started_at)
def snapshot(self) -> tuple[tuple[DetectionLike, ...], str]:
"""Return the latest immutable overlay state for another renderer."""
with self._lock:
return self._detections, self._status
def draw_rgba(self, image: np.ndarray) -> None:
"""Draw a native-resolution transparent preview overlay in place."""
if image.dtype != np.uint8 or image.ndim != 3 or image.shape[2] != 4:
raise ValueError("overlay image must be a uint8 RGBA array")
started_at = perf_counter()
detections, status = self.snapshot()
image.fill(0)
height, width = image.shape[:2]
scale = min(width / self.width, height / self.height)
x_offset = (width - self.width * scale) / 2
y_offset = (height - self.height * scale) / 2
for detection in detections:
self._draw_rgba_detection(image, detection, scale, x_offset, y_offset)
self._draw_rgba_status(image, status, scale, y_offset)
self._record_render_time(perf_counter() - started_at)
def _draw_rgba_detection(
self,
image: np.ndarray,
detection: DetectionLike,
scale: float,
x_offset: float,
y_offset: float,
) -> None:
x1, y1, x2, y2 = detection.box
left = round(x_offset + min(max(x1, 0), self.width - 1) * scale)
top = round(y_offset + min(max(y1, 0), self.height - 1) * scale)
right = round(x_offset + min(max(x2, 0), self.width - 1) * scale)
bottom = round(y_offset + min(max(y2, 0), self.height - 1) * scale)
if right <= left or bottom <= top:
return
blue, green, red, _ = _COLORS_BGRX[detection.class_id % len(_COLORS_BGRX)]
color = (red, green, blue, 255)
cv2.rectangle(image, (left, top), (right, bottom), color, max(2, round(2 * scale)))
label = detection.class_name
if self.show_confidence:
label += f" {detection.confidence:.2f}"
font = cv2.FONT_HERSHEY_SIMPLEX
font_scale = 0.45 * scale
thickness = max(1, round(scale))
(text_width, text_height), baseline = cv2.getTextSize(label, font, font_scale, thickness)
image_height, image_width = image.shape[:2]
box_width = min(text_width + round(8 * scale), image_width)
box_height = text_height + baseline + round(6 * scale)
label_left = min(left, image_width - box_width)
label_top = max(round(y_offset), top - box_height)
label_bottom = min(label_top + box_height, image_height - 1)
cv2.rectangle(
image,
(label_left, label_top),
(label_left + box_width, label_bottom),
(0, 0, 0, 220),
cv2.FILLED,
)
cv2.putText(
image,
label,
(label_left + round(4 * scale), label_bottom - baseline - round(3 * scale)),
font,
font_scale,
color,
thickness,
cv2.LINE_AA,
)
def _draw_rgba_status(
self,
image: np.ndarray,
status: str,
scale: float,
y_offset: float,
) -> None:
font = cv2.FONT_HERSHEY_SIMPLEX
font_scale = 0.435 * scale
thickness = max(1, round(scale))
(text_width, text_height), baseline = cv2.getTextSize(status, font, font_scale, thickness)
image_height, image_width = image.shape[:2]
panel_width = min(text_width + round(15 * scale), image_width)
panel_height = text_height + baseline + round(14 * scale)
panel_left = max(0, round((image_width - panel_width) / 2))
panel_right = min(panel_left + panel_width, image_width - 1)
panel_bottom = min(round(y_offset + self.height * scale - 6 * scale), image_height - 1)
panel_top = max(round(y_offset), panel_bottom - panel_height)
cv2.rectangle(
image,
(panel_left, panel_top),
(panel_right, panel_bottom),
(0, 0, 0, 220),
cv2.FILLED,
)
cv2.putText(
image,
status,
(panel_left + round(5 * scale), panel_bottom - baseline - round(5 * scale)),
font,
font_scale,
(255, 255, 255, 255),
thickness,
cv2.LINE_AA,
)
def _record_render_time(self, elapsed_seconds: float) -> None:
with self._lock:
self._render_count += 1
self._render_seconds += elapsed_seconds
@property
def render_mean_ms(self) -> float:
with self._lock:
if not self._render_count:
return 0.0
return self._render_seconds * 1000 / self._render_count
@staticmethod
def _draw_label(
image: np.ndarray,
label: str,
left: int,
top: int,
color: tuple[int, int, int, int],
) -> None:
font = cv2.FONT_HERSHEY_SIMPLEX
font_scale = 0.45
thickness = 1
(text_width, text_height), baseline = cv2.getTextSize(label, font, font_scale, thickness)
image_height, image_width = image.shape[:2]
box_width = min(text_width + 8, image_width)
box_height = text_height + baseline + 6
label_left = min(left, image_width - box_width)
label_top = max(0, top - box_height)
label_bottom = min(label_top + box_height, image_height)
cv2.rectangle(
image,
(label_left, label_top),
(label_left + box_width, label_bottom),
(0, 0, 0, 0),
cv2.FILLED,
)
cv2.putText(
image,
label,
(label_left + 4, label_bottom - baseline - 3),
font,
font_scale,
color,
thickness,
cv2.LINE_AA,
)
@staticmethod
def _draw_status(image: np.ndarray, status: str) -> None:
font = cv2.FONT_HERSHEY_SIMPLEX
font_scale = 0.435
thickness = 1
(text_width, text_height), baseline = cv2.getTextSize(status, font, font_scale, thickness)
image_height, image_width = image.shape[:2]
panel_width = min(text_width + 15, image_width)
panel_height = text_height + baseline + 14
panel_left = max(0, (image_width - panel_width) // 2)
panel_right = min(panel_left + panel_width, image_width - 1)
panel_bottom = image_height - 6
panel_top = max(0, panel_bottom - panel_height)
cv2.rectangle(
image,
(panel_left, panel_top),
(panel_right, panel_bottom),
(0, 0, 0, 0),
cv2.FILLED,
)
cv2.putText(
image,
status,
(panel_left + 5, panel_bottom - baseline - 5),
font,
font_scale,
(255, 255, 255, 0),
thickness,
cv2.LINE_AA,
)