"""
PyQtGraph-based image plotter.
Has 2 parts: :class:`ImageView` which displays the image,
and :class:`ImageViewController` which controls the image display (value ranges, flipping or transposing, etc.)
:class:`ImageView` can also operate alone without a controller.
When both are used, :class:`ImageView` is created and set up first, and then supplied to :meth:`ImageViewController.setupUi` method.
"""
from .param_table import ParamTable, FixedParamTable
from ....core.gui.qt.thread import controller
from ....core.utils import funcargparse
from ....core.dataproc import filters
from PyQt5 import QtWidgets, QtCore
import pyqtgraph
import numpy as np
import contextlib
[docs]class ImageViewController(QtWidgets.QWidget):
"""
Class for controlling an image inside :class:`ImageView`.
Like most widgets, requires calling :meth:`setupUi` to set up before usage.
Args:
parent: parent widget
"""
def __init__(self, parent=None):
super(ImageViewController, self).__init__(parent)
[docs] def setupUi(self, name, view, display_table=None, display_table_root=None):
"""
Setup the image view controller.
Args:
name (str): widget name
view (ImageView): controlled image view
display_table (bool): as :class:`.IndicatorValuesTable` object used to access table values; by default, create one internally
display_table_root (str): if not ``None``, specify root (i.e., path prefix) for values inside the table.
"""
self.name=name
self.setObjectName(self.name)
self.hLayout=QtWidgets.QHBoxLayout(self)
self.hLayout.setContentsMargins(0,0,0,0)
self.hLayout.setObjectName("hLayout")
self.view=view
self.view._attach_controller(self)
self.settings_table=ParamTable(self)
self.settings_table.setObjectName("settings_table")
self.hLayout.addWidget(self.settings_table)
self.img_lim=(0,65536)
self.settings_table.setupUi("img_settings",add_indicator=False,display_table=display_table,display_table_root=display_table_root)
self.settings_table.add_text_label("size",label="Image size:")
self.settings_table.add_check_box("flip_x","Flip X",value=False)
self.settings_table.add_check_box("flip_y","Flip Y",value=False,location=(-1,1))
self.settings_table.add_check_box("transpose","Transpose",value=True)
self.settings_table.add_check_box("normalize","Normalize",value=False)
self.settings_table.add_num_edit("minlim",value=self.img_lim[0],limiter=self.img_lim+("coerce","int"),formatter=("int"),label="Minimal intensity:",add_indicator=True)
self.settings_table.add_num_edit("maxlim",value=self.img_lim[1],limiter=self.img_lim+("coerce","int"),formatter=("int"),label="Maximal intensity:",add_indicator=True)
self.settings_table.add_check_box("show_lines","Show lines",value=True).value_changed_signal().connect(self.setup_gui_state)
self.settings_table.add_num_edit("vlinepos",value=0,limiter=(0,None,"coerce","float"),formatter=("float","auto",1,True),label="X line:")
self.settings_table.add_num_edit("hlinepos",value=0,limiter=(0,None,"coerce","float"),formatter=("float","auto",1,True),label="Y line:")
self.settings_table.add_check_box("show_linecuts","Show line cuts",value=False).value_changed_signal().connect(self.setup_gui_state)
self.settings_table.add_num_edit("linecut_width",value=1,limiter=(1,None,"coerce","int"),formatter="int",label="Line cut width:")
self.settings_table.add_button("center_lines","Center lines").value_changed_signal().connect(view.center_lines)
self.settings_table.value_changed.connect(lambda: self.view.update_image(update_controls=False,do_redraw=True))
self.settings_table.add_spacer(10)
self.settings_table.add_button("update_image","Updating",checkable=True).value_changed_signal().connect(view._set_image_update)
self.settings_table.add_button("single","Single").value_changed_signal().connect(self.view.arm_single)
self.settings_table.add_padding()
[docs] def set_img_lim(self, *args):
"""
Set up image limits
Can specify either only upper limit (lower stays the same), or both limits.
Value of ``None`` implies no limit.
"""
if len(args)==1:
self.img_lim=(self.img_lim[0],args[0])
elif len(args)==2:
self.img_lim=tuple(args)
else:
return
minl,maxl=self.img_lim
self.settings_table.w["minlim"].set_number_limit(minl,maxl,"coerce","int")
self.settings_table.w["maxlim"].set_number_limit(minl,maxl,"coerce","int")
@controller.exsafeSlot()
def setup_gui_state(self):
"""Enable or disable controls based on which actions are enabled"""
show_lines=self.settings_table.v["show_lines"]
for n in ["vlinepos","hlinepos","show_linecuts"]:
self.settings_table.lock(n,not show_lines)
show_linecuts=self.settings_table.v["show_linecuts"]
self.settings_table.lock("linecut_width",not (show_lines and show_linecuts))
[docs] def get_all_values(self):
"""Get all control values"""
return self.settings_table.get_all_values()
[docs] def set_all_values(self, params):
"""Set all control values"""
self.settings_table.set_all_values(params)
self.setup_gui_state()
[docs] def get_all_indicators(self):
"""Get all GUI indicators as a dictionary"""
return self.settings_table.get_all_indicators()
builtin_cmaps={ "gray":([0,1.],[(0.,0.,0.),(1.,1.,1.)]),
"gray_sat":([0,0.999,1.],[(0.,0.,0.),(1.,1.,1.),(1.,0.,0.)]),
"hot":([0,0.3,0.7,1.],[(0.,0.,0.),(1.,0.,0.),(1.,1.,0.),(1.,1.,1.)]),
"hot_sat":([0,0.3,0.7,0.999,1.],[(0.,0.,0.),(1.,0.,0.),(1.,1.,0.),(1.,1.,1.),(0.,0.,1.)])
}
[docs]class ImageView(QtWidgets.QWidget):
"""
Image view object.
Built on top of :class:`pyqtgraph.ImageView` class.
Args:
parent: parent widget
"""
def __init__(self, parent=None):
super(ImageView, self).__init__(parent)
self.ctl=None
[docs] class Rectangle(object):
def __init__(self, rect, center=None, size=None):
object.__init__(self)
self.rect=rect
self.center=center or (0,0)
self.size=size or (0,0)
[docs] def update_params(self, center=None, size=None):
if center:
self.center=center
if size:
self.size=size
[docs] def setupUi(self, name, img_size=(1024,1024), min_size=(512,512)):
"""
Setup the image view.
Args:
name (str): widget name
img_size (tuple): default image size (used only until actual image is supplied)
min_size (tuple): minimal widget size
"""
self.name=name
self.setObjectName(self.name)
self.single_armed=False
self.single_acquired=False
self.hLayout=QtWidgets.QVBoxLayout(self)
self.hLayout.setContentsMargins(0,0,0,0)
self.hLayout.setObjectName("hLayout")
self.img=np.zeros(img_size)
self.do_image_update=False
self.xbin=1
self.ybin=1
self.dec_mode="mean"
if min_size:
self.setMinimumSize(QtCore.QSize(*min_size))
self.imageWindow=pyqtgraph.ImageView(self)
self.imageWindow.setObjectName("imageWindow")
self.hLayout.addWidget(self.imageWindow)
self.hLayout.setStretch(0,4)
self.set_colormap("hot_sat")
self.imageWindow.ui.roiBtn.hide()
self.imageWindow.ui.menuBtn.hide()
self.imgVLine=pyqtgraph.InfiniteLine(angle=90,movable=True,bounds=[0,None])
self.imgHLine=pyqtgraph.InfiniteLine(angle=0,movable=True,bounds=[0,None])
self.imageWindow.getView().addItem(self.imgVLine)
self.imageWindow.getView().addItem(self.imgHLine)
self.linecut_boundary_pen=pyqtgraph.mkPen("#008000",style=pyqtgraph.QtCore.Qt.DashLine)
self.imgHBLines=[pyqtgraph.InfiniteLine(angle=0,movable=False,bounds=[0,None],pen=self.linecut_boundary_pen) for _ in range(2)]
self.imgVBLines=[pyqtgraph.InfiniteLine(angle=90,movable=False,bounds=[0,None],pen=self.linecut_boundary_pen) for _ in range(2)]
self.imageWindow.getView().addItem(self.imgHBLines[0])
self.imageWindow.getView().addItem(self.imgHBLines[1])
self.imageWindow.getView().addItem(self.imgVBLines[0])
self.imageWindow.getView().addItem(self.imgVBLines[1])
self.plotWindow=pyqtgraph.PlotWidget(self)
self.plotWindow.addLegend()
self.plotWindow.setLabel("left","Image cut")
self.plotWindow.showGrid(True,True,0.7)
self.cut_lines=[pyqtgraph.PlotCurveItem(pen="#B0B000",name="Horizontal"), pyqtgraph.PlotCurveItem(pen="#B000B0",name="Vertical")]
for c in self.cut_lines:
self.plotWindow.addItem(c)
self.hLayout.addWidget(self.plotWindow)
self.hLayout.setStretch(1,1)
self.plotWindow.setVisible(False)
self._signals_connected=False
self._connect_signals()
self.imgVLine.sigPositionChanged.connect(self.update_image_controls)
self.imgHLine.sigPositionChanged.connect(self.update_image_controls)
self.imageWindow.getHistogramWidget().sigLevelsChanged.connect(self.update_image_controls)
self.rectangles={}
def _attach_controller(self, ctl):
"""
Attach :class:`ImageViewController` object.
Called automatically in :meth:`ImageViewController.setupUi`
"""
self.ctl=ctl
def _get_params(self):
if self.ctl is not None:
return self.ctl.settings_table
return FixedParamTable(v={"transpose":False,
"flip_x":False,
"flip_y":False,
"normalize":True,
"show_lines":False,
"show_linecuts":False,
"vlinepos":0,
"hlinepos":0,
"linecut_width":0,
"update_image":True})
@controller.exsafeSlot("bool")
def _set_image_update(self, do_update):
self.do_image_update=do_update
[docs] def set_colormap(self, cmap):
"""
Setup colormap.
Can be name of one built-in colormaps (``"gray"``, ``"gray_sat"``, ``"hot"``, ``"hot_sat"``),
a list specifying PyQtGraph colormap or a :class:`pyqtgraph.ColorMap` instance.
"""
cmap=builtin_cmaps.get(cmap,cmap)
if isinstance(cmap,(list,tuple)):
cmap=pyqtgraph.ColorMap(*cmap)
self.imageWindow.setColorMap(cmap)
def _connect_signals(self):
if not self._signals_connected:
self.imgVLine.sigPositionChanged.connect(self.update_image_controls)
self.imgHLine.sigPositionChanged.connect(self.update_image_controls)
self.imageWindow.getHistogramWidget().sigLevelsChanged.connect(self.update_image_controls)
self._signals_connected=True
def _disconnect_signals(self):
if self._signals_connected:
self.imgVLine.sigPositionChanged.disconnect(self.update_image_controls)
self.imgHLine.sigPositionChanged.disconnect(self.update_image_controls)
self.imageWindow.getHistogramWidget().sigLevelsChanged.disconnect(self.update_image_controls)
self._signals_connected=False
@contextlib.contextmanager
def _no_events(self):
self._disconnect_signals()
try:
yield
finally:
self._connect_signals()
[docs] def set_binning(self, xbin=1, ybin=1, mode="mean", update_image=True):
"""
Set image binning (useful for showing large images).
"""
bin_changes=(xbin!=self.xbin) or (ybin!=self.ybin) or (mode!=self.dec_mode)
self.xbin=xbin
self.ybin=ybin
self.dec_mode=mode
if bin_changes and update_image:
self.update_image(update_controls=True,do_redraw=True)
[docs] def set_image(self, img):
"""
Set the current image.
The image display won't be updated until :meth:`update_image` is called.
This function is thread-safe (i.e., the application state remains consistent if it's called from another thread,
although race conditions on simultaneous calls from multiple threads still might happen).
"""
if self.do_image_update or self.single_armed:
self.img=img
self.single_armed=False
self.single_acquired=True
@controller.exsafe
def center_lines(self):
"""Center coordinate lines"""
imshape=self.img.shape[::-1] if self._get_params().v["transpose"] else self.img.shape
self.imgVLine.setPos(imshape[0]/2)
self.imgHLine.setPos(imshape[1]/2)
[docs] def arm_single(self):
"""Arm the single-image trigger"""
self.single_armed=True
[docs] def set_rectangle(self, name, center=None, size=None):
"""
Add or change parameters of a rectangle with a given name.
Rectangle coordinates are specified in the original image coordinate system
(i.e., rectangles are automatically flipped/transposed/scaled with the image).
"""
if name not in self.rectangles:
pqrect=pyqtgraph.ROI((0,0),(0,0),movable=False,pen="#FF00FF")
self.imageWindow.getView().addItem(pqrect)
self.rectangles[name]=self.Rectangle(pqrect)
rect=self.rectangles[name]
rect.update_params(center,size)
rcenter=rect.center[0]-rect.size[0]/2.,rect.center[1]-rect.size[1]/2.
rsize=rect.size
imshape=self.img.shape
params=self._get_params()
rcenter=rcenter[0]/self.xbin,rcenter[1]/self.ybin
rsize=rsize[0]/self.xbin,rsize[1]/self.ybin
if params.v["transpose"]:
rcenter=rcenter[::-1]
rsize=rsize[::-1]
imshape=imshape[::-1]
if params.v["flip_x"]:
rcenter=(imshape[0]-rcenter[0]-rsize[0]),rcenter[1]
if params.v["flip_y"]:
rcenter=rcenter[0],(imshape[1]-rcenter[1]-rsize[1])
rect.rect.setPos(rcenter)
rect.rect.setSize(rsize)
[docs] def update_rectangles(self):
"""Update rectangle coordinates"""
for name in self.rectangles:
self.set_rectangle(name)
[docs] def del_rectangle(self, name):
"""Delete a rectangle with a given name"""
if name in self.rectangles:
rect=self.rectangles.pop(name)
self.imageWindow.getView().removeItem(rect)
[docs] def show_rectangles(self, show=True, names=None):
"""
Toggle showing rectangles on or off
If `names` is given, it specifies names of rectangles to show or hide (by default, all rectangles).
"""
imgview=self.imageWindow.getView()
if names is None:
names=self.rectangles
else:
names=funcargparse.as_sequence(names)
for n in names:
rect=self.rectangles[n]
if show and rect.rect not in imgview.addedItems:
imgview.addItem(rect.rect)
if (not show) and rect.rect in imgview.addedItems:
imgview.removeItem(rect.rect)
def _update_linecut_boundaries(self, params):
vpos=self.imgVLine.getPos()[0]
hpos=self.imgHLine.getPos()[1]
cut_width=params.v["linecut_width"]
show_boundary_lines=params.v["show_lines"] and params.v["show_linecuts"] and cut_width>1
for ln in self.imgVBLines+self.imgHBLines:
ln.setPen(self.linecut_boundary_pen if show_boundary_lines else None)
if show_boundary_lines:
self.imgVBLines[0].setPos(vpos-cut_width/2)
self.imgVBLines[1].setPos(vpos+cut_width/2)
self.imgHBLines[0].setPos(hpos-cut_width/2)
self.imgHBLines[1].setPos(hpos+cut_width/2)
# Update image controls based on PyQtGraph image window
@controller.exsafeSlot()
def update_image_controls(self):
"""Update image controls in the connected :class:`ImageViewController` object"""
params=self._get_params()
levels=self.imageWindow.getHistogramWidget().getLevels()
params.v["minlim"],params.v["maxlim"]=levels
params.v["vlinepos"]=self.imgVLine.getPos()[0]
params.v["hlinepos"]=self.imgHLine.getPos()[1]
self._update_linecut_boundaries(params)
def _get_min_nonzero(self, img, default=0):
img=img[img!=0]
return default if np.all(np.isnan(img)) else np.nanmin(img)
def _sanitize_img(self, img): # PyQtGraph histogram has an unfortunate failure mode (crashing) when the whole image has the same value
"""Make first image pixel different from any other pixel"""
img=img.copy().astype(float)
if np.isnan(img[0,0]):
if np.all(np.isnan(img)):
img[0,0]=0
else:
minval=np.nanmin(img)
mindiff=self._get_min_nonzero(img-minval)
img[0,0]=minval+mindiff*0.5 if mindiff>0 else minval+1
else:
mindiff=self._get_min_nonzero(np.abs(img-img[0,0]))
img[0,0]=img[0,0]+mindiff*0.5 if mindiff>0 else img[0,0]+1
return img
# Update image plot
@controller.exsafe
def update_image(self, update_controls=False, do_redraw=False, only_new_image=True):
"""
Update displayed image.
If ``update_controls==True``, update control values (such as image min/max values and line positions).
If ``do_redraw==True``, force update regardless of the ``"update_image"`` button state; otherwise, update only if it is enabled.
If ``only_new_image==True`` and the image hasn't changed since the last call to ``update_image``, skip redraw (however, if ``do_redraw==True``, force redrawing regardless).
"""
with self._no_events():
params=self._get_params()
if not do_redraw:
if not (params.v["update_image"] or self.single_acquired):
return params
if only_new_image and not self.single_acquired:
return params
self.single_acquired=False
draw_img=self.img
if self.xbin>1:
draw_img=filters.decimate(draw_img,self.xbin,dec_mode=self.dec_mode,axis=0)
if self.ybin>1:
draw_img=filters.decimate(draw_img,self.ybin,dec_mode=self.dec_mode,axis=1)
if params.v["transpose"]:
draw_img=draw_img.transpose()
if params.v["flip_x"]:
draw_img=draw_img[::-1,:]
if params.v["flip_y"]:
draw_img=draw_img[:,::-1]
img_shape=draw_img.shape
if np.prod(img_shape)<=1: # ImageView can't plot images with less than 1 px
draw_img=np.zeros((2,2),dtype=np.asarray(draw_img).dtype)+(draw_img[0,0] if np.prod(draw_img.shape) else 0)
autoscale=params.v["normalize"]
draw_img=self._sanitize_img(draw_img)
if self.isVisible():
self.imageWindow.setImage(draw_img,autoLevels=autoscale,autoHistogramRange=autoscale)
if update_controls:
self.update_image_controls()
if not autoscale:
levels=params.v["minlim"],params.v["maxlim"]
self.imageWindow.setLevels(*levels)
self.imageWindow.getHistogramWidget().setLevels(*levels)
self.imageWindow.getHistogramWidget().autoHistogramRange()
params.i["minlim"]=self.imageWindow.levelMin
params.i["maxlim"]=self.imageWindow.levelMax
params.v["size"]="{} x {}".format(*img_shape)
show_lines=params.v["show_lines"]
for ln in [self.imgVLine,self.imgHLine]:
ln.setPen("g" if show_lines else None)
ln.setHoverPen("y" if show_lines else None)
ln.setMovable(show_lines)
for ln in [self.imgVLine]+self.imgVBLines:
ln.setBounds([0,draw_img.shape[0]])
for ln in [self.imgHLine]+self.imgHBLines:
ln.setBounds([0,draw_img.shape[1]])
self.imgVLine.setPos(params.v["vlinepos"])
self.imgHLine.setPos(params.v["hlinepos"])
self._update_linecut_boundaries(params)
if params.v["show_lines"] and params.v["show_linecuts"]:
cut_width=params.v["linecut_width"]
vpos=params.v["vlinepos"]
vmin=int(min(max(0,vpos-cut_width/2),draw_img.shape[0]-1))
vmax=int(vpos+cut_width/2)
if vmax==vmin:
if vmin==0:
vmax+=1
else:
vmin-=1
hpos=params.v["hlinepos"]
hmin=int(min(max(0,hpos-cut_width/2),draw_img.shape[1]-1))
hmax=int(hpos+cut_width/2)
if hmax==hmin:
if hmin==0:
hmax+=1
else:
hmin-=1
x_cut=draw_img[:,hmin:hmax].mean(axis=1)
y_cut=draw_img[vmin:vmax,:].mean(axis=0)
autorange=self.plotWindow.getViewBox().autoRangeEnabled()
self.plotWindow.disableAutoRange()
self.cut_lines[0].setData(np.arange(len(x_cut)),x_cut)
self.cut_lines[1].setData(np.arange(len(y_cut)),y_cut)
if any(autorange):
self.plotWindow.enableAutoRange(x=autorange[0],y=autorange[1])
self.plotWindow.setVisible(True)
else:
self.plotWindow.setVisible(False)
self.update_rectangles()
return params