# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""Redux Viewer using QAD modules for display to DS9."""
import os
import configobj
from astropy import log
from sofia_redux.pipeline.viewer import Viewer
from sofia_redux.pipeline.gui.widgets import GeneralRunnable
try:
from PyQt6 import QtWidgets, QtCore
from sofia_redux.pipeline.gui.ui import ui_qad_settings
except ImportError:
HAS_PYQT6 = False
QtCore = None
# duck type parents to allow class definition
class QtWidgets:
class QWidget:
pass
class ui_qad_settings:
class Ui_Form:
pass
else:
HAS_PYQT6 = True
__all__ = ['QADViewerSettings', 'QADViewer']
[docs]
class QADViewerSettings(QtWidgets.QWidget, ui_qad_settings.Ui_Form):
"""
Settings widget for QAD Viewer.
All attributes and methods for this class are intended for internal
use, in response to user actions within a Qt5 application.
"""
def __init__(self, imviewer, parent=None):
"""
Start up the settings widget.
Parameters
----------
imviewer : `sofia_redux.pipeline.gui.qad.qad_imview.QADImView`
Associated QAD Image Viewer.
parent : `QWidget`, optional
Parent widget. May be any Qt Widget.
"""
if not HAS_PYQT6: # pragma: no cover
raise ImportError('PyQt6 package is required for Redux GUI.')
# parent initialization
super().__init__()
self.setupUi(self)
self.imviewer = imviewer
self.headviewer = None
self.cfg_dir = None
self.load_worker = None
self.imexam_worker = None
self.dispInitial = self.imviewer.disp_parameters.copy()
self.photInitial = self.imviewer.phot_parameters.copy()
self.plotInitial = self.imviewer.plot_parameters.copy()
# set initial widget values from viewer parameters
self.setDispValue(self.dispInitial)
self.setPhotValue(self.photInitial)
self.setPlotValue(self.plotInitial)
# set icons
imexam_icon = self.style().standardIcon(
QtWidgets.QStyle.StandardPixmap.SP_FileDialogContentsView)
self.imexamButton.setIcon(imexam_icon)
self.imexamButton.setIconSize(QtCore.QSize(32, 32))
save_icon = self.style().standardIcon(
QtWidgets.QStyle.StandardPixmap.SP_DialogSaveButton)
self.saveButton.setIcon(save_icon)
self.saveButton.setIconSize(QtCore.QSize(32, 32))
header_icon = self.style().standardIcon(
QtWidgets.QStyle.StandardPixmap.SP_FileDialogInfoView)
self.headerButton.setIcon(header_icon)
self.headerButton.setIconSize(QtCore.QSize(32, 32))
# connect signals to slots
self.disableDS9Box.stateChanged.connect(self.getDispValue)
self.disableOverplotsBox.stateChanged.connect(self.getDispValue)
self.extensionBox.currentIndexChanged.connect(self.getDispValue)
self.extensionBox.lineEdit().editingFinished.connect(self.getDispValue)
self.lockImageBox.currentIndexChanged.connect(self.getDispValue)
self.lockSliceBox.currentIndexChanged.connect(self.getDispValue)
self.scaleBox.currentIndexChanged.connect(self.getDispValue)
self.colorMapBox.editingFinished.connect(self.getDispValue)
self.zoomBox.stateChanged.connect(self.getDispValue)
self.tileBox.stateChanged.connect(self.getDispValue)
self.snRangeBox.editingFinished.connect(self.getDispValue)
self.modelTypeBox.currentIndexChanged.connect(self.getPhotValue)
self.windowUnitsBox.currentIndexChanged.connect(self.getPhotValue)
self.fwhmUnitsBox.currentIndexChanged.connect(self.getPhotValue)
self.apradUnitsBox.currentIndexChanged.connect(self.getPhotValue)
self.windowSizeBox.editingFinished.connect(self.getPhotValue)
self.fwhmBox.editingFinished.connect(self.getPhotValue)
self.apradBox.editingFinished.connect(self.getPhotValue)
self.bgrinBox.editingFinished.connect(self.getPhotValue)
self.bgwidBox.editingFinished.connect(self.getPhotValue)
self.radialPlotBox.stateChanged.connect(self.getPhotValue)
self.plotWindowUnitsBox.currentIndexChanged.connect(self.getPlotValue)
self.shareAxesBox.currentIndexChanged.connect(self.getPlotValue)
self.plotWindowSizeBox.editingFinished.connect(self.getPlotValue)
self.plotColorBox.editingFinished.connect(self.getPlotValue)
self.histBinBox.editingFinished.connect(self.getPlotValue)
self.histLimitsBox.editingFinished.connect(self.getPlotValue)
self.summaryStatBox.currentIndexChanged.connect(self.getPlotValue)
self.p2pReferenceBox.editingFinished.connect(self.getPlotValue)
self.separatePlotsBox.stateChanged.connect(self.getPlotValue)
self.resetDispButton.clicked.connect(self.resetDisp)
self.restoreDispButton.clicked.connect(self.restoreDisp)
self.resetPhotButton.clicked.connect(self.resetPhot)
self.restorePhotButton.clicked.connect(self.restorePhot)
self.resetPlotButton.clicked.connect(self.resetPlot)
self.restorePlotButton.clicked.connect(self.restorePlot)
self.imexamButton.clicked.connect(self.onImExam)
self.headerButton.clicked.connect(self.onDisplayHeader)
self.saveButton.clicked.connect(self.onSave)
[docs]
def getDispValue(self):
"""Get current display settings from widgets."""
parameters = self.imviewer.disp_parameters.copy()
parameters['extension'] = str(self.extensionBox.currentText()).lower()
parameters['lock_image'] = str(self.lockImageBox.currentText()).lower()
parameters['lock_slice'] = str(self.lockSliceBox.currentText()).lower()
parameters['scale'] = str(self.scaleBox.currentText()).lower()
parameters['cmap'] = str(self.colorMapBox.text()).lower()
parameters['zoom_fit'] = self.zoomBox.isChecked()
parameters['tile'] = self.tileBox.isChecked()
parameters['overplots'] = not self.disableOverplotsBox.isChecked()
parameters['ds9_viewer_pipeline'] = not self.disableDS9Box.isChecked()
parameters['ds9_viewer'] = parameters['ds9_viewer_pipeline']
try:
lim = str(self.snRangeBox.text()).split(',')
parameters['s2n_range'] = [float(lim[0]), float(lim[1])]
except (ValueError, TypeError, AttributeError, IndexError):
parameters['s2n_range'] = None
# attempt redisplay if any parameters have changed
if parameters != self.imviewer.disp_parameters:
self.imviewer.disp_parameters = parameters
self.onLoad(None)
[docs]
def getPhotValue(self):
"""Get current photometry settings from widgets."""
parameters = self.imviewer.phot_parameters
parameters['model'] = \
str(self.modelTypeBox.currentText()).lower()
parameters['fwhm_units'] = \
str(self.fwhmUnitsBox.currentText()).lower()
parameters['window_units'] = \
str(self.windowUnitsBox.currentText()).lower()
parameters['aperture_units'] = \
str(self.apradUnitsBox.currentText()).lower()
try:
parameters['window'] = float(self.windowSizeBox.text())
except ValueError:
pass
try:
parameters['fwhm'] = float(self.fwhmBox.text())
except ValueError:
pass
try:
parameters['psf_radius'] = float(self.apradBox.text())
except ValueError:
parameters['psf_radius'] = 'auto'
bgi = self.bgrinBox.text()
if str(bgi).lower() == 'none':
parameters['bg_inner'] = None
else:
try:
parameters['bg_inner'] = float(bgi)
except ValueError:
parameters['bg_inner'] = 'auto'
bgw = self.bgwidBox.text()
if str(bgw).lower() == 'none':
parameters['bg_width'] = None
else:
try:
parameters['bg_width'] = float(bgw)
except ValueError:
parameters['bg_width'] = 'auto'
parameters['show_plots'] = self.radialPlotBox.isChecked()
self.imviewer.phot_parameters = parameters
[docs]
def getPlotValue(self):
"""Get current plot settings from widgets."""
parameters = self.imviewer.plot_parameters
parameters['window_units'] = \
str(self.plotWindowUnitsBox.currentText()).lower()
try:
parameters['window'] = float(self.plotWindowSizeBox.text())
except ValueError:
parameters['window'] = None
parameters['color'] = str(self.plotColorBox.text()).lower()
parameters['share_axes'] = str(self.shareAxesBox.currentText()).lower()
parameters['separate_plots'] = self.separatePlotsBox.isChecked()
try:
parameters['bin'] = int(self.histBinBox.text())
except ValueError:
bin_val = str(self.histBinBox.text()).lower()
if bin_val in ['auto', 'fd', 'doane', 'scott', 'stone',
'rice', 'sturges', 'sqrt']:
parameters['bin'] = bin_val
else:
parameters['bin'] = 'fd'
try:
lim = str(self.histLimitsBox.text()).split(',')
parameters['hist_limits'] = [float(lim[0]), float(lim[1])]
except (ValueError, TypeError, AttributeError, IndexError):
parameters['hist_limits'] = None
parameters['summary_stat'] = str(
self.summaryStatBox.currentText()).lower()
try:
parameters['p2p_reference'] = int(self.p2pReferenceBox.text())
except ValueError:
parameters['p2p_reference'] = 1
self.imviewer.plot_parameters = parameters
[docs]
def onImExam(self):
"""Start imexam in a new thread."""
self.imexamButton.setEnabled(False)
self.setStatus("Starting imexam in DS9; press 'q' to quit.")
self.imviewer.break_loop = False
threadpool = QtCore.QThreadPool.globalInstance()
self.imexam_worker = GeneralRunnable(self.imviewer.imexam)
self.imexam_worker.signals.finished.connect(self.imexamFinish)
threadpool.start(self.imexam_worker)
[docs]
def imexamFinish(self, status):
"""
ImExam callback.
Parameters
----------
status : None or tuple
If not None, contains an error message to log.
"""
if status is not None:
# log the error
log.error("\n{}".format(status[2]))
self.imexam_worker = None
self.imviewer.break_loop = True
self.setStatus('')
self.imexamButton.setEnabled(True)
[docs]
def onLoad(self, data, regfiles=None):
"""Start DS9 load in a new thread."""
if self.load_worker is not None:
self.setStatus("Skipping display; DS9 is busy")
return
else:
self.setStatus("Loading QAD data.")
threadpool = QtCore.QThreadPool.globalInstance()
if data is None:
self.load_worker = GeneralRunnable(self.imviewer.reload)
else:
self.load_worker = GeneralRunnable(self.imviewer.load, data,
regfiles=regfiles)
self.load_worker.signals.finished.connect(self.loadFinish)
threadpool.start(self.load_worker)
[docs]
def loadFinish(self, status):
"""
DS9 load callback.
Parameters
----------
status : None or tuple
If not None, contains an error message to log.
"""
if status is not None:
# log the error
log.error("\n{}".format(status[2]))
self.load_worker = None
self.setStatus('')
[docs]
def onSave(self):
"""Save QAD parameters."""
if self.cfg_dir is None or not os.path.isdir(self.cfg_dir):
log.error('No config directory available; not saving parameters')
return
# display parameters
config = configobj.ConfigObj(self.imviewer.disp_parameters,
unrepr=True)
config.filename = os.path.join(self.cfg_dir, 'display.cfg')
config.write()
# photometry parameters
config = configobj.ConfigObj(self.imviewer.phot_parameters,
unrepr=True)
config.filename = os.path.join(self.cfg_dir, 'photometry.cfg')
config.write()
# plot parameters
config = configobj.ConfigObj(self.imviewer.plot_parameters,
unrepr=True)
config.filename = os.path.join(self.cfg_dir, 'plot.cfg')
config.write()
self.setStatus('Settings saved to {:s}'.format(self.cfg_dir))
[docs]
def resetDisp(self):
"""Reset display settings to initial values."""
self.setDispValue(self.dispInitial)
self.getDispValue()
[docs]
def resetPhot(self):
"""Reset photometry settings to initial values."""
self.setPhotValue(self.photInitial)
self.getPhotValue()
[docs]
def resetPlot(self):
"""Reset plot settings to initial values."""
self.setPlotValue(self.plotInitial)
self.getPlotValue()
[docs]
def restoreDisp(self):
"""Restore display settings to default values."""
self.setDispValue(self.imviewer.default_parameters('display'))
self.getDispValue()
[docs]
def restorePhot(self):
"""Restore photometry settings to default values."""
self.setPhotValue(self.imviewer.default_parameters('photometry'))
self.getPhotValue()
[docs]
def restorePlot(self):
"""Restore plot settings to default values."""
self.setPlotValue(self.imviewer.default_parameters('plot'))
self.getPlotValue()
[docs]
def setDispValue(self, fromdict):
"""
Set widget values for display settings.
Parameters
----------
fromdict : dict
Display parameter dictionary (`QADImView.disp_parameters`).
"""
# extension combo box
if 'extension' in fromdict:
ext = str(fromdict['extension']).lower()
idx = self.extensionBox.findText(
ext, QtCore.Qt.MatchFlag.MatchFixedString)
if idx != -1:
self.extensionBox.setCurrentIndex(idx)
else:
# set text in last entry (extnum)
idx = 3
self.extensionBox.setCurrentIndex(idx)
self.extensionBox.setItemText(idx, ext.upper())
# lock type combo boxes
if 'lock_image' in fromdict:
lock = fromdict['lock_image'].lower()
idx = self.lockImageBox.findText(
lock, QtCore.Qt.MatchFlag.MatchFixedString)
if idx != -1:
self.lockImageBox.setCurrentIndex(idx)
if 'lock_slice' in fromdict:
lock = fromdict['lock_slice'].lower()
idx = self.lockSliceBox.findText(
lock, QtCore.Qt.MatchFlag.MatchFixedString)
if idx != -1:
self.lockSliceBox.setCurrentIndex(idx)
# scale combo box
if 'scale' in fromdict:
scale = fromdict['scale'].lower()
idx = self.scaleBox.findText(
scale, QtCore.Qt.MatchFlag.MatchFixedString)
if idx != -1:
self.scaleBox.setCurrentIndex(idx)
# colormap text
try:
self.colorMapBox.setText(str(fromdict['cmap']))
except KeyError:
pass
# zoom to fit check box
try:
self.zoomBox.setChecked(fromdict['zoom_fit'])
except KeyError:
pass
# tile check box
try:
self.tileBox.setChecked(fromdict['tile'])
except KeyError:
pass
# s2n range text
try:
str_lim = [str(f) for f in fromdict['s2n_range']]
self.snRangeBox.setText(','.join(str_lim))
except (KeyError, ValueError, TypeError, AttributeError, IndexError):
self.snRangeBox.setText('')
# overplots check box
try:
self.disableOverplotsBox.setChecked(not fromdict['overplots'])
except KeyError:
pass
# DS9 disable check box
try:
self.disableDS9Box.setChecked(not fromdict['ds9_viewer_pipeline'])
except KeyError:
pass
self.repaint()
[docs]
def setPhotValue(self, fromdict):
"""
Set widget values for photometry settings.
Parameters
----------
fromdict : dict
Photometry parameter dictionary (`QADImView.phot_parameters`).
"""
# model list
if 'model' in fromdict:
mname = fromdict['model'].lower()
idx = self.modelTypeBox.findText(
mname, QtCore.Qt.MatchFlag.MatchFixedString)
if idx != -1:
self.modelTypeBox.setCurrentIndex(idx)
# window text
try:
self.windowSizeBox.setText(str(fromdict['window']))
except KeyError:
pass
# window units list
if 'window_units' in fromdict:
units = fromdict['window_units'].lower()
idx = self.windowUnitsBox.findText(
units, QtCore.Qt.MatchFlag.MatchFixedString)
if idx != -1:
self.windowUnitsBox.setCurrentIndex(idx)
# fwhm text
try:
self.fwhmBox.setText(str(fromdict['fwhm']))
except KeyError:
pass
# fwhm units list
if 'fwhm_units' in fromdict:
units = fromdict['fwhm_units'].lower()
idx = self.fwhmUnitsBox.findText(
units, QtCore.Qt.MatchFlag.MatchFixedString)
if idx != -1:
self.fwhmUnitsBox.setCurrentIndex(idx)
# aperture (PSF) radius text
try:
self.apradBox.setText(str(fromdict['psf_radius']))
except KeyError:
pass
# aperture units list
if 'aperture_units' in fromdict:
units = fromdict['aperture_units'].lower()
idx = self.apradUnitsBox.findText(
units, QtCore.Qt.MatchFlag.MatchFixedString)
if idx != -1:
self.apradUnitsBox.setCurrentIndex(idx)
# bg inner text
try:
self.bgrinBox.setText(str(fromdict['bg_inner']))
except KeyError:
pass
# bg width text
try:
self.bgwidBox.setText(str(fromdict['bg_width']))
except KeyError:
pass
# show plots check box
try:
self.radialPlotBox.setChecked(fromdict['show_plots'])
except KeyError:
pass
self.repaint()
[docs]
def setPlotValue(self, fromdict):
"""
Set widget values for plot settings.
Parameters
----------
fromdict : dict
Plot parameter dictionary (`QADImView.plot_parameters`).
"""
# window text
try:
self.plotWindowSizeBox.setText(str(fromdict['window']))
except KeyError:
pass
# window units list
if 'window_units' in fromdict:
units = fromdict['window_units'].lower()
idx = self.plotWindowUnitsBox.findText(
units, QtCore.Qt.MatchFlag.MatchFixedString)
if idx != -1:
self.plotWindowUnitsBox.setCurrentIndex(idx)
# color cycle text
try:
self.plotColorBox.setText(str(fromdict['color']))
except KeyError:
pass
# share axes list
if 'share_axes' in fromdict:
ax = fromdict['share_axes'].lower()
idx = self.shareAxesBox.findText(
ax, QtCore.Qt.MatchFlag.MatchFixedString)
if idx != -1:
self.shareAxesBox.setCurrentIndex(idx)
# separate plots check box
try:
self.separatePlotsBox.setChecked(fromdict['separate_plots'])
except KeyError:
pass
# histogram binning text
try:
self.histBinBox.setText(str(fromdict['bin']))
except KeyError:
pass
# histogram limits text
try:
str_lim = [str(f) for f in fromdict['hist_limits']]
self.histLimitsBox.setText(','.join(str_lim))
except (KeyError, ValueError, TypeError, AttributeError, IndexError):
self.histLimitsBox.setText('')
# summary stat list
if 'summary_stat' in fromdict:
ax = fromdict['summary_stat'].lower()
idx = self.summaryStatBox.findText(
ax, QtCore.Qt.MatchFlag.MatchFixedString)
if idx != -1:
self.summaryStatBox.setCurrentIndex(idx)
# P2P reference frame text
try:
self.p2pReferenceBox.setText(str(fromdict['p2p_reference']))
except KeyError:
pass
self.repaint()
[docs]
def setStatus(self, msg):
"""
Set a status message.
Parameters
----------
msg : str
Status message to display.
"""
self.status.setText(msg)
self.status.repaint()
[docs]
class QADViewer(Viewer):
"""
Redux Viewer interface to DS9 and the Eye of SOFIA.
Uses ds9samp adapter to control DS9 for image and cube display,
and the Eye viewer for spectra.
Attributes
----------
imviewer : `sofia_redux.pipeline.gui.qad.qad_imview.QADImView`
QAD Image and Spectrum Viewer.
settings : `QADViewerSettings`
Control widgets for the QADViewer.
See Also
--------
sofia_redux.pipeline.gui.qad.qad_app : standalone QAD application
"""
def __init__(self):
"""Initialize the QAD Viewer."""
if not HAS_PYQT6: # pragma: no cover
raise ImportError('PyQt6 package is required for Redux GUI.')
super().__init__()
self.name = "QADViewer"
self.embedded = True
self.imviewer = None
self.settings = None
[docs]
def start(self, parent=None):
"""
Start up the viewer.
Parameters
----------
parent : QtWidgets.QSplitter
Widget to add the viewer settings to. May be any Qt
widget with an `addWidget` method.
"""
self.parent = parent
# import at last minute to avoid ds9samp
# startup behavior until necessary
try:
from .qad.qad_imview import QADImView
self.imviewer = QADImView()
except ImportError:
log.error("QAD not found; Viewer will not display data.")
self.embedded = False
return
# read settings if available
cfg_dir = os.path.join(os.path.expanduser('~'), '.qad')
os.makedirs(cfg_dir, exist_ok=True)
disp_cfg = os.path.join(cfg_dir, 'display.cfg')
if os.path.isfile(disp_cfg):
config = configobj.ConfigObj(disp_cfg, unrepr=True)
self.imviewer.disp_parameters.update(config.dict())
phot_cfg = os.path.join(cfg_dir, 'photometry.cfg')
if os.path.isfile(phot_cfg):
config = configobj.ConfigObj(phot_cfg, unrepr=True)
self.imviewer.phot_parameters.update(config.dict())
plot_cfg = os.path.join(cfg_dir, 'plot.cfg')
if os.path.isfile(plot_cfg):
config = configobj.ConfigObj(plot_cfg, unrepr=True)
self.imviewer.plot_parameters.update(config.dict())
# set ds9 disable preference to pipeline version
# (separate from standalone preference)
self.imviewer.disp_parameters['ds9_viewer'] = \
self.imviewer.disp_parameters['ds9_viewer_pipeline']
# disable EyeViewer -- it is run separately for the
# Redux interface
self.imviewer.HAS_EYE = False
self.settings = QADViewerSettings(self.imviewer, parent=parent)
self.settings.cfg_dir = cfg_dir
# add widget to parent
parent.addWidget(self.settings)
log.debug('QAD Viewer started.')
[docs]
def display(self):
"""
Display data.
Data items should be set in the `display_data` attribute,
by the `update` method.
This method dispatches FITS files, DS9 region files, and
data arrays to the QAD image viewer. Any other files are
ignored.
"""
if self.imviewer is None:
return
# break imexam loop on new load
self.imviewer.break_loop = True
imview_items = []
reg_files = []
for item in self.display_data:
if os.path.isfile(str(item)):
if item.endswith('.reg'):
# ds9 region files: pass to imviewer
reg_files.append(item)
elif item.endswith('.fits'):
# FITS files: pass to imviewer
imview_items.append(item)
else:
# probably data arrays -- pass to imviewer
imview_items.append(item)
# load imviewable items in a separate thread
if len(imview_items) > 0:
self.settings.onLoad(imview_items, regfiles=reg_files)
[docs]
def reset(self):
"""Reset the viewer."""
if self.imviewer is None:
return
self.imviewer.reset()
[docs]
def close(self):
"""Close the viewer."""
if self.imviewer is not None:
log.debug("Quitting DS9.")
self.imviewer.break_loop = True
self.imviewer.quit()