# /*##########################################################################
#
# Copyright (c) 2016-2022 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ###########################################################################*/
"""This module provides Plot3DAction related to input/output.
It provides QAction to copy, save (snapshot and video), print a Plot3DWidget.
"""
__authors__ = ["T. Vincent"]
__license__ = "MIT"
__date__ = "06/09/2017"
import logging
import os
import numpy
from silx.gui import qt, printer
from silx.gui.icons import getQIcon
from .Plot3DAction import Plot3DAction
from ..utils import mng
from ...utils.image import convertQImageToArray
_logger = logging.getLogger(__name__)
[docs]
class CopyAction(Plot3DAction):
"""QAction to provide copy of a Plot3DWidget
:param parent: See :class:`QAction`
:param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
Plot3DWidget the action is associated with
"""
def __init__(self, parent, plot3d=None):
super(CopyAction, self).__init__(parent, plot3d)
self.setIcon(getQIcon("edit-copy"))
self.setText("Copy")
self.setToolTip("Copy a snapshot of the 3D scene to the clipboard")
self.setCheckable(False)
self.setShortcut(qt.QKeySequence.Copy)
self.setShortcutContext(qt.Qt.WidgetShortcut)
self.triggered[bool].connect(self._triggered)
def _triggered(self, checked=False):
plot3d = self.getPlot3DWidget()
if plot3d is None:
_logger.error("Cannot copy widget, no associated Plot3DWidget")
else:
image = plot3d.grabGL()
qt.QApplication.clipboard().setImage(image)
[docs]
class SaveAction(Plot3DAction):
"""QAction to provide save snapshot of a Plot3DWidget
:param parent: See :class:`QAction`
:param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
Plot3DWidget the action is associated with
"""
def __init__(self, parent, plot3d=None):
super(SaveAction, self).__init__(parent, plot3d)
self.setIcon(getQIcon("document-save"))
self.setText("Save...")
self.setToolTip("Save a snapshot of the 3D scene")
self.setCheckable(False)
self.setShortcut(qt.QKeySequence.Save)
self.setShortcutContext(qt.Qt.WidgetShortcut)
self.triggered[bool].connect(self._triggered)
def _triggered(self, checked=False):
plot3d = self.getPlot3DWidget()
if plot3d is None:
_logger.error("Cannot save widget, no associated Plot3DWidget")
else:
dialog = qt.QFileDialog(self.parent())
dialog.setWindowTitle("Save snapshot as")
dialog.setModal(True)
dialog.setNameFilters(
("Plot3D Snapshot PNG (*.png)", "Plot3D Snapshot JPEG (*.jpg)")
)
dialog.setFileMode(qt.QFileDialog.AnyFile)
dialog.setAcceptMode(qt.QFileDialog.AcceptSave)
if not dialog.exec():
return
nameFilter = dialog.selectedNameFilter()
filename = dialog.selectedFiles()[0]
dialog.close()
# Forces the filename extension to match the chosen filter
extension = nameFilter.split()[-1][2:-1]
if (
len(filename) <= len(extension)
or filename[-len(extension) :].lower() != extension.lower()
):
filename += extension
image = plot3d.grabGL()
if not image.save(filename):
_logger.error("Failed to save image as %s", filename)
qt.QMessageBox.critical(
self.parent(), "Save snapshot as", "Failed to save snapshot"
)
[docs]
class PrintAction(Plot3DAction):
"""QAction to provide printing of a Plot3DWidget
:param parent: See :class:`QAction`
:param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
Plot3DWidget the action is associated with
"""
def __init__(self, parent, plot3d=None):
super(PrintAction, self).__init__(parent, plot3d)
self.setIcon(getQIcon("document-print"))
self.setText("Print...")
self.setToolTip("Print a snapshot of the 3D scene")
self.setCheckable(False)
self.setShortcut(qt.QKeySequence.Print)
self.setShortcutContext(qt.Qt.WidgetShortcut)
self.triggered[bool].connect(self._triggered)
[docs]
def getPrinter(self):
"""Return the QPrinter instance used for printing.
:rtype: QPrinter
"""
return printer.getDefaultPrinter()
def _triggered(self, checked=False):
plot3d = self.getPlot3DWidget()
if plot3d is None:
_logger.error("Cannot print widget, no associated Plot3DWidget")
else:
printer = self.getPrinter()
dialog = qt.QPrintDialog(printer, plot3d)
dialog.setWindowTitle("Print Plot3D snapshot")
if not dialog.exec():
return
image = plot3d.grabGL()
# Draw pixmap with painter
painter = qt.QPainter()
if not painter.begin(printer):
return
pageRect = printer.pageRect(qt.QPrinter.DevicePixel)
if pageRect.width() < image.width() or pageRect.height() < image.height():
# Downscale to page
xScale = pageRect.width() / image.width()
yScale = pageRect.height() / image.height()
scale = min(xScale, yScale)
else:
scale = 1.0
rect = qt.QRectF(0, 0, scale * image.width(), scale * image.height())
painter.drawImage(rect, image)
painter.end()
[docs]
class VideoAction(Plot3DAction):
"""This action triggers the recording of a video of the scene.
The scene is rotated 360 degrees around a vertical axis.
:param parent: Action parent see :class:`QAction`.
:param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
Plot3DWidget the action is associated with
"""
PNG_SERIE_FILTER = "Serie of PNG files (*.png)"
MNG_FILTER = "Multiple-image Network Graphics file (*.mng)"
def __init__(self, parent, plot3d=None):
super(VideoAction, self).__init__(parent, plot3d)
self.setText("Record video..")
self.setIcon(getQIcon("camera"))
self.setToolTip("Record a video of a 360 degrees rotation of the 3D scene.")
self.setCheckable(False)
self.triggered[bool].connect(self._triggered)
def _triggered(self, checked=False):
"""Action triggered callback"""
plot3d = self.getPlot3DWidget()
if plot3d is None:
_logger.warning("Ignoring action triggered without Plot3DWidget set")
return
dialog = qt.QFileDialog(parent=plot3d)
dialog.setWindowTitle("Save video as...")
dialog.setModal(True)
dialog.setNameFilters([self.PNG_SERIE_FILTER, self.MNG_FILTER])
dialog.setFileMode(qt.QFileDialog.AnyFile)
dialog.setAcceptMode(qt.QFileDialog.AcceptSave)
if not dialog.exec():
return
nameFilter = dialog.selectedNameFilter()
filename = dialog.selectedFiles()[0]
# Forces the filename extension to match the chosen filter
extension = nameFilter.split()[-1][2:-1]
if (
len(filename) <= len(extension)
or filename[-len(extension) :].lower() != extension.lower()
):
filename += extension
nbFrames = int(4.0 * 25) # 4 seconds, 25 fps
if nameFilter == self.PNG_SERIE_FILTER:
self._saveAsPNGSerie(filename, nbFrames)
elif nameFilter == self.MNG_FILTER:
self._saveAsMNG(filename, nbFrames)
else:
_logger.error("Unsupported file filter: %s", nameFilter)
def _saveAsPNGSerie(self, filename, nbFrames):
"""Save video as serie of PNG files.
It adds a counter to the provided filename before the extension.
:param str filename: filename to use as template
:param int nbFrames: Number of frames to generate
"""
plot3d = self.getPlot3DWidget()
assert plot3d is not None
# Define filename template
nbDigits = int(numpy.log10(nbFrames)) + 1
indexFormat = "%%0%dd" % nbDigits
extensionIndex = filename.rfind(".")
filenameFormat = (
filename[:extensionIndex] + indexFormat + filename[extensionIndex:]
)
try:
for index, image in enumerate(self._video360(nbFrames)):
image.save(filenameFormat % index)
except GeneratorExit:
pass
def _saveAsMNG(self, filename, nbFrames):
"""Save video as MNG file.
:param str filename: filename to use
:param int nbFrames: Number of frames to generate
"""
plot3d = self.getPlot3DWidget()
assert plot3d is not None
frames = (convertQImageToArray(im) for im in self._video360(nbFrames))
try:
with open(filename, "wb") as file_:
for chunk in mng.convert(frames, nb_images=nbFrames):
file_.write(chunk)
except GeneratorExit:
os.remove(filename) # Saving aborted, delete file
def _video360(self, nbFrames):
"""Run the video and provides the images
:param int nbFrames: The number of frames to generate for
:return: Iterator of QImage of the video sequence
"""
plot3d = self.getPlot3DWidget()
assert plot3d is not None
angleStep = 360.0 / nbFrames
# Create progress bar dialog
dialog = qt.QDialog(plot3d)
dialog.setWindowTitle("Record Video")
layout = qt.QVBoxLayout(dialog)
progress = qt.QProgressBar()
progress.setRange(0, nbFrames)
layout.addWidget(progress)
btnBox = qt.QDialogButtonBox(qt.QDialogButtonBox.Abort)
btnBox.rejected.connect(dialog.reject)
layout.addWidget(btnBox)
dialog.setModal(True)
dialog.show()
qapp = qt.QApplication.instance()
for frame in range(nbFrames):
progress.setValue(frame)
image = plot3d.grabGL()
yield image
plot3d.viewport.orbitCamera("left", angleStep)
qapp.processEvents()
if not dialog.isVisible():
break # It as been rejected by the abort button
else:
dialog.accept()
if dialog.result() == qt.QDialog.Rejected:
raise GeneratorExit("Aborted")