mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-29 00:11:28 +01:00
feat(image): fast 8-bit path and sensor coordinates for jpeg preview frames
This commit is contained in:
7 files changed
+577
-56
No files matched your search
@@ -8,12 +8,19 @@ Shows detector preview frames that arrive JPEG-compressed, next to the same fram
|
||||
live: `dev.jpeg_cam.configure_stream(width=4096, height=4096, encoding="jpeg_gray")`.
|
||||
- The Image widget decodes JPEG previews on the thread that receives Redis messages
|
||||
(`BECDispatcher.connect_slot(..., preprocess=decode_jpeg_preview)`) and keeps only the newest
|
||||
frame when it falls behind (`latest_only=True`). Everything else in the widget is unchanged and
|
||||
works on the decoded 0–255 values.
|
||||
frame when it falls behind (`latest_only=True`). JPEG frames take a fast 8-bit path
|
||||
(`Image.fast_8bit_preview`, on by default): no processed copy, no per-frame statistics, a fixed
|
||||
0–255 range under autorange, and the frame laid out column-first off the GUI thread so
|
||||
pyqtgraph draws it without another copy. Crosshair, ROIs and the colour bar work on the decoded
|
||||
0–255 values. Rotation, transpose, log or FFT fall back to the normal path.
|
||||
- Frames may carry `"sensor_shape": [H, W]`: a preview scaled down from a bigger sensor is then
|
||||
drawn in sensor pixels, so coordinates and ROIs stay put when the preview resolution changes.
|
||||
- `jpeg_view.JpegView` is the picture-only path: Qt decodes the JPEG into a QImage off the GUI
|
||||
thread and the view draws it as a pixmap. No levels, histogram, ROI or pixel values.
|
||||
- `demo.py` puts both views side by side with live camera controls, a statistics table and a
|
||||
benchmark sweep (sizes x encodings x views, saved as CSV and JSON).
|
||||
thread and the view draws it as a pixmap on a pyqtgraph plot in sensor pixels, with a crosshair
|
||||
and rectangle/circle ROIs. Coordinates only: no levels, histogram or pixel values.
|
||||
- `demo.py` puts both views side by side with live camera controls, a normal/fast switch for the
|
||||
Image widget, a statistics table and a benchmark sweep (sizes x encodings x views, saved as CSV
|
||||
and JSON).
|
||||
- `test_offline_benchmark.py` measures the same path without Redis.
|
||||
|
||||
Install `simplejpeg` (`pip install -e .[jpeg]`) for the fast decoder; without it the widgets fall
|
||||
|
||||
@@ -215,7 +215,7 @@ class CameraControl(QWidget):
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
for label, widget in (
|
||||
(None, self.live_button),
|
||||
("Size", self.resolution_box),
|
||||
("Preview size", self.resolution_box),
|
||||
(None, self.width_spin),
|
||||
("x", self.height_spin),
|
||||
("Encoding", self.encoding_box),
|
||||
@@ -346,6 +346,14 @@ class JpegPreviewDemo(QMainWindow):
|
||||
for check in (self.image_check, self.view_check):
|
||||
check.setChecked(True)
|
||||
self.image_check.toggled.connect(lambda on: self.enable_view("Image widget", on))
|
||||
self.fast_check = QCheckBox("Fast 8-bit path", self)
|
||||
self.fast_check.setToolTip(
|
||||
"On: JPEG frames are drawn as 8-bit images with a fixed 0-255 range and no per-frame"
|
||||
" statistics.\nOff: the normal Image widget path (statistics and mean +/- 2 std"
|
||||
" autorange on every frame)."
|
||||
)
|
||||
self.fast_check.setChecked(self.image.fast_8bit_preview)
|
||||
self.fast_check.toggled.connect(self.set_fast_path)
|
||||
self.view_check.toggled.connect(lambda on: self.enable_view("Picture view", on))
|
||||
|
||||
left, right = QWidget(self), QWidget(self)
|
||||
@@ -355,7 +363,12 @@ class JpegPreviewDemo(QMainWindow):
|
||||
):
|
||||
column = QVBoxLayout(holder)
|
||||
column.setContentsMargins(0, 0, 0, 0)
|
||||
column.addWidget(check)
|
||||
header = QHBoxLayout()
|
||||
header.addWidget(check)
|
||||
if view is self.image:
|
||||
header.addWidget(self.fast_check)
|
||||
header.addStretch(1)
|
||||
column.addLayout(header)
|
||||
column.addWidget(view)
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal, self)
|
||||
splitter.addWidget(left)
|
||||
@@ -412,6 +425,14 @@ class JpegPreviewDemo(QMainWindow):
|
||||
self.jpeg_view.bec_dispatcher.disconnect_slot(self.jpeg_view.on_frame)
|
||||
self.trackers[name].clear()
|
||||
|
||||
@SafeSlot(bool)
|
||||
def set_fast_path(self, enabled: bool) -> None:
|
||||
"""Switch the Image widget between its normal and its fast 8-bit path."""
|
||||
self.fast_check.setChecked(enabled)
|
||||
self.image.fast_8bit_preview = enabled
|
||||
self.image.autorange = True
|
||||
self.trackers["Image widget"].clear()
|
||||
|
||||
@SafeSlot()
|
||||
def _on_view_frame(self):
|
||||
md = self.jpeg_view.last_frame_metadata
|
||||
@@ -475,6 +496,14 @@ class JpegPreviewDemo(QMainWindow):
|
||||
self.benchmark.start()
|
||||
|
||||
|
||||
# view name in the results -> (panel, Image widget fast 8-bit path)
|
||||
BENCHMARK_VIEWS = {
|
||||
"Image widget": ("Image widget", False),
|
||||
"Image widget (fast)": ("Image widget", True),
|
||||
"Picture view": ("Picture view", False),
|
||||
}
|
||||
|
||||
|
||||
class BenchmarkRunner(QObject):
|
||||
"""
|
||||
Steps through every size, encoding and view without blocking the GUI.
|
||||
@@ -488,7 +517,7 @@ class BenchmarkRunner(QObject):
|
||||
):
|
||||
super().__init__(demo)
|
||||
self.demo = demo
|
||||
self.steps = list(itertools.product(sizes, encodings, ("Image widget", "Picture view")))
|
||||
self.steps = list(itertools.product(sizes, encodings, BENCHMARK_VIEWS))
|
||||
self.frame_rate = frame_rate
|
||||
self.settle_s = settle_s
|
||||
self.measure_s = measure_s
|
||||
@@ -500,6 +529,7 @@ class BenchmarkRunner(QObject):
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._previous_views = (demo.image_check.isChecked(), demo.view_check.isChecked())
|
||||
self._previous_fast = demo.fast_check.isChecked()
|
||||
|
||||
def start(self):
|
||||
"""Run the first step."""
|
||||
@@ -515,8 +545,10 @@ class BenchmarkRunner(QObject):
|
||||
self.demo.benchmark_label.setText(
|
||||
f"Benchmark {self._index + 1}/{len(self.steps)}: {view}, {size} x {size}, {encoding}"
|
||||
)
|
||||
self.demo.enable_view("Image widget", view == "Image widget")
|
||||
self.demo.enable_view("Picture view", view == "Picture view")
|
||||
panel, fast = BENCHMARK_VIEWS[view]
|
||||
self.demo.set_fast_path(fast)
|
||||
self.demo.enable_view("Image widget", panel == "Image widget")
|
||||
self.demo.enable_view("Picture view", panel == "Picture view")
|
||||
self.demo.control.set_settings(
|
||||
width=size, height=size, encoding=encoding, frame_rate=self.frame_rate
|
||||
)
|
||||
@@ -533,7 +565,8 @@ class BenchmarkRunner(QObject):
|
||||
def _record(self):
|
||||
self._timer.timeout.disconnect(self._record)
|
||||
size, encoding, view = self.steps[self._index]
|
||||
summary = self.demo.summaries(since=self._measure_start).get(view, {})
|
||||
panel = BENCHMARK_VIEWS[view][0]
|
||||
summary = self.demo.summaries(since=self._measure_start).get(panel, {})
|
||||
row = {"view": view, "size": f"{size}x{size}", "encoding": encoding}
|
||||
row.update({key: round(summary.get(key, float("nan")), 2) for key in STAT_COLUMNS})
|
||||
self.rows.append(row)
|
||||
@@ -551,6 +584,7 @@ class BenchmarkRunner(QObject):
|
||||
csv_path.with_suffix(".json").write_text(json.dumps(self.rows, indent=1))
|
||||
self.demo.benchmark_label.setText(f"Benchmark saved to {csv_path}")
|
||||
self.demo.benchmark_button.setEnabled(True)
|
||||
self.demo.set_fast_path(self._previous_fast)
|
||||
self.demo.enable_view("Image widget", self._previous_views[0])
|
||||
self.demo.enable_view("Picture view", self._previous_views[1])
|
||||
print(f"Benchmark saved to {csv_path}")
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
"""
|
||||
Picture-only live view for JPEG preview frames.
|
||||
Picture-only live view for JPEG preview frames, with crosshair and ROIs in sensor pixels.
|
||||
|
||||
The frame is decoded by Qt into a QImage on the thread that receives Redis messages and drawn
|
||||
as a pixmap. There is no numpy data on the GUI side, so there are no levels, histogram, ROI or
|
||||
pixel values; a grayscale frame can still be coloured through an 8-bit colour table.
|
||||
as a pixmap. There is no numpy data on the GUI side, so there are no levels, histogram or pixel
|
||||
values; a grayscale frame can still be coloured through an 8-bit colour table.
|
||||
|
||||
The pixmap sits in a pyqtgraph plot, scaled onto the sensor the preview was taken from
|
||||
(``"sensor_shape"`` in the frame metadata, otherwise the frame's own size). Axes, crosshair and
|
||||
ROIs are therefore in sensor pixels and stay put when the preview resolution changes. As in the
|
||||
Image widget, array rows run along x and columns along y.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from qtpy.QtCore import QRectF, Qt, Signal
|
||||
from qtpy.QtGui import QColor, QImage, QPainter, QPixmap
|
||||
from qtpy.QtCore import QPointF, QRectF, Qt, Signal
|
||||
from qtpy.QtGui import QColor, QImage, QPixmap, QTransform
|
||||
from qtpy.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QComboBox,
|
||||
QGraphicsPixmapItem,
|
||||
QGraphicsScene,
|
||||
QGraphicsView,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
@@ -28,46 +37,53 @@ from qtpy.QtWidgets import (
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import Colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.jpeg_frames import qimage_preview
|
||||
from bec_widgets.utils.jpeg_frames import qimage_preview, sensor_shape
|
||||
|
||||
COLORMAPS = ("gray", "magma", "viridis", "plasma", "inferno")
|
||||
|
||||
|
||||
class _ZoomView(QGraphicsView):
|
||||
"""Graphics view with wheel zoom around the cursor."""
|
||||
|
||||
def __init__(self, scene: QGraphicsScene, parent=None):
|
||||
super().__init__(scene, parent)
|
||||
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
||||
self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
|
||||
self.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False)
|
||||
self.setBackgroundBrush(QColor(20, 20, 20))
|
||||
self.fit_on_next_frame = True
|
||||
|
||||
def wheelEvent(self, event): # pylint: disable=invalid-name
|
||||
factor = 1.25 if event.angleDelta().y() > 0 else 0.8
|
||||
self.scale(factor, factor)
|
||||
self.fit_on_next_frame = False
|
||||
ROI_COLORS = ("#ffd200", "#00d0ff", "#ff5ab4", "#7dff5a", "#ff8c1a", "#b48cff")
|
||||
|
||||
|
||||
class JpegView(BECWidget, QWidget):
|
||||
"""Live view that shows preview frames as a Qt picture, without decoding to numpy."""
|
||||
|
||||
frame_shown = Signal()
|
||||
rois_changed = Signal(list)
|
||||
|
||||
def __init__(self, parent=None, device: str | None = None, signal: str = "preview", **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self._device = None
|
||||
self._signal = signal
|
||||
self._color_table: list[int] | None = None
|
||||
self._frame_size: tuple[int, int] | None = None
|
||||
self._sensor: tuple[int, int] | None = None
|
||||
self._encoding: str | None = None
|
||||
self._fit_on_next_frame = True
|
||||
self._rois: list[pg.ROI] = []
|
||||
self._roi_count = 0
|
||||
self.last_frame_metadata: dict = {}
|
||||
self.last_update_ms = 0.0
|
||||
|
||||
self._scene = QGraphicsScene(self)
|
||||
self.view = pg.PlotWidget(parent=self)
|
||||
self.plot_item = self.view.getPlotItem()
|
||||
self.plot_item.setAspectLocked(True)
|
||||
self.plot_item.setLabel("bottom", "x (sensor px)")
|
||||
self.plot_item.setLabel("left", "y (sensor px)")
|
||||
self.plot_item.setMenuEnabled(False)
|
||||
self.plot_item.hideButtons()
|
||||
self.view_box: pg.ViewBox = self.plot_item.getViewBox()
|
||||
|
||||
self._pixmap_item = QGraphicsPixmapItem()
|
||||
self._pixmap_item.setTransformationMode(Qt.TransformationMode.FastTransformation)
|
||||
self._scene.addItem(self._pixmap_item)
|
||||
self.view = _ZoomView(self._scene, self)
|
||||
self._pixmap_item.setZValue(-100)
|
||||
self.view_box.addItem(self._pixmap_item, ignoreBounds=True)
|
||||
|
||||
pen = pg.mkPen("#ffd200", width=1, style=Qt.PenStyle.DashLine)
|
||||
self._v_line = pg.InfiniteLine(angle=90, movable=False, pen=pen)
|
||||
self._h_line = pg.InfiniteLine(angle=0, movable=False, pen=pen)
|
||||
for line in (self._v_line, self._h_line):
|
||||
line.setVisible(False)
|
||||
self.view_box.addItem(line, ignoreBounds=True)
|
||||
self.view.scene().sigMouseMoved.connect(self._on_mouse_moved)
|
||||
|
||||
self.colormap_box = QComboBox(self)
|
||||
self.colormap_box.addItems(COLORMAPS)
|
||||
@@ -75,22 +91,58 @@ class JpegView(BECWidget, QWidget):
|
||||
self.colormap_box.currentTextChanged.connect(self.set_colormap)
|
||||
fit_button = QPushButton("Fit", self)
|
||||
fit_button.clicked.connect(self.fit)
|
||||
self.crosshair_button = QPushButton("Crosshair", self)
|
||||
self.crosshair_button.setCheckable(True)
|
||||
self.crosshair_button.toggled.connect(self.set_crosshair)
|
||||
rect_button = QPushButton("+ Rect ROI", self)
|
||||
rect_button.clicked.connect(lambda: self.add_roi("rect"))
|
||||
circle_button = QPushButton("+ Circle ROI", self)
|
||||
circle_button.clicked.connect(lambda: self.add_roi("circle"))
|
||||
clear_button = QPushButton("Clear ROIs", self)
|
||||
clear_button.clicked.connect(self.clear_rois)
|
||||
self.info_label = QLabel("No frame yet", self)
|
||||
self.cursor_label = QLabel("", self)
|
||||
self.cursor_label.setMinimumWidth(170)
|
||||
|
||||
self.roi_table = QTableWidget(0, 5, self)
|
||||
self.roi_table.setHorizontalHeaderLabels(["ROI", "x from", "x to", "y from", "y to"])
|
||||
self.roi_table.verticalHeader().setVisible(False)
|
||||
self.roi_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||||
self.roi_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.roi_table.setToolTip(
|
||||
"ROI bounds in sensor pixels. Drag the ROI to move it, its handles to resize it;"
|
||||
" right-click an ROI to remove it."
|
||||
)
|
||||
self.roi_table.setMaximumHeight(150)
|
||||
self.roi_table.setVisible(False)
|
||||
|
||||
bar = QHBoxLayout()
|
||||
bar.addWidget(QLabel("Colormap", self))
|
||||
bar.addWidget(self.colormap_box)
|
||||
bar.addWidget(fit_button)
|
||||
bar.addWidget(self.crosshair_button)
|
||||
bar.addWidget(rect_button)
|
||||
bar.addWidget(circle_button)
|
||||
bar.addWidget(clear_button)
|
||||
bar.addStretch(1)
|
||||
bar.addWidget(self.cursor_label)
|
||||
bar.addWidget(self.info_label)
|
||||
splitter = QSplitter(Qt.Orientation.Vertical, self)
|
||||
splitter.addWidget(self.view)
|
||||
splitter.addWidget(self.roi_table)
|
||||
splitter.setStretchFactor(0, 5)
|
||||
splitter.setStretchFactor(1, 1)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addLayout(bar)
|
||||
layout.addWidget(self.view)
|
||||
layout.addWidget(splitter)
|
||||
|
||||
if device:
|
||||
self.set_source(device, signal)
|
||||
|
||||
################################################################################
|
||||
# Source and display
|
||||
|
||||
def set_source(self, device: str, signal: str = "preview") -> None:
|
||||
"""
|
||||
Subscribe to a device preview signal.
|
||||
@@ -104,7 +156,7 @@ class JpegView(BECWidget, QWidget):
|
||||
self.on_frame, MessageEndpoints.device_preview(self._device, self._signal)
|
||||
)
|
||||
self._device, self._signal = device, signal
|
||||
self.view.fit_on_next_frame = True
|
||||
self._fit_on_next_frame = True
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_frame,
|
||||
MessageEndpoints.device_preview(device, signal),
|
||||
@@ -128,8 +180,11 @@ class JpegView(BECWidget, QWidget):
|
||||
|
||||
@SafeSlot()
|
||||
def fit(self) -> None:
|
||||
"""Fit the whole frame into the view."""
|
||||
self.view.fitInView(self._pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
|
||||
"""Fit the whole sensor into the view."""
|
||||
if self._sensor is None:
|
||||
return
|
||||
rows, cols = self._sensor
|
||||
self.view_box.setRange(QRectF(0, 0, rows, cols), padding=0)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_frame(self, msg: dict, metadata: dict) -> None:
|
||||
@@ -151,15 +206,192 @@ class JpegView(BECWidget, QWidget):
|
||||
pixmap = QPixmap.fromImage(indexed)
|
||||
else:
|
||||
pixmap = QPixmap.fromImage(qimage)
|
||||
new_size = pixmap.size() != self._pixmap_item.pixmap().size()
|
||||
frame_size = (qimage.height(), qimage.width())
|
||||
sensor = sensor_shape(metadata, frame_size)
|
||||
encoding = metadata.get("encoding", "raw")
|
||||
if (frame_size, sensor, encoding) != (self._frame_size, self._sensor, self._encoding):
|
||||
self._set_geometry(frame_size, sensor, encoding)
|
||||
self._pixmap_item.setPixmap(pixmap)
|
||||
if new_size:
|
||||
self._scene.setSceneRect(QRectF(pixmap.rect()))
|
||||
if new_size or self.view.fit_on_next_frame:
|
||||
self.view.fit_on_next_frame = False
|
||||
self.fit()
|
||||
self.last_update_ms = (time.perf_counter() - t0) * 1e3
|
||||
self.last_frame_metadata = metadata
|
||||
encoding = metadata.get("encoding", "raw")
|
||||
self.info_label.setText(f"{qimage.width()} x {qimage.height()} {encoding}")
|
||||
self.frame_shown.emit()
|
||||
|
||||
def _set_geometry(
|
||||
self, frame_size: tuple[int, int], sensor: tuple[int, int], encoding: str
|
||||
) -> None:
|
||||
"""Place frame pixels on sensor pixels: pixmap (col, row) -> view (row, col)."""
|
||||
sensor_changed = sensor != self._sensor
|
||||
self._frame_size, self._sensor, self._encoding = frame_size, sensor, encoding
|
||||
scale_r, scale_c = sensor[0] / frame_size[0], sensor[1] / frame_size[1]
|
||||
self._pixmap_item.setTransform(QTransform(0, scale_c, scale_r, 0, 0, 0))
|
||||
text = f"{frame_size[1]} x {frame_size[0]} {encoding}"
|
||||
if frame_size != sensor:
|
||||
text += f", sensor {sensor[1]} x {sensor[0]}"
|
||||
self.info_label.setText(text)
|
||||
if sensor_changed or self._fit_on_next_frame:
|
||||
self._fit_on_next_frame = False
|
||||
self.fit()
|
||||
|
||||
################################################################################
|
||||
# Crosshair
|
||||
|
||||
@SafeSlot(bool)
|
||||
def set_crosshair(self, enabled: bool) -> None:
|
||||
"""
|
||||
Show or hide the crosshair that follows the mouse.
|
||||
|
||||
Args:
|
||||
enabled (bool): Whether the crosshair is shown.
|
||||
"""
|
||||
if self.crosshair_button.isChecked() != enabled:
|
||||
self.crosshair_button.setChecked(enabled) # comes back here through toggled
|
||||
return
|
||||
if not enabled:
|
||||
for line in (self._v_line, self._h_line):
|
||||
line.setVisible(False)
|
||||
self.cursor_label.setText("")
|
||||
|
||||
def _on_mouse_moved(self, scene_pos: QPointF) -> None:
|
||||
if not self.crosshair_button.isChecked():
|
||||
return
|
||||
if not self.view_box.sceneBoundingRect().contains(scene_pos):
|
||||
return
|
||||
point = self.view_box.mapSceneToView(scene_pos)
|
||||
self.update_crosshair(point.x(), point.y())
|
||||
|
||||
def update_crosshair(self, x: float, y: float) -> None:
|
||||
"""
|
||||
Move the crosshair to a point and show its sensor pixel.
|
||||
|
||||
Args:
|
||||
x (float): x in sensor pixels (array row).
|
||||
y (float): y in sensor pixels (array column).
|
||||
"""
|
||||
self._v_line.setPos(x)
|
||||
self._h_line.setPos(y)
|
||||
for line in (self._v_line, self._h_line):
|
||||
line.setVisible(True)
|
||||
if self._sensor is None:
|
||||
return
|
||||
rows, cols = self._sensor
|
||||
if 0 <= x < rows and 0 <= y < cols:
|
||||
self.cursor_label.setText(f"x {int(x)} y {int(y)}")
|
||||
else:
|
||||
self.cursor_label.setText("outside the sensor")
|
||||
|
||||
################################################################################
|
||||
# ROIs
|
||||
|
||||
def add_roi(self, shape: str = "rect") -> pg.ROI:
|
||||
"""
|
||||
Add a draggable ROI in the middle of the sensor.
|
||||
|
||||
Args:
|
||||
shape (str): "rect" or "circle".
|
||||
|
||||
Returns:
|
||||
pg.ROI: The new ROI. Its position and size are in sensor pixels.
|
||||
"""
|
||||
rows, cols = self._sensor or (100, 100)
|
||||
size = max(4, int(min(rows, cols) / 5))
|
||||
pos = (int(rows / 2 - size / 2), int(cols / 2 - size / 2))
|
||||
color = ROI_COLORS[self._roi_count % len(ROI_COLORS)]
|
||||
self._roi_count += 1
|
||||
options = {
|
||||
"pen": pg.mkPen(color, width=2),
|
||||
"removable": True,
|
||||
"snapSize": 1,
|
||||
"translateSnap": True,
|
||||
"scaleSnap": True,
|
||||
}
|
||||
if shape == "circle":
|
||||
roi = pg.CircleROI(pos, (size, size), **options)
|
||||
elif shape == "rect":
|
||||
roi = pg.RectROI(pos, (size, size), **options)
|
||||
else:
|
||||
raise ValueError(f"Unknown ROI shape {shape!r}; use 'rect' or 'circle'")
|
||||
roi.setProperty("roi_name", f"{shape} {self._roi_count}")
|
||||
roi.setProperty("roi_shape", shape)
|
||||
roi.setProperty("roi_color", color)
|
||||
roi.sigRegionChanged.connect(self._update_roi_table)
|
||||
roi.sigRemoveRequested.connect(self.remove_roi)
|
||||
self.view_box.addItem(roi, ignoreBounds=True)
|
||||
self._rois.append(roi)
|
||||
self._update_roi_table()
|
||||
return roi
|
||||
|
||||
@SafeSlot(object)
|
||||
def remove_roi(self, roi: pg.ROI) -> None:
|
||||
"""
|
||||
Remove one ROI.
|
||||
|
||||
Args:
|
||||
roi (pg.ROI): The ROI to remove.
|
||||
"""
|
||||
if roi not in self._rois:
|
||||
return
|
||||
self._rois.remove(roi)
|
||||
self.view_box.removeItem(roi)
|
||||
# pyqtgraph gives the ROI and each of its handles a parentless menu
|
||||
for item in (roi, *roi.getHandles()):
|
||||
_delete_menu(getattr(item, "menu", None))
|
||||
self._update_roi_table()
|
||||
|
||||
@SafeSlot()
|
||||
def clear_rois(self) -> None:
|
||||
"""Remove all ROIs."""
|
||||
for roi in list(self._rois):
|
||||
self.remove_roi(roi)
|
||||
|
||||
def roi_regions(self) -> list[dict]:
|
||||
"""
|
||||
ROI bounds in sensor pixels.
|
||||
|
||||
Returns:
|
||||
list[dict]: One dict per ROI with ``name``, ``shape``, ``x`` and ``y`` (each a
|
||||
``(from, to)`` pair; x runs along array rows, y along columns) and, for circles,
|
||||
``center`` and ``radius``.
|
||||
"""
|
||||
regions = []
|
||||
for roi in self._rois:
|
||||
(x0, y0), (w, h) = tuple(roi.pos()), tuple(roi.size())
|
||||
region = {
|
||||
"name": roi.property("roi_name"),
|
||||
"shape": roi.property("roi_shape"),
|
||||
"x": (x0, x0 + w),
|
||||
"y": (y0, y0 + h),
|
||||
}
|
||||
if region["shape"] == "circle":
|
||||
region["center"] = (x0 + w / 2, y0 + h / 2)
|
||||
region["radius"] = w / 2
|
||||
regions.append(region)
|
||||
return regions
|
||||
|
||||
def _update_roi_table(self) -> None:
|
||||
regions = self.roi_regions()
|
||||
self.roi_table.setRowCount(len(regions))
|
||||
for row, (roi, region) in enumerate(zip(self._rois, regions)):
|
||||
name = QTableWidgetItem(region["name"])
|
||||
name.setForeground(QColor(roi.property("roi_color")))
|
||||
if region["shape"] == "circle":
|
||||
cx, cy = region["center"]
|
||||
name.setToolTip(f"centre x {cx:g}, y {cy:g}, radius {region['radius']:g}")
|
||||
self.roi_table.setItem(row, 0, name)
|
||||
values = (*region["x"], *region["y"])
|
||||
for col, value in enumerate(values, start=1):
|
||||
self.roi_table.setItem(row, col, QTableWidgetItem(f"{value:g}"))
|
||||
self.roi_table.setVisible(bool(regions))
|
||||
self.rois_changed.emit(regions)
|
||||
|
||||
def cleanup(self):
|
||||
"""Remove ROIs and the plot's context menus, which are top-level widgets."""
|
||||
self.clear_rois()
|
||||
_delete_menu(self.view_box.menu)
|
||||
_delete_menu(self.plot_item.ctrlMenu)
|
||||
super().cleanup()
|
||||
|
||||
|
||||
def _delete_menu(menu) -> None:
|
||||
if menu is not None:
|
||||
menu.close()
|
||||
menu.deleteLater()
|
||||
@@ -5,6 +5,10 @@ A JPEG preview frame is a ``DevicePreviewMessage`` whose ``data`` is a flat uint
|
||||
the JPEG bytes, with message metadata ``{"encoding": "jpeg", "shape": [h, w], "channels": 1 | 3}``.
|
||||
Frames without that metadata are passed through untouched.
|
||||
|
||||
A preview may be scaled down from a bigger sensor. ``"sensor_shape": [H, W]`` in the metadata
|
||||
says which sensor size the frame covers; ``sensor_transform`` maps frame pixels onto sensor
|
||||
pixels, so crosshair and ROI coordinates do not change with the preview resolution.
|
||||
|
||||
The ``*_preview`` functions match the ``preprocess`` hook of ``BECDispatcher.connect_slot``:
|
||||
they run on the thread that receives Redis messages, so decoding never blocks the GUI thread.
|
||||
"""
|
||||
@@ -15,7 +19,7 @@ import time
|
||||
|
||||
import numpy as np
|
||||
from qtpy.QtCore import QByteArray
|
||||
from qtpy.QtGui import QImage
|
||||
from qtpy.QtGui import QImage, QTransform
|
||||
|
||||
try: # libjpeg-turbo bindings; much faster than Qt's own JPEG plugin
|
||||
import simplejpeg
|
||||
@@ -79,6 +83,30 @@ def decode_jpeg(data: bytes | np.ndarray, channels: int | None = None) -> np.nda
|
||||
return qimage_to_array(qimage)
|
||||
|
||||
|
||||
def column_first(image: np.ndarray, tile: int = 256) -> np.ndarray:
|
||||
"""
|
||||
Copy a frame into column-first memory order; values and indexing stay the same.
|
||||
|
||||
pyqtgraph draws array rows along x and makes a transposed, contiguous copy of the frame
|
||||
before every paint. For a frame already laid out column-first that copy is a no-op, so
|
||||
calling this off the GUI thread takes the copy out of the paint. The copy runs in tiles,
|
||||
which is several times faster than one strided copy of a large frame.
|
||||
|
||||
Args:
|
||||
image (np.ndarray): (h, w) or (h, w, channels) frame.
|
||||
tile (int): Tile edge in pixels.
|
||||
|
||||
Returns:
|
||||
np.ndarray: The same frame, with ``np.swapaxes(result, 0, 1)`` C-contiguous.
|
||||
"""
|
||||
h, w = image.shape[:2]
|
||||
out = np.empty((w, h) + image.shape[2:], image.dtype)
|
||||
for r in range(0, h, tile):
|
||||
for c in range(0, w, tile):
|
||||
out[c : c + tile, r : r + tile] = np.swapaxes(image[r : r + tile, c : c + tile], 0, 1)
|
||||
return np.swapaxes(out, 0, 1)
|
||||
|
||||
|
||||
def decode_jpeg_preview(msg_content: dict, metadata: dict) -> tuple[dict, dict]:
|
||||
"""
|
||||
Dispatcher preprocess hook: replace JPEG bytes in a preview message by the decoded array.
|
||||
@@ -95,6 +123,7 @@ def decode_jpeg_preview(msg_content: dict, metadata: dict) -> tuple[dict, dict]:
|
||||
data = msg_content.get("data")
|
||||
t0 = time.perf_counter()
|
||||
image = decode_jpeg(data, metadata.get("channels"))
|
||||
image = column_first(image)
|
||||
decode_ms = (time.perf_counter() - t0) * 1e3
|
||||
metadata = {**metadata, "decode_ms": decode_ms, "transfer_nbytes": int(np.size(data))}
|
||||
return {**msg_content, "data": image}, metadata
|
||||
@@ -130,3 +159,37 @@ def qimage_preview(msg_content: dict, metadata: dict) -> tuple[dict, dict]:
|
||||
decode_ms = (time.perf_counter() - t0) * 1e3
|
||||
metadata = {**metadata, "decode_ms": decode_ms, "transfer_nbytes": int(np.asarray(data).nbytes)}
|
||||
return {**msg_content, "qimage": qimage}, metadata
|
||||
|
||||
|
||||
def sensor_shape(metadata: dict | None, frame_shape: tuple[int, ...]) -> tuple[int, int]:
|
||||
"""
|
||||
Sensor height and width a preview frame covers.
|
||||
|
||||
Args:
|
||||
metadata (dict | None): Preview message metadata.
|
||||
frame_shape (tuple[int, ...]): Shape of the frame, rows first.
|
||||
|
||||
Returns:
|
||||
tuple[int, int]: ``metadata["sensor_shape"]`` if given, otherwise the frame's own size.
|
||||
"""
|
||||
shape = (metadata or {}).get("sensor_shape")
|
||||
if shape is not None and len(shape) == 2 and min(shape) > 0:
|
||||
return int(shape[0]), int(shape[1])
|
||||
return int(frame_shape[0]), int(frame_shape[1])
|
||||
|
||||
|
||||
def sensor_transform(metadata: dict | None, frame_shape: tuple[int, ...]) -> QTransform | None:
|
||||
"""
|
||||
Scale from frame pixels to sensor pixels for an image item with rows on the x axis.
|
||||
|
||||
Args:
|
||||
metadata (dict | None): Preview message metadata.
|
||||
frame_shape (tuple[int, ...]): Shape of the frame, rows first.
|
||||
|
||||
Returns:
|
||||
QTransform | None: The scaling, or None if the frame has no ``sensor_shape``.
|
||||
"""
|
||||
if not (metadata or {}).get("sensor_shape"):
|
||||
return None
|
||||
rows, cols = sensor_shape(metadata, frame_shape)
|
||||
return QTransform.fromScale(rows / frame_shape[0], cols / frame_shape[1])
|
||||
@@ -12,7 +12,12 @@ from qtpy.QtWidgets import QWidget
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors, apply_theme
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.jpeg_frames import decode_jpeg_preview
|
||||
from bec_widgets.utils.jpeg_frames import (
|
||||
decode_jpeg_preview,
|
||||
is_jpeg_frame,
|
||||
sensor_shape,
|
||||
sensor_transform,
|
||||
)
|
||||
from bec_widgets.widgets.plots.image.image_base import ImageBase
|
||||
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
||||
from bec_widgets.widgets.plots.image.toolbar_components.device_selection import (
|
||||
@@ -120,6 +125,9 @@ class Image(ImageBase):
|
||||
self.gui_id = config.gui_id
|
||||
self.subscriptions: defaultdict[str, ImageLayerConfig] = defaultdict(ImageLayerConfig)
|
||||
self.last_frame_metadata: dict = {}
|
||||
self._frame_extent: tuple[int, int] | None = None
|
||||
# Draw JPEG preview frames as 8-bit images without per-frame statistics
|
||||
self.fast_8bit_preview = True
|
||||
# Store signal configs separately (not serialized to QSettings)
|
||||
self._signal_configs: dict[str, dict] = {}
|
||||
|
||||
@@ -1008,13 +1016,19 @@ class Image(ImageBase):
|
||||
logger.warning("No data received for image update from 2D.")
|
||||
return
|
||||
self.last_frame_metadata = metadata
|
||||
previous = image.raw_data
|
||||
if previous is not None and np.shape(previous) != np.shape(data):
|
||||
# A preview scaled down from a bigger sensor is drawn in sensor pixels
|
||||
transform = sensor_transform(metadata, np.shape(data))
|
||||
extent = sensor_shape(metadata, np.shape(data))
|
||||
if transform is None and image.image_transform is not None:
|
||||
image.resetTransform()
|
||||
if image.raw_data is not None and extent != self._frame_extent:
|
||||
# e.g. the detector changed resolution: show the whole new frame
|
||||
self._autorange_on_next_update = True
|
||||
self._frame_extent = extent
|
||||
if self._color_bar is not None:
|
||||
self._color_bar.blockSignals(True)
|
||||
image.set_data(data)
|
||||
image.preview_8bit = self.fast_8bit_preview and is_jpeg_frame(metadata)
|
||||
image.set_data(data, transform=transform)
|
||||
if self._color_bar is not None:
|
||||
self._color_bar.blockSignals(False)
|
||||
if self._autorange_on_next_update:
|
||||
|
||||
@@ -88,6 +88,8 @@ class ImageItem(BECConnector, pg.ImageItem):
|
||||
else:
|
||||
self.parent_image = None
|
||||
self.image_transform = None
|
||||
# Set by the owner for frames a camera already scaled to 8 bit (e.g. JPEG previews)
|
||||
self.preview_8bit = False
|
||||
super().__init__(config=config, gui_id=gui_id, **kwargs)
|
||||
|
||||
self.raw_data = None
|
||||
@@ -197,6 +199,9 @@ class ImageItem(BECConnector, pg.ImageItem):
|
||||
def apply_autorange(self):
|
||||
if self.raw_data is None:
|
||||
return
|
||||
if self._draws_8bit_as_is():
|
||||
self._show_full_8bit_range()
|
||||
return
|
||||
data = self.image
|
||||
if data is None:
|
||||
data = self.raw_data
|
||||
@@ -227,6 +232,16 @@ class ImageItem(BECConnector, pg.ImageItem):
|
||||
if self.raw_data is None:
|
||||
return
|
||||
|
||||
if self._draws_8bit_as_is():
|
||||
# The camera already scaled the frame: draw it as it is, without a processed copy
|
||||
# or statistics over the whole frame. Autorange shows the full 0-255 range.
|
||||
self.setImage(self.raw_data, autoLevels=False)
|
||||
if self.image_transform is not None:
|
||||
self.setTransform(self.image_transform)
|
||||
if self.config.autorange:
|
||||
self._show_full_8bit_range()
|
||||
return
|
||||
|
||||
if np.all(np.isnan(self.raw_data)):
|
||||
return
|
||||
|
||||
@@ -238,6 +253,20 @@ class ImageItem(BECConnector, pg.ImageItem):
|
||||
self.setTransform(self.image_transform)
|
||||
self.autorange = autorange
|
||||
|
||||
def _draws_8bit_as_is(self) -> bool:
|
||||
"""Whether the current frame is an 8-bit preview frame that needs no processing."""
|
||||
processing = self.config.processing
|
||||
return (
|
||||
self.preview_8bit
|
||||
and getattr(self.raw_data, "dtype", None) == np.uint8
|
||||
and not (processing.fft or processing.log or processing.transpose)
|
||||
and not processing.num_rotation_90
|
||||
)
|
||||
|
||||
def _show_full_8bit_range(self) -> None:
|
||||
if self.levels is None or tuple(self.levels) != (0, 255):
|
||||
self.set_v_range((0, 255), disable_autorange=False)
|
||||
|
||||
@property
|
||||
def fft(self) -> bool:
|
||||
"""Get or set whether FFT postprocessing is enabled."""
|
||||
|
||||
@@ -189,3 +189,145 @@ def test_jpeg_view_shows_frame_and_colormap(qtbot, mocked_client):
|
||||
assert view._pixmap_item.pixmap().height() == 24
|
||||
view.set_colormap("gray")
|
||||
assert view._color_table is None
|
||||
|
||||
|
||||
def test_sensor_transform_maps_frame_to_sensor_pixels():
|
||||
assert jpeg_frames.sensor_transform({}, (512, 256)) is None
|
||||
assert jpeg_frames.sensor_shape({}, (512, 256)) == (512, 256)
|
||||
metadata = {"sensor_shape": [4096, 2048]}
|
||||
assert jpeg_frames.sensor_shape(metadata, (512, 256)) == (4096, 2048)
|
||||
transform = jpeg_frames.sensor_transform(metadata, (512, 256))
|
||||
# image item axes: x = row, y = column
|
||||
assert transform.map(512.0, 256.0) == (4096.0, 2048.0)
|
||||
|
||||
|
||||
def test_image_draws_preview_in_sensor_pixels(qtbot, mocked_client):
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
metadata = {"sensor_shape": [400, 800]}
|
||||
view.on_image_update_2d({"data": np.zeros((100, 200))}, metadata)
|
||||
bounds = view.main_image.mapRectToView(view.main_image.boundingRect())
|
||||
assert (bounds.width(), bounds.height()) == (400, 800)
|
||||
view._autorange_on_next_update = False
|
||||
with mock.patch.object(view, "auto_range") as auto_range:
|
||||
# a new preview resolution of the same sensor keeps the zoom
|
||||
view.on_image_update_2d({"data": np.zeros((50, 100))}, metadata)
|
||||
auto_range.assert_not_called()
|
||||
bounds = view.main_image.mapRectToView(view.main_image.boundingRect())
|
||||
assert (bounds.width(), bounds.height()) == (400, 800)
|
||||
# frames without a sensor shape are drawn in their own pixels again
|
||||
view.on_image_update_2d({"data": np.zeros((10, 20))}, {})
|
||||
bounds = view.main_image.mapRectToView(view.main_image.boundingRect())
|
||||
assert (bounds.width(), bounds.height()) == (10, 20)
|
||||
|
||||
|
||||
def _show_frame(view, image, metadata):
|
||||
content, metadata = qimage_preview(
|
||||
{"data": np.frombuffer(_jpeg_bytes(image), np.uint8)}, {"encoding": "jpeg", **metadata}
|
||||
)
|
||||
view.on_frame(content, metadata)
|
||||
|
||||
|
||||
def test_jpeg_view_places_preview_on_sensor(qtbot, mocked_client):
|
||||
view = create_widget(qtbot, JpegView, client=mocked_client)
|
||||
_show_frame(view, _gradient(h=24, w=40), {"sensor_shape": [240, 80]})
|
||||
bounds = view._pixmap_item.mapRectToParent(view._pixmap_item.boundingRect())
|
||||
# rows along x, columns along y, scaled up to the sensor
|
||||
assert (bounds.width(), bounds.height()) == (240, 80)
|
||||
assert "sensor 80 x 240" in view.info_label.text()
|
||||
# another preview resolution covers the same sensor area
|
||||
_show_frame(view, _gradient(h=12, w=20), {"sensor_shape": [240, 80]})
|
||||
bounds = view._pixmap_item.mapRectToParent(view._pixmap_item.boundingRect())
|
||||
assert (bounds.width(), bounds.height()) == (240, 80)
|
||||
|
||||
|
||||
def test_jpeg_view_crosshair_reports_sensor_pixel(qtbot, mocked_client):
|
||||
view = create_widget(qtbot, JpegView, client=mocked_client)
|
||||
_show_frame(view, _gradient(h=24, w=40), {"sensor_shape": [240, 400]})
|
||||
view.set_crosshair(True)
|
||||
assert view.crosshair_button.isChecked()
|
||||
view.update_crosshair(120.7, 33.2)
|
||||
assert view.cursor_label.text() == "x 120 y 33"
|
||||
assert view._v_line.value() == pytest.approx(120.7)
|
||||
view.update_crosshair(-1, 10)
|
||||
assert view.cursor_label.text() == "outside the sensor"
|
||||
view.set_crosshair(False)
|
||||
assert not view._v_line.isVisible() and view.cursor_label.text() == ""
|
||||
|
||||
|
||||
def test_jpeg_view_rois_in_sensor_pixels(qtbot, mocked_client):
|
||||
view = create_widget(qtbot, JpegView, client=mocked_client)
|
||||
_show_frame(view, _gradient(h=24, w=40), {"sensor_shape": [1000, 500]})
|
||||
with qtbot.waitSignal(view.rois_changed) as blocker:
|
||||
rect = view.add_roi("rect")
|
||||
assert blocker.args[0][0]["x"] == (450, 550) and blocker.args[0][0]["y"] == (200, 300)
|
||||
circle = view.add_roi("circle")
|
||||
rect.setPos((10, 20))
|
||||
rect.setSize((30, 40))
|
||||
regions = view.roi_regions()
|
||||
assert regions[0]["x"] == (10, 40) and regions[0]["y"] == (20, 60)
|
||||
assert regions[1]["shape"] == "circle" and regions[1]["radius"] == 50
|
||||
assert view.roi_table.rowCount() == 2
|
||||
assert view.roi_table.item(0, 1).text() == "10"
|
||||
# ROIs stay where they are when the preview resolution changes
|
||||
_show_frame(view, _gradient(h=12, w=20), {"sensor_shape": [1000, 500]})
|
||||
assert view.roi_regions()[0]["x"] == (10, 40)
|
||||
view.remove_roi(circle)
|
||||
assert len(view.roi_regions()) == 1
|
||||
view.clear_rois()
|
||||
assert view.roi_regions() == [] and view.roi_table.isHidden()
|
||||
with pytest.raises(ValueError):
|
||||
view.add_roi("polygon")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape", [(5, 7), (300, 520), (300, 520, 3)])
|
||||
def test_column_first_keeps_values(shape):
|
||||
image = np.random.default_rng(0).integers(0, 255, shape, dtype=np.uint8)
|
||||
result = jpeg_frames.column_first(image, tile=256)
|
||||
assert np.array_equal(result, image)
|
||||
assert np.swapaxes(result, 0, 1).flags["C_CONTIGUOUS"]
|
||||
|
||||
|
||||
def test_decode_jpeg_preview_lays_frame_out_column_first():
|
||||
payload = np.frombuffer(_jpeg_bytes(_gradient()), np.uint8)
|
||||
content, _ = decode_jpeg_preview({"data": payload}, {"encoding": "jpeg", "channels": 1})
|
||||
assert content["data"].shape == (24, 40)
|
||||
assert content["data"].T.flags["C_CONTIGUOUS"]
|
||||
|
||||
|
||||
def _noisy_8bit(h=32, w=48):
|
||||
return np.random.default_rng(1).integers(0, 60, (h, w), dtype=np.uint8)
|
||||
|
||||
|
||||
def test_image_fast_path_draws_jpeg_frames_as_they_are(qtbot, mocked_client):
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
frame = _noisy_8bit()
|
||||
with mock.patch("bec_widgets.widgets.plots.image.image_item.ImageStats.from_data") as from_data:
|
||||
view.on_image_update_2d({"data": frame}, {"encoding": "jpeg"})
|
||||
from_data.assert_not_called()
|
||||
image = view.main_image
|
||||
assert np.shares_memory(image.image, frame) # no processed copy
|
||||
assert tuple(image.levels) == (0, 255) and image.autorange
|
||||
# a manual range survives later frames
|
||||
image.v_range = (10, 40)
|
||||
view.on_image_update_2d({"data": _noisy_8bit()}, {"encoding": "jpeg"})
|
||||
assert tuple(image.levels) == (10, 40)
|
||||
# auto levels from the menu go back to the full 8-bit range
|
||||
view.autorange = True
|
||||
assert tuple(image.levels) == (0, 255)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"setup, metadata",
|
||||
[
|
||||
(lambda view: setattr(view, "fast_8bit_preview", False), {"encoding": "jpeg"}),
|
||||
(lambda view: None, {}), # uint8 frames that are not JPEG previews
|
||||
(lambda view: setattr(view.main_image, "log", True), {"encoding": "jpeg"}),
|
||||
],
|
||||
)
|
||||
def test_image_normal_path_when_fast_path_does_not_apply(qtbot, mocked_client, setup, metadata):
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
setup(view)
|
||||
frame = _noisy_8bit()
|
||||
view.on_image_update_2d({"data": frame}, metadata)
|
||||
image = view.main_image
|
||||
assert tuple(image.levels) != (0, 255)
|
||||
Reference in new issue
Block a user