Source code for mesoSPIM.src.mesoSPIM_CameraWindow

'''
mesoSPIM CameraWindow
'''
import sys
import numpy as np
import logging
from PyQt5 import QtWidgets, QtCore, QtGui
from PyQt5.uic import loadUi
import pyqtgraph as pg
from .utils.optimization import shannon_dct
from .utils.utility_functions import log_cpu_core

logger = logging.getLogger(__name__)

[docs] class mesoSPIM_CameraWindow(QtWidgets.QWidget): sig_update_roi = QtCore.pyqtSignal(tuple) sig_update_status = QtCore.pyqtSignal() '''Live-view camera display window for mesoSPIM. Renders camera frames in real time using PyQtGraph\'s :class:`~pyqtgraph.ImageView` widget. Key features: * **Subsampled display** — independent sub-sampling factors controlled GUI :class:`mesoSPIM_MainWindow` for live and acquisition modes (``camera_display_live_subsampling`` / ``camera_display_acquisition_subsampling`` from state). * **Histogram / level adjustment** — manual (button) or automatic percentile stretch via :meth:`adjust_levels`. * **Overlays** — three overlay modes selectable from a combo-box: * *LS marker* — colour-coded arrow showing active light-sheet direction. * *Box ROI* — draggable rectangle used by :class:`mesoSPIM_Optimizer`. * *None* — clean view. * **ROI reporting** — emits ``sig_update_roi`` whenever the box ROI changes so that the Optimizer can crop its sharpness measurements. * **Status updates** — emits ``sig_update_status`` to notify other components of changes in the camera window status. Receives frames via :meth:`set_image` (called from the Core thread via a queued connection). ''' def __init__(self, parent=None): super().__init__() self.parent = parent # the mesoSPIM_MainWindow() instance self.cfg = parent.cfg self.state = self.parent.state # the mesoSPIM_StateSingleton() instance self._first_image_drawn = False # # Timer to display images at ~30fps max # self._latest_frame = None # self._latest_seq = 0 # self._displayed_seq = -1 # # self._display_timer = QtCore.QTimer(self) # self._display_timer.timeout.connect(self._display_latest) # self._display_timer.start(33) # cap at 30fps pg.setConfigOptions(imageAxisOrder='row-major') if (hasattr(self.cfg, 'ui_options') and self.cfg.ui_options['dark_mode']) or\ (hasattr(self.cfg, 'dark_mode') and self.cfg.dark_mode): pg.setConfigOptions(background=pg.mkColor('#19232D')) # To avoid pitch black bg for the image view else: pg.setConfigOptions(background="w") '''Set up the UI''' if __name__ == '__main__': loadUi('../gui/mesoSPIM_CameraWindow.ui', self) else: loadUi(self.parent.package_directory + '/gui/mesoSPIM_CameraWindow.ui', self) self.setWindowTitle('mesoSPIM-Control: Camera Window') self.status_label.setText("Status: OK") ''' Set histogram Range ''' self.image_view.setLevels(100, 3000) self.imageItem = self.image_view.getImageItem() self.histogram = self.image_view.getHistogramWidget() self.histogram.setMinimumWidth(100) self.histogram.item.vb.setMaximumWidth(100) ''' This is flipped to account for image rotation ''' self.y_image_width = self.cfg.camera_parameters['x_pixels'] self.x_image_width = self.cfg.camera_parameters['y_pixels'] self.ini_subsampling = self.cfg.startup['camera_display_live_subsampling'] ''' Initialize crosshairs ''' self.crosspen = pg.mkPen({'color': "r", 'width': 1}) self.vLine = pg.InfiniteLine(pos=self.x_image_width/2, angle=90, movable=False, pen=self.crosspen) self.hLine = pg.InfiniteLine(pos=self.y_image_width/2, angle=0, movable=False, pen=self.crosspen) self.image_view.addItem(self.vLine) self.image_view.addItem(self.hLine) # Create overlay ROIs self.overlay = 'LS marker' # 'box', None, 'LS marker' w, h = self.x_image_width//self.ini_subsampling, self.y_image_width//self.ini_subsampling self.roi_box = pg.RectROI((0, 0), (w, h), sideScalers=True) self.roi_drawn = False # Create polygons that show light-sheet direction self.points_R = np.array([[0, self.y_image_width//self.ini_subsampling//2 - 25], [0, self.y_image_width//self.ini_subsampling//2 + 25], [100, self.y_image_width//self.ini_subsampling//2]]) self.points_L = np.array([[self.x_image_width//self.ini_subsampling, self.y_image_width//self.ini_subsampling//2 - 25], [self.x_image_width//self.ini_subsampling, self.y_image_width//self.ini_subsampling//2 + 25], [self.x_image_width//self.ini_subsampling - 100, self.y_image_width//self.ini_subsampling//2]]) self.lightsheet_marker_R = pg.PolyLineROI(positions=self.points_R, closed=True, pen='y', movable=False, rotatable=False, removable=False, aspectLocked=True) self.lightsheet_marker_L = pg.PolyLineROI(positions=self.points_L, closed=True, pen='y', movable=False, rotatable=False, removable=False, aspectLocked=True) self.image_view.addItem(self.lightsheet_marker_R) self.image_view.addItem(self.lightsheet_marker_L) self.hide_light_sheet_marker() # Set up internal CameraWindow signals self.adjustLevelsButton.clicked.connect(self.adjust_levels) self.overlayCombo.currentTextChanged.connect(self.change_overlay) self.roi_box.sigRegionChangeFinished.connect(self.update_status) self.sig_update_status.connect(self.update_status)
[docs] def disable_auto_range(self): ''' Hard disable ViewBox auto-range which was causing high CPU loads ''' vb = self.image_view.getView() # ImageView's ViewBox vb.disableAutoRange() vb.enableAutoRange(x=False, y=False) # belt + suspenders
[docs] def adjust_levels(self, pct_low=25, pct_hi=99.99): """Stretch the display histogram to the *pct_low*–*pct_hi* percentile range. Args: pct_low (float): Lower percentile clipping level (default 25). pct_hi (float): Upper percentile clipping level (default 99.99). """ ''''Adjust histogram levels''' img = self.image_view.getImageItem().image self.image_view.setLevels(min=np.percentile(img, pct_low), max=np.percentile(img, pct_hi))
[docs] def px2um(self, px, scale=1): """Convert a pixel distance to micrometres using the current zoom pixel size. Args: px (float): Pixel count. scale (float): Additional scaling factor (e.g. the display sub-sampling ratio). Returns: float: Distance in micrometres. """ return scale * px * self.cfg.pixelsize[self.state['zoom']]
[docs] @QtCore.pyqtSlot(str) def change_overlay(self, overlay_name): w, h = self.get_image_shape() if overlay_name == 'Box roi': self.set_roi('box', (w//2 - 50, h//2 - 50, 100, 100)) elif overlay_name == 'Overlay: none': self.set_roi(None, (0, 0, w, h)) elif overlay_name == 'LS marker': self.set_roi('LS marker', (0, 0, w, h))
[docs] def get_roi(self): """Return the image array within the currently active overlay ROI. If the overlay is a draggable box the ROI is cropped to that region and ``sig_update_roi`` is emitted with ``(x, y, w, h)``. Otherwise the full image is returned and the ROI covers the whole frame. Returns: numpy.ndarray: 2-D uint16 pixel array of the ROI. """ im_item = self.image_view.getImageItem() if self.overlay == 'box' and self.roi_drawn: roi = self.roi_box.getArrayRegion(im_item.image, im_item) x, y = self.roi_box.pos() w, h = self.roi_box.size() self.sig_update_roi.emit((x, y, w, h)) else: roi = im_item.image w, h = im_item.image.shape self.sig_update_roi.emit((0, 0, w, h)) return roi
[docs] def set_roi(self, mode='box', x_y_w_h=(0, 0, 100, 100)): """Set the overlay mode and reposition the ROI rectangle. Args: mode (str or None): ``'box'`` — show draggable rectangle; ``'LS marker'`` — show light-sheet direction arrows; ``None`` — no overlay. x_y_w_h (tuple): ``(x, y, width, height)`` in *screen* (sub-sampled) pixel coordinates. """ assert mode in ('box', None, 'LS marker'), f"Mode must be in ('box', None, 'LS marker'), received {mode} instead" self.overlay = mode x, y, w, h = x_y_w_h self.roi_box.setPos((x, y)) self.roi_box.setSize((w, h)) if self.overlay in (None, 'LS marker') and self.roi_drawn: self.image_view.removeItem(self.roi_box) self.roi_drawn = False elif self.overlay == 'box' and not self.roi_drawn: self.image_view.addItem(self.roi_box) self.roi_drawn = True self.sig_update_status.emit()
[docs] def get_image_shape(self): return self.image_view.getImageItem().image.shape
[docs] @QtCore.pyqtSlot() def update_status(self, subsampling=2.0): roi = self.get_roi() if self.overlay == 'box': w, h = self.roi_box.size() self.status_label.setText(f"Screen ROI size: W {int(w)} px, {int(self.px2um(w, subsampling)):,} \u03BCm. " f"H {int(h)} px, {int(self.px2um(h, subsampling)):,} \u03BCm. " f"Screen subsampling {subsampling}.") #f"sharpness {np.round(1e4 * shannon_dct(roi)):.0f}") self.hide_light_sheet_marker() elif self.overlay == None: self.hide_light_sheet_marker() self.status_label.setText(f"Image dimensions: {roi.shape}") elif self.overlay == 'LS marker': self.draw_lightsheet_marker() else: self.status_label.setText(f"Image dimensions: {roi.shape}")
[docs] def draw_crosshairs(self): """Add (or re-add) the red crosshair lines to the image view.""" self.image_view.addItem(self.vLine) self.image_view.addItem(self.hLine)
[docs] def draw_lightsheet_marker(self): """Show the yellow arrow overlay indicating the active light-sheet side. The left arrow is shown when ``state['shutterconfig']`` is ``'Left'``, the right arrow when it is ``'Right'``, and both when it is ``'Both'``. """ if self.state['shutterconfig'] == 'Left': self.lightsheet_marker_R.setOpacity(0) self.lightsheet_marker_L.setOpacity(1) elif self.state['shutterconfig'] == 'Right': self.lightsheet_marker_R.setOpacity(1) self.lightsheet_marker_L.setOpacity(0) elif self.state['shutterconfig'] == 'Both': self.lightsheet_marker_R.setOpacity(1) self.lightsheet_marker_L.setOpacity(1)
[docs] def hide_light_sheet_marker(self): self.lightsheet_marker_R.setOpacity(0) self.lightsheet_marker_L.setOpacity(0)
#@QtCore.pyqtSlot(np.ndarray) # deprecated due to slow performance
[docs] def set_image(self, image): if image is None: return log_cpu_core(logger, msg='set_image()') logger.debug(f"setImage() with shape {image.shape} started") if self.state['state'] in ('live', 'idle'): subsampling_ratio = self.state['camera_display_live_subsampling'] elif self.state['state'] in ('run_acquisition_list', 'run_selected_acquisition'): subsampling_ratio = self.state['camera_display_acquisition_subsampling'] else: subsampling_ratio = self.ini_subsampling self.image_view.setImage(image[::subsampling_ratio, ::subsampling_ratio], autoLevels=False, autoHistogramRange=False, autoRange=False) logger.debug(f"setImage() finished") # update roi size if subsampling has changed interactively: # if self.overlay == 'box': # x, y = self.roi_box.pos() # w, h = self.roi_box.size() # self.roi_box.setPos((x / subsampling_ratio, y / subsampling_ratio)) # self.roi_box.setSize((w / subsampling_ratio, h / subsampling_ratio)) self.update_status(subsampling_ratio) h, w = image.shape[-2]//subsampling_ratio, image.shape[-1]//subsampling_ratio # works for both 2D and 3/4D loaded TIFF files. if h != self.y_image_width or w != self.x_image_width: self.x_image_width, self.y_image_width = w, h self.vLine.setPos(self.x_image_width/2.) self.hLine.setPos(self.y_image_width/2.) new_points_R = [p/(subsampling_ratio/self.ini_subsampling) for p in self.points_R] new_points_L = [p/(subsampling_ratio/self.ini_subsampling) for p in self.points_L] self.lightsheet_marker_R.setPoints(new_points_R) self.lightsheet_marker_L.setPoints(new_points_L) # hide the light sheet marker draggable handles: for h in self.lightsheet_marker_R.getHandles(): h.setVisible(False) for h in self.lightsheet_marker_L.getHandles(): h.setVisible(False) # self.draw_crosshairs() # Disable auto range after the first image is displayed # avoids use after each subsequent image which leads to high CPU loads and microscope instability. if not self._first_image_drawn: self.disable_auto_range() self._first_image_drawn = True
[docs] @QtCore.pyqtSlot() def update_image_from_deque(self): """Poll the head of ``frame_queue_display`` and call :meth:`set_image` if a frame is available. If the deque is empty the call is a no-op. """ if len(self.parent.core.frame_queue_display) > 0: image = self.parent.core.frame_queue_display[0] self.set_image(image) else: return
# @QtCore.pyqtSlot() # def update_image_from_deque(self): # if self.parent.core.frame_queue_display: # self._latest_frame = self.parent.core.frame_queue_display[0] # self._latest_seq += 1 # # def _display_latest(self): # if self._latest_seq == self._displayed_seq: # return # nothing new; minimal GIL time # self._displayed_seq = self._latest_seq # self.set_image(self._latest_frame)