Source code for experimentor.views.camera.camera_viewer_widget
"""
Camera Viewer Widget
====================
Wrapper around PyQtGraph ImageView.
"""
import numpy as np
import pyqtgraph as pg
from PyQt5.QtCore import pyqtSignal, QTimer
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QApplication, QWidget, QHBoxLayout, QAction, QVBoxLayout, QPushButton
from pyqtgraph import GraphicsLayoutWidget
from experimentor.lib.log import get_logger
from experimentor.views.data_view_widget import DataViewWidget
[docs]class CameraViewerWidget(DataViewWidget):
""" The Camera Viewer Widget is a wrapper around PyQtGraph ImageView. It adds some common methods for getting extra
mouse interactions, such as performing an auto-range through right-clicking, it allows to drag and drop horizontal
and vertical lines to define a ROI, and it allows to draw on top of the image. The core idea is to make these options
explicit, in order to systematize them in one place.
Signals
-------
clicked_on_image: Emits [float, float] with the coordinates where the mouse was clicked on the image. Does not
distinguish between left/right clicks. Any further processing must be done downstream.
Attributes
----------
layout: QHBoxLayout, in case extra elements must be added
viewport: GraphicsLayoutWidget
view: ViewBox
img: ImageItem
imv: ImageView
auto_levels: Whether to actualize the levels of the image every time they are refreshed
"""
clicked_on_image = pyqtSignal([float, float])
def __init__(self, parent=None):
super().__init__(parent=parent)
# Settings for the image
self.viewport = GraphicsLayoutWidget()
self.view = self.viewport.addViewBox(lockAspect=False, enableMenu=True)
self.img = pg.ImageItem()
self.view.addItem(self.img)
self.imv = pg.ImageView(view=self.view, imageItem=self.img)
self.scene().sigMouseClicked.connect(self.mouse_clicked)
# Add everything to the widget
layout = self.get_layout()
layout.addWidget(self.imv)
self.show_roi_lines = False # ROI lines are shown or not
self.show_cross_hair = False # Cross hair is shown or not
self.show_cross_cut = False # Cross cut is shown or not
self.cross_hair_setup = False # Cross hair was setup or not
self.cross_cut_setup = False # Cross cut was setup or not
self.rois = []
self.corner_roi = [0, 0] # Initial ROI corner for the camera
self.first_image = True
self.logger = get_logger()
self.last_image = None
self.add_actions_to_menu()
self.setup_mouse_tracking()
[docs] def update_image(self, image, auto_range=False, auto_histogram_range=False):
""" Updates the image being displayed with some sensitive defaults, which can be over written if needed.
"""
auto_levels = self.auto_levels_action.isChecked()
self.logger.debug(f'Updating image with auto_levels: {auto_levels}')
if image is not None:
self.imv.setImage(image, autoLevels=auto_levels, autoRange=auto_range, autoHistogramRange=auto_histogram_range)
if self.first_image:
self.do_auto_range()
self.first_image = False
self.last_image = image
else:
self.logger.debug(f'No new image to update')
[docs] def setup_roi_lines(self, max_size=None):
"""Sets up the ROI lines surrounding the image.
:param list max_size: List containing the maximum size of the image to avoid ROIs bigger than the CCD."""
if self.last_image is None:
return
self.logger.info('Setting up ROI lines')
if not isinstance(max_size, list):
max_size = self.last_image.shape
self.hline1 = pg.InfiniteLine(angle=0, movable=True, hoverPen={'color': "FF0", 'width': 4})
self.hline2 = pg.InfiniteLine(angle=0, movable=True, hoverPen={'color': "FF0", 'width': 4})
self.vline1 = pg.InfiniteLine(angle=90, movable=True, hoverPen={'color': "FF0", 'width': 4})
self.vline2 = pg.InfiniteLine(angle=90, movable=True, hoverPen={'color': "FF0", 'width': 4})
self.hline1.setValue(0)
self.vline1.setValue(0)
self.vline2.setValue(max_size[0])
self.hline2.setValue(max_size[1])
self.hline1.setBounds((0, max_size[1]))
self.hline2.setBounds((0, max_size[1]))
self.vline1.setBounds((0, max_size[0]))
self.vline2.setBounds((0, max_size[0]))
self.view.addItem(self.hline1)
self.view.addItem(self.hline2)
self.view.addItem(self.vline1)
self.view.addItem(self.vline2)
self.corner_roi[0] = 0
self.corner_roi[1] = 0
[docs] def get_roi_values(self):
""" Get's the ROI values in camera-space. It keeps track of the top left corner in order
to update the values before returning.
:return: Position of the corners of the ROI region assuming 0-indexed cameras.
"""
y1 = round(self.hline1.value())
y2 = round(self.hline2.value())
x1 = round(self.vline1.value())
x2 = round(self.vline2.value())
width = np.abs(x1-x2)
height = np.abs(y1-y2)
x = np.min((x1, x2)) + self.corner_roi[0]
y = np.min((y1, y2)) + self.corner_roi[1]
return (x, width), (y, height)
[docs] def set_roi_lines(self, X, Y):
self.corner_roi = [X[0], Y[0]]
self.hline1.setValue(0)
self.vline1.setValue(0)
self.hline2.setValue(Y[1]) # To the last pixel
self.vline2.setValue(X[1]) # To the last pixel
[docs] def setup_mouse_tracking(self):
self.imv.setMouseTracking(True)
self.imv.getImageItem().scene().sigMouseMoved.connect(self.mouseMoved)
self.imv.getImageItem().scene().contextMenu = None
[docs] def keyPressEvent(self,key):
"""Triggered when there is a key press with some modifier.
Shift+C: Removes the cross hair from the screen
These last two events have to be handeled in the mainWindow that implemented this widget."""
modifiers = QApplication.keyboardModifiers()
if modifiers == Qt.ShiftModifier:
if key.key() == 67: # For letter C of 'Clear
if self.show_cross_hair:
for c in self.crosshair:
self.view.removeItem(c)
self.show_cross_hair = False
if self.show_cross_cut:
self.view.removeItem(self.crossCut)
self.show_cross_cut = False
[docs] def mouseMoved(self, arg):
"""Updates the position of the cross hair. The mouse has to be moved while pressing down the Ctrl button."""
# arg = evt.pos()
modifiers = QApplication.keyboardModifiers()
if modifiers == Qt.ControlModifier:
if self.cross_hair_setup:
if not self.show_cross_hair:
for c in self.crosshair:
self.view.addItem(c)
self.show_cross_hair = True
self.crosshair[1].setValue(int(self.img.mapFromScene(arg).x()))
self.crosshair[0].setValue(int(self.img.mapFromScene(arg).y()))
elif modifiers == Qt.AltModifier:
self.logger.debug('Moving mouse while pressing Alt')
if self.cross_cut_setup:
if not self.show_cross_cut:
self.view.addItem(self.crossCut)
self.show_cross_cut = True
self.crossCut.setValue(int(self.img.mapFromScene(arg).y()))
[docs] def do_auto_range(self):
""" Sets the levels of the image based on the maximum and minimum. This is useful when auto-levels are off
(the default behavior), and one needs to quickly adapt the histogram.
"""
h, y = self.img.getHistogram()
self.imv.setLevels(min(h),max(h))
[docs] def draw_target_pointer(self, locations):
"""gets an image and draws a circle around the target locations.
:param DataFrame locations: DataFrame generated by trackpy's locate method. It only requires columns `x` and `y` with coordinates.
"""
if locations is None:
return
locations = locations[['y', 'x']].values
brush = pg.mkBrush(color=(255, 0, 0))
self.marker.setData(locations[:, 0], locations[:, 1], symbol='x', symbolBrush=brush)
[docs] def setup_cross_hair(self, max_size):
"""Sets up a cross hair."""
self.show_cross_hair = self.setup_cross_hair_action.isChecked()
if self.last_image is None:
return
self.logger.info('Setting up Cross Hair lines')
if not isinstance(max_size, list):
max_size = self.last_image.shape
self.cross_hair_setup = True
self.crosshair = []
self.crosshair.append(pg.InfiniteLine(angle=0, movable=False, pen={'color': 124, 'width': 4}))
self.crosshair.append(pg.InfiniteLine(angle=90, movable=False, pen={'color': 124, 'width': 4}))
self.crosshair[0].setBounds((1, max_size[1] - 1))
self.crosshair[1].setBounds((1, max_size[0] - 1))
[docs] def setup_cross_cut(self, max_size):
"""Set ups the horizontal line for the cross cut."""
self.show_cross_cut = self.setup_cross_cut_action.isChecked()
if self.last_image is None:
return
self.logger.info('Setting up horizontal cross cut line')
if not isinstance(max_size, list):
max_size = self.last_image.shape[1]
self.cross_cut_setup = True
self.crossCut = pg.InfiniteLine(angle=0, movable=False, pen={'color': 'g', 'width': 2})
self.crossCut.setBounds((1, max_size))
[docs] def mouse_clicked(self, evnt):
modifiers = evnt.modifiers()
if modifiers == Qt.ControlModifier:
self.clicked_on_image.emit(self.img.mapFromScene(evnt.pos()).x(), self.img.mapFromScene(evnt.pos()).y())
[docs] @classmethod
def connect_to_camera(cls, camera, refresh_time=50, parent=None):
""" Instantiate the viewer using connect_to_camera in order to get some functionality out of the box. It will
create a timer to automatically update the image
"""
instance = cls(parent=parent)
instance.timer = QTimer()
instance.timer.timeout.connect(lambda: instance.update_image(camera.temp_image))
instance.timer.start(refresh_time)
return instance