'''
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, fit_window_to_screen
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')
fit_window_to_screen(self)
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
self.roi_line = pg.LineSegmentROI(
[(w // 4, h // 2), (3 * w // 4, h // 2)],
pen=pg.mkPen('g', width=2)
)
self.line_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.roi_line.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 == 'Line roi':
self.set_roi('line', (0, 0, w, h))
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 get_line_length_px(self):
"""Return the length of the line ROI in display (image) pixels.
Handle positions in roi_line.handles[i]['pos'] are in the ROI's local
coordinate system, which shares axes and scale with image pixel space.
The length vector between the two handles is invariant to ROI translation,
so this is correct regardless of where the line is dragged.
"""
h0 = self.roi_line.handles[0]['pos']
h1 = self.roi_line.handles[1]['pos']
return np.hypot(h1.x() - h0.x(), h1.y() - h0.y())
[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;
``'line'`` — show draggable line segment;
``'LS marker'`` — show light-sheet direction arrows;
``None`` — no overlay.
x_y_w_h (tuple): ``(x, y, width, height)`` used only for ``'box'`` mode.
"""
assert mode in ('box', 'line', None, 'LS marker'), \
f"Mode must be in ('box', 'line', None, 'LS marker'), received {mode} instead"
self.overlay = mode
# Box ROI
if mode == 'box':
x, y, w, h = x_y_w_h
self.roi_box.setPos((x, y))
self.roi_box.setSize((w, h))
if mode == 'box' and not self.roi_drawn:
self.image_view.addItem(self.roi_box)
self.roi_drawn = True
elif mode != 'box' and self.roi_drawn:
self.image_view.removeItem(self.roi_box)
self.roi_drawn = False
# Line ROI
if mode == 'line' and not self.line_drawn:
self.image_view.addItem(self.roi_line)
self.line_drawn = True
elif mode != 'line' and self.line_drawn:
self.image_view.removeItem(self.roi_line)
self.line_drawn = False
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}.")
self.hide_light_sheet_marker()
elif self.overlay == 'line':
length_px = self.get_line_length_px()
length_um = self.px2um(length_px, subsampling)
self.status_label.setText(f"Line length: {length_px:.1f} px, {length_um:.1f} \u03BCm. " f"Screen subsampling {subsampling}.")
self.hide_light_sheet_marker()
elif self.overlay is 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]
@log_cpu_core
def set_image(self, image):
if image is None:
return
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)