Source code for pystream.plugins.roi

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ROI Plugin - mirrors the line plugin pattern exactly.

All graphics items live in the QGraphicsScene (scene coords throughout).
pg.RectROI is placed in the scene, so its pos/size are scene coords, and
its built-in handles work without any coordinate-system gymnastics.

Interaction:
  - Toggle ON      → crosshair cursor, wait for draw.
  - Press & drag   → live rectangle preview.
  - Release        → ROI finalised; PyQtGraph handles active for reshape/move.
  - Toggle OFF     → ROI erased.
"""

import logging
from typing import Optional
import numpy as np
import pyqtgraph as pg
from PyQt5 import QtWidgets, QtCore


[docs] class ROIManager: _IDLE = 'idle' _PLACING = 'placing' _PLACED = 'placed' def __init__( self, image_view: pg.ImageView, stats_label: QtWidgets.QLabel, logger: Optional[logging.Logger] = None, *, handle_size: int = 10, roi_pen_width: int = 2, show_dimensions: bool = True, ): self.image_view = image_view self.stats_label = stats_label self.logger = logger self.handle_size = max(6, int(handle_size)) self.roi_pen_width = max(1, int(roi_pen_width)) self.show_dimensions = show_dimensions self.roi: Optional[pg.ROI] = None self.enabled = False self._last_image: Optional[np.ndarray] = None self.dimension_text: Optional[pg.TextItem] = None self._state = self._IDLE self._press_scene = None # QPointF self._preview: Optional[QtWidgets.QGraphicsRectItem] = None self._gv = image_view.ui.graphicsView self._vp_filter = _RoiVpFilter(self) self._gv.viewport().installEventFilter(self._vp_filter) # ── helpers ─────────────────────────────────────────────────────────── def _sc(self): return self._gv.scene() def _to_scene(self, vp_pos) -> QtCore.QPointF: return self._gv.mapToScene(vp_pos) # ── public API ────────────────────────────────────────────────────────
[docs] def toggle(self, state): from PyQt5.QtCore import Qt self.enabled = (state == Qt.Checked) if self.enabled: self._state = self._IDLE self._gv.viewport().setCursor(QtCore.Qt.CrossCursor) self.stats_label.setText("ROI: click and drag to draw") else: self._remove_all() self._state = self._IDLE self._gv.viewport().setCursor(QtCore.Qt.ArrowCursor) self.stats_label.setText("No ROI selected")
[docs] def reset(self): if self._last_image is None: QtWidgets.QMessageBox.information(None, "Reset ROI", "No image available.") return img_item = self.image_view.getImageItem() h, w = self._last_image.shape[:2] rw = max(2, w // 4) rh = max(2, h // 4) rx = (w - rw) // 2 ry = (h - rh) // 2 # convert image-pixel coords to scene coords p = img_item.mapToScene(QtCore.QPointF(rx, ry)) p2 = img_item.mapToScene(QtCore.QPointF(rx + rw, ry + rh)) self._remove_all() self._build_roi(p.x(), p.y(), abs(p2.x() - p.x()), abs(p2.y() - p.y())) self.enabled = True self._state = self._PLACED self._gv.viewport().setCursor(QtCore.Qt.ArrowCursor) self._update_stats()
[docs] def update_stats(self, image: np.ndarray): self._last_image = image if self.enabled and self._state == self._PLACED and self.roi is not None: self._update_stats()
[docs] def get_roi_data(self, image: Optional[np.ndarray] = None) -> Optional[np.ndarray]: if not self.enabled or self.roi is None: return None img = image if image is not None else self._last_image if img is None: return None try: roi_slice, _ = self.roi.getArraySlice(img, self.image_view.getImageItem()) return img[roi_slice[0], roi_slice[1]] except Exception as e: if self.logger: self.logger.warning("ROI data extract: %s", e) return None
[docs] def get_roi_bounds(self) -> Optional[dict]: if self.roi is None: return None pos = self.roi.pos() size = self.roi.size() return {"x": pos[0], "y": pos[1], "width": size[0], "height": size[1]}
[docs] def set_roi_bounds(self, x, y, width, height): img_item = self.image_view.getImageItem() if img_item is not None: p = img_item.mapToScene(QtCore.QPointF(x, y)) p2 = img_item.mapToScene(QtCore.QPointF(x + width, y + height)) sx, sy = p.x(), p.y() sw, sh = abs(p2.x() - p.x()), abs(p2.y() - p.y()) else: sx, sy, sw, sh = x, y, width, height self._remove_all() self._build_roi(sx, sy, sw, sh) self.enabled = True self._state = self._PLACED self._update_stats()
[docs] def cleanup(self): self._remove_all() try: self._gv.viewport().removeEventFilter(self._vp_filter) except Exception: pass self.enabled = False self._last_image = None
# ── mouse events ───────────────────────────────────────────────────── def _on_press(self, vp_pos) -> bool: if not self.enabled or self._state != self._IDLE: return False sp = self._to_scene(vp_pos) sc = self._sc() if sc is None: return False self._press_scene = sp pen = pg.mkPen('y', width=2) pen.setCosmetic(True) self._preview = QtWidgets.QGraphicsRectItem(sp.x(), sp.y(), 0, 0) self._preview.setPen(pen) self._preview.setZValue(1000) sc.addItem(self._preview) self._state = self._PLACING return True def _on_move(self, vp_pos) -> bool: if self._state != self._PLACING or self._preview is None: return False sp = self._to_scene(vp_pos) x = min(self._press_scene.x(), sp.x()) y = min(self._press_scene.y(), sp.y()) w = max(1.0, abs(sp.x() - self._press_scene.x())) h = max(1.0, abs(sp.y() - self._press_scene.y())) self._preview.setRect(x, y, w, h) return True def _on_release(self, vp_pos) -> bool: if self._state != self._PLACING: return False sp = self._to_scene(vp_pos) # remove preview sc = self._sc() if sc is not None and self._preview is not None: sc.removeItem(self._preview) self._preview = None # bounding box in scene coordinates (same as line plugin) x = min(self._press_scene.x(), sp.x()) y = min(self._press_scene.y(), sp.y()) w = max(1.0, abs(sp.x() - self._press_scene.x())) h = max(1.0, abs(sp.y() - self._press_scene.y())) self._remove_roi() self._build_roi(x, y, w, h) self._state = self._PLACED self._gv.viewport().setCursor(QtCore.Qt.ArrowCursor) self._update_stats() return True # ── graphics ───────────────────────────────────────────────────────── def _remove_roi(self): sc = self._sc() if self.roi is not None: try: self.roi.sigRegionChanged.disconnect(self._on_roi_changed) except Exception: pass if sc: try: sc.removeItem(self.roi) except Exception: pass self.roi = None if self.dimension_text is not None: if sc: try: sc.removeItem(self.dimension_text) except Exception: pass self.dimension_text = None def _remove_all(self): sc = self._sc() if self._preview is not None: if sc: try: sc.removeItem(self._preview) except Exception: pass self._preview = None self._remove_roi() def _build_roi(self, x, y, w, h): """Create pg.RectROI in SCENE coordinates — same as line plugin.""" sc = self._sc() if sc is None: return self._remove_roi() # always clean up any existing ROI first pen = pg.mkPen('y', width=self.roi_pen_width) hover_pen = pg.mkPen((255, 255, 100), width=self.roi_pen_width + 1) # Create at (0,0) then place after adding to scene — avoids any # parent-change coordinate adjustment (same reason line plugin sets # line coords directly in QGraphicsLineItem constructor). self.roi = pg.RectROI( [0, 0], [w, h], pen=pen, hoverPen=hover_pen, movable=True, resizable=True, removable=False, ) self.roi.setZValue(1000) sc.addItem(self.roi) # scene is parent-less → scene coords self.roi.setPos(x, y) # now in scene coords ✓ # 4 corners + 4 edges self.roi.addScaleHandle([1, 1], [0, 0]) self.roi.addScaleHandle([0, 0], [1, 1]) self.roi.addScaleHandle([1, 0], [0, 1]) self.roi.addScaleHandle([0, 1], [1, 0]) self.roi.addScaleHandle([0.5, 0], [0.5, 1]) self.roi.addScaleHandle([0.5, 1], [0.5, 0]) self.roi.addScaleHandle([0, 0.5], [1, 0.5]) self.roi.addScaleHandle([1, 0.5], [0, 0.5]) self._style_handles() if self.show_dimensions: self.dimension_text = pg.TextItem( anchor=(0.5, 1.1), color='y', fill=(0, 0, 0, 180), border='y', ) self.dimension_text.setZValue(2000) sc.addItem(self.dimension_text) self.roi.sigRegionChanged.connect(self._on_roi_changed) self.roi.setVisible(True) self._update_dimension_display() if self.logger: self.logger.info("ROI at (%.1f,%.1f) size (%.1f,%.1f)", x, y, w, h) def _style_handles(self): if self.roi is None: return brush = pg.mkBrush(255, 0, 0, 255) pen = pg.mkPen('w', width=3) size = float(self.handle_size * 2) for ho in self.roi.getHandles(): hi = ho.item if hasattr(ho, 'item') else ho if hi is None: continue try: if hasattr(hi, 'setSize'): hi.setSize(size) elif hasattr(hi, 'setScale'): hi.setScale(size / 10.0) if hasattr(hi, 'setBrush'): hi.setBrush(brush) if hasattr(hi, 'setPen'): hi.setPen(pen) hi.setZValue(self.roi.zValue() + 1) except Exception as e: if self.logger: self.logger.debug("Handle style: %s", e) def _on_roi_changed(self): if self._state == self._PLACED: self._update_stats() self._update_dimension_display() def _update_dimension_display(self): if not self.show_dimensions or self.dimension_text is None or self.roi is None: return try: pos = self.roi.pos() size = self.roi.size() self.dimension_text.setPos(pos[0] + size[0] / 2, pos[1]) self.dimension_text.setText(f"{int(size[0])} x {int(size[1])}") self.dimension_text.setVisible(self.enabled) except Exception as e: if self.logger: self.logger.warning("Dimension display: %s", e) # ── stats ───────────────────────────────────────────────────────────── def _update_stats(self): if not self.enabled or self.roi is None or self._last_image is None: return try: roi_slice, _ = self.roi.getArraySlice( self._last_image, self.image_view.getImageItem()) roi_data = self._last_image[roi_slice[0], roi_slice[1]] if roi_data.size == 0: self.stats_label.setText("ROI outside image bounds") return pos = self.roi.pos() size = self.roi.size() self.stats_label.setText( f"Position:\n X: {pos[0]:.1f}\n Y: {pos[1]:.1f}\n\n" f"Size:\n W: {size[0]:.1f}\n H: {size[1]:.1f}\n" f" Pixels: {roi_data.size}\n\n" f"Stats:\n Min: {float(np.min(roi_data)):.2f}\n" f" Max: {float(np.max(roi_data)):.2f}\n" f" Mean: {float(np.mean(roi_data)):.2f}\n" f" Std: {float(np.std(roi_data)):.2f}\n" f" Sum: {float(np.sum(roi_data)):.2f}" ) except Exception as e: if self.logger: self.logger.warning("ROI stats: %s", e) self.stats_label.setText("Error calculating ROI stats")
class _RoiVpFilter(QtCore.QObject): def __init__(self, mgr: ROIManager): super().__init__() self.mgr = mgr def eventFilter(self, _obj, event): t = event.type() if t == QtCore.QEvent.MouseButtonPress: if event.button() == QtCore.Qt.LeftButton: return self.mgr._on_press(event.pos()) elif t == QtCore.QEvent.MouseMove: return self.mgr._on_move(event.pos()) elif t == QtCore.QEvent.MouseButtonRelease: if event.button() == QtCore.Qt.LeftButton: return self.mgr._on_release(event.pos()) return False