diff --git a/doc/manual/conf.py b/doc/manual/conf.py index b9a0296..0875ccf 100644 --- a/doc/manual/conf.py +++ b/doc/manual/conf.py @@ -75,7 +75,6 @@ # The name of the Pygments (syntax highlighting) style to use. pygments_style = None - # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for diff --git a/doc/manual/nexxT.rst b/doc/manual/nexxT.rst index 4997936..f59aacd 100644 --- a/doc/manual/nexxT.rst +++ b/doc/manual/nexxT.rst @@ -11,8 +11,6 @@ Subpackages autogenerated/nexxT.services autogenerated/nexxT.filters autogenerated/nexxT.examples - cplusplus - commandline Module contents --------------- diff --git a/doc/manual/tutorial.rst b/doc/manual/tutorial.rst index f80633e..8975327 100644 --- a/doc/manual/tutorial.rst +++ b/doc/manual/tutorial.rst @@ -306,3 +306,55 @@ For being able to announce the C++ filters, the plugin needs to be defined. This .. literalinclude:: ../../nexxT/tests/src/Plugins.cpp :language: c +Debugging ++++++++++ +Debugging can be achieved with any IDE of your choice which supports starting python modules (a.k.a. python -m ). The nexxT-gui start script can be replaced by python -m nexxT.core.AppConsole. See specific examples below. + +Python debugging with Visual Studio Code +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +To start with VS Code make sure the Python extension for VS Code is installed (`see here `_). + +Open VS Code in your source code directory via menu ("File/Open Folder") or cd in your terminal of choice to your folder and start VS Code by typing :code:`code .` (dot for the current directory). + +Setting virtual environment +*************************** +If you're not using venv, continue to the next section. In case you are using a virtual environment, we need to provide VS Code some information. +Open the settings.json file in your .vscode directory (or create it). Your settings should include following information: + +.. code-block:: JSON + + { + "python.pythonPath": "/path/to/your/python/interpreter/python.exe", + "python.venvPath": "/path/to/your/venv", + "python.terminal.activateEnvironment": true + } + +The paths should be absolute. If :code:`"python.pythonPath"` is inside your venv, :code:`"python.venvPath"` is not required, VS Code will recognize it and activate venv automatically. + +**Note**: Make sure that at least one .py file is opened in the editor when starting debugging, otherwise the venv may not be activated (the author does not know exactly under which circumstances this is required, but this information may safe you some time searching the internet when things don't go as expected). + +With these settings at hand, venv will also be started automatically when we create a new terminal in VS Code ("Terminal/New Terminal"). + +Configuring launch file +*********************** +The next step is to create the launch.json file for our debug session (manually or via "Run/Add configuration"). Your launch.json file in .vscode folder should look like this: + +.. code-block:: JSON + + { + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Modul", + "type": "python", + "request": "launch", + "module": "nexxT.core.AppConsole", + "justMyCode": false + } + ] + } + +The "module" setting is the critical part. Under the hood VS code will invoke :code:`python -m `. +With the "justMyCode" setting, we can extend debugging to external code loaded by our application. + +We're all set, now we can run our debug session by pressing F5 or the "Run" menu. diff --git a/nexxT/core/AppConsole.py b/nexxT/core/AppConsole.py index 2b6a1e4..c52a198 100644 --- a/nexxT/core/AppConsole.py +++ b/nexxT/core/AppConsole.py @@ -158,6 +158,10 @@ def main(withGui): NEXXT_CEXT_PATH: Can be set to override the default search path for the nexxT C extension. + +NEXXT_BLACKLISTED_PACKAGES: + List of additional python packages (seperated by a ';') which are not unloaded by nexxT when configuration files + are switched. Use "*" or "__all__" to blacklist all modules. """) parser.add_argument("cfg", nargs='?', help=".json configuration file of the project to be loaded.") parser.add_argument("-a", "--active", default=None, type=str, diff --git a/nexxT/core/ConfigFiles.py b/nexxT/core/ConfigFiles.py index 496efd4..39167de 100644 --- a/nexxT/core/ConfigFiles.py +++ b/nexxT/core/ConfigFiles.py @@ -8,6 +8,7 @@ This modules defines classes for nexxT config file handling. """ +import copy import json import logging from pathlib import Path @@ -216,7 +217,7 @@ def splitCommonGuiStateSections(newGuiState, oldGuiState): return res guistate = {} - cfg = newCfg.copy() + cfg = copy.deepcopy(newCfg) guistate["_guiState"] = splitCommonGuiStateSections(cfg["_guiState"], oldCfg["_guiState"]) cfg["_guiState"] = oldCfg["_guiState"] diff --git a/nexxT/core/Exceptions.py b/nexxT/core/Exceptions.py index 0682dbe..1793354 100644 --- a/nexxT/core/Exceptions.py +++ b/nexxT/core/Exceptions.py @@ -9,6 +9,8 @@ """ def _factoryToString(factory): + # pylint: disable=import-outside-toplevel + # needed to avoid recursive import from nexxT.interface.Ports import InputPortInterface, OutputPortInterface if isinstance(factory, str): return factory @@ -95,6 +97,8 @@ class UnexpectedFilterState(NexTRuntimeError): raised when operations are performed in unexpected filter states """ def __init__(self, state, operation): + # pylint: disable=import-outside-toplevel + # needed to avoid recursive import from nexxT.interface.Filters import FilterState super().__init__("Operation '%s' cannot be performed in state %s" % (operation, FilterState.state2str(state))) @@ -103,6 +107,8 @@ class FilterStateMachineError(UnexpectedFilterState): raised when a state transition is invalid. """ def __init__(self, oldState, newState): + # pylint: disable=import-outside-toplevel + # needed to avoid recursive import from nexxT.interface.Filters import FilterState super().__init__(oldState, "Transition to " + FilterState.state2str(newState)) diff --git a/nexxT/core/FilterMockup.py b/nexxT/core/FilterMockup.py index b29896a..7e2c347 100644 --- a/nexxT/core/FilterMockup.py +++ b/nexxT/core/FilterMockup.py @@ -66,8 +66,6 @@ def createFilterAndUpdate(self, immediate=True): Creates the filter, performs init() operation and updates the port information. :return: None """ - # TODO is it really a good idea to use queued connections for dynamic port changes? - # maybe there is a better way to prevent too many calls to _createFilterAndUpdate if immediate: self._createFilterAndUpdate() self._createFilterAndUpdatePending = None diff --git a/nexxT/core/PluginManager.py b/nexxT/core/PluginManager.py index 2f8cea0..edbc284 100644 --- a/nexxT/core/PluginManager.py +++ b/nexxT/core/PluginManager.py @@ -10,6 +10,7 @@ import sys import inspect +import os import os.path import logging from collections import OrderedDict @@ -61,6 +62,9 @@ class PythonLibrary: LIBTYPE_MODULE = 1 LIBTYPE_ENTRY_POINT = 2 + # blacklisted packages are not unloaded when closing an application. + BLACKLISTED_PACKAGES = ["h5py", "numpy", "matplotlib", "PySide2", "shiboken2"] + def __init__(self, library, libtype): self._library = library self._libtype = libtype @@ -130,6 +134,23 @@ def __len__(self): self._checkAvailableFilters() return len(self._availableFilters) + @staticmethod + def blacklisted(moduleName): + """ + returns whether the module is blacklisted for unload heuristics + + :param moduleName: the name of the module as a key in sys.modules + """ + pkg = PythonLibrary.BLACKLISTED_PACKAGES[:] + if "NEXXT_BLACKLISTED_PACKAGES" in os.environ: + if os.environ["NEXXT_BLACKLISTED_PACKAGES"] in ["*", "__all__"]: + return True + pkg.extend(os.environ["NEXXT_BLACKLISTED_PACKAGES"].split(";")) + for p in pkg: + if moduleName.startswith(p + ".") or moduleName == p: + return True + return False + def unload(self): """ Unloads this python module. During loading, transitive dependent modules are detected and they are unloaded @@ -148,8 +169,11 @@ def unload(self): loader = getattr(mod, '__loader__', None) if not (fn is None or isinstance(loader, ExtensionFileLoader) or os.path.splitext(fn)[1] in EXTENSION_SUFFIXES): - logger.internal("Unloading pure python module '%s' (%s)", m, fn) - del sys.modules[m] + if not self.blacklisted(m): + logger.internal("Unloading pure python module '%s' (%s)", m, fn) + del sys.modules[m] + else: + logger.internal("Module '%s' (%s) is blacklisted and will not be unloaded", m, fn) class PluginManager(QObject): """ diff --git a/nexxT/core/PortImpl.py b/nexxT/core/PortImpl.py index 6671ea3..ff4261f 100644 --- a/nexxT/core/PortImpl.py +++ b/nexxT/core/PortImpl.py @@ -291,6 +291,8 @@ def setInterthreadDynamicQueue(self, enabled): """ if enabled != self._interthreadDynamicQueue: state = self.environment().state() + # pylint: disable=import-outside-toplevel + # needed to avoid recursive import from nexxT.interface.Filters import FilterState # avoid recursive import if state not in [FilterState.CONSTRUCTING, FilterState.CONSTRUCTED, FilterState.INITIALIZING, FilterState.INITIALIZED]: diff --git a/nexxT/core/SubConfiguration.py b/nexxT/core/SubConfiguration.py index d7a9c27..a4ca3cf 100644 --- a/nexxT/core/SubConfiguration.py +++ b/nexxT/core/SubConfiguration.py @@ -218,7 +218,8 @@ def getThreadSet(item): :param mockup: :return: set of strings """ - # avoid recursive import + # pylint: disable=import-outside-toplevel + # needed to avoid recursive import from nexxT.core.CompositeFilter import CompositeFilter from nexxT.core.FilterMockup import FilterMockup if isinstance(item, FilterMockup): diff --git a/nexxT/core/Utils.py b/nexxT/core/Utils.py index 48749f4..b23ff2b 100644 --- a/nexxT/core/Utils.py +++ b/nexxT/core/Utils.py @@ -420,36 +420,37 @@ def paintEvent(self, event): :param event: a QPaintEvent instance :return: """ + self._paintEvent(event) + + @handleException + def _paintEvent(self, event): super().paintEvent(event) painter = QPainter(self) - try: - fontMetrics = painter.fontMetrics() - lineSpacing = self.fontMetrics().lineSpacing() - y = 0 - textLayout = QTextLayout(self._content, painter.font()) - textLayout.setTextOption(self._textOption) - textLayout.beginLayout() - while True: - line = textLayout.createLine() - if not line.isValid(): - break - line.setLineWidth(self.width()) - nextLineY = y + lineSpacing - if self.height() >= nextLineY + lineSpacing: - # not the last line - elidedLine = self._content[line.textStart():line.textStart() + line.textLength()] - elidedLine = fontMetrics.elidedText(elidedLine, self._elideMode, self.width()) - painter.drawText(QPoint(0, y + fontMetrics.ascent()), elidedLine) - y = nextLineY - else: - # last line, check if we are to elide here to the end - lastLine = self._content[line.textStart():] - elidedLastLine = fontMetrics.elidedText(lastLine, self._elideMode, self.width()) - painter.drawText(QPoint(0, y + fontMetrics.ascent()), elidedLastLine) - break - textLayout.endLayout() - except Exception as e: - logger.exception("Exception during paint: %s", e) + fontMetrics = painter.fontMetrics() + lineSpacing = self.fontMetrics().lineSpacing() + y = 0 + textLayout = QTextLayout(self._content, painter.font()) + textLayout.setTextOption(self._textOption) + textLayout.beginLayout() + while True: + line = textLayout.createLine() + if not line.isValid(): + break + line.setLineWidth(self.width()) + nextLineY = y + lineSpacing + if self.height() >= nextLineY + lineSpacing: + # not the last line + elidedLine = self._content[line.textStart():line.textStart() + line.textLength()] + elidedLine = fontMetrics.elidedText(elidedLine, self._elideMode, self.width()) + painter.drawText(QPoint(0, y + fontMetrics.ascent()), elidedLine) + y = nextLineY + else: + # last line, check if we are to elide here to the end + lastLine = self._content[line.textStart():] + elidedLastLine = fontMetrics.elidedText(lastLine, self._elideMode, self.width()) + painter.drawText(QPoint(0, y + fontMetrics.ascent()), elidedLastLine) + break + textLayout.endLayout() if __name__ == "__main__": # pragma: no cover def _smokeTestBarrier(): diff --git a/nexxT/examples/__init__.py b/nexxT/examples/__init__.py index 190f708..6f08e94 100644 --- a/nexxT/examples/__init__.py +++ b/nexxT/examples/__init__.py @@ -10,9 +10,9 @@ from pathlib import Path import os +from nexxT.interface import FilterSurrogate if os.environ.get("READTHEDOCS", None) is None: from PySide2 import QtMultimedia # needed to load corresponding DLL before loading the nexxT plugin -from nexxT.interface import FilterSurrogate AviReader = FilterSurrogate( "binary://" + str((Path(__file__).parent.parent / "tests" / diff --git a/nexxT/filters/GenericReader.py b/nexxT/filters/GenericReader.py new file mode 100644 index 0000000..718b5e1 --- /dev/null +++ b/nexxT/filters/GenericReader.py @@ -0,0 +1,458 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2020 ifm electronic gmbh +# +# THE PROGRAM IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. +# + +""" +This module provides a generic reader which can be inherited to use new data formats inside nexxT. +""" + +import time +import logging +import math +from PySide2.QtCore import Signal, QTimer +from PySide2.QtWidgets import QFileDialog +from nexxT.interface import Filter, Services, DataSample +from nexxT.core.Utils import handleException, isMainThread + +logger = logging.getLogger(__name__) + +class GenericReaderFile: + """ + Interface for adaptations of new file formats. + + For supporting new file formats, inherit from this class and overwrite all of the methods listed here. The + constructor of the inherited class usually takes a filename argument. Inherit also from GenericReader to provide + a new Filter class and overwrite the methods getNameFilter and openFile, which returns an instance of the + GenericReaderFile implementation. + + See :py:class:`nexxT.filters.hdf5.Hdf5File` and :py:class:`nexxT.filters.hdf5.Hdf5Reader` for an example. + """ + + # pylint: disable=no-self-use + # this is an abstract class and the methods are provided for reference + + def close(self): + """ + Closes the file. + + :return: + """ + raise NotImplementedError() + + def getNumberOfSamples(self, stream): + """ + Returns the number of samples in the given stream + + :param stream: the name of the stream as a string + :return: the number of samples in the stream + """ + raise NotImplementedError() + + def getTimestampResolution(self): + """ + Returns the resolution of the timestamps in ticks per second. + + :return: ticks per second as an integer + """ + raise NotImplementedError() + + def allStreams(self): + """ + Returns the streams in this file. + + :return: a list of strings + """ + raise NotImplementedError() + + def readSample(self, stream, streamIdx): + """ + Returns the referenced sample as a tuple (content, dataType, dataTimestamp, rcvTimestamp). + + :param stream: the stream + :param idx: the index of the sample in the stream + :return: (content: QByteArray, dataType: str, dataTimestamp: int, receiveTimestamp: int) + """ + raise NotImplementedError() + + def getRcvTimestamp(self, stream, streamIdx): + """ + Returns the recevie timestamp of the given (stream, streamIdx) sample. The default implementation uses + readSample(...). It may be replaced by a more efficient implementation. + + :param stream: the name of the stream as a string + :param streamIdx: the stream index as an integer + :return: the timestamp as an integer (see also getTimestampResolution) + """ + return self.readSample(stream, streamIdx)[3] + +class GenericReader(Filter): + """ + Generic harddisk reader which can be used as base class for implementing readers for custom file formats. To create + a new input file reader, inherit from this class and reimplement getNameFilter(...) and openFile(...). openFile(...) + shall return an instance of an implementation of the interface GenericReaderFile. + + See :py:class:`nexxT.filters.hdf5.Hdf5Reader` for an example. + """ + + # signals for playback device + playbackStarted = Signal() + playbackPaused = Signal() + sequenceOpened = Signal(str, 'qint64', 'qint64', list) + currentTimestampChanged = Signal('qint64') + timeRatioChanged = Signal(float) + + # methods to be overloaded + + def getNameFilter(self): # pylint: disable=no-self-use + """ + Returns the name filter associated with the input files. + + :return: a list of strings, e.g. ["*.h5", "*.hdf5"] + """ + raise NotImplementedError() + + def openFile(self, filename): # pylint: disable=no-self-use + """ + Opens the given file and return an instance of GenericReaderFile. + + :return: an instance of GenericReaderFile + """ + raise NotImplementedError() + + # slots for playback device + + def startPlayback(self): + """ + slot called when the playback shall be started + + :return: + """ + if not self._playing: + self._playing = True + self._timer.start(0) + self._updateTimer.start() + self.playbackStarted.emit() + + def pausePlayback(self): + """ + slot called when the playback shall be paused + + :return: + """ + if self._playing: + self._playing = False + self._untilStream = None + self._dir = 1 + self._timer.stop() + self._updateTimer.stop() + self._updateCurrentTimestamp() + self.playbackPaused.emit() + + def stepForward(self, stream): + """ + slot called to step one frame in stream forward + + :param stream: a string instance or None (all streams are selected) + :return: + """ + self._untilStream = stream if stream is not None else '' + self.startPlayback() + + def stepBackward(self, stream): + """ + slot called to step one frame in stream backward + + :param stream: a string instance or None (all streams are selected) + :return: + """ + self._dir = -1 + self._untilStream = stream if stream is not None else '' + self.startPlayback() + + def seekBeginning(self): + """ + slot called to go to the beginning of the file + + :return: + """ + self.pausePlayback() + for p in self._portToIdx: + self._portToIdx[p] = -1 + self._transmitNextSample() + self._updateCurrentTimestamp() + + def seekEnd(self): + """ + slot called to go to the end of the file + + :return: + """ + self.pausePlayback() + for p in self._portToIdx: + self._portToIdx[p] = self._file.getNumberOfSamples(p) + self._dir = -1 + self._transmitNextSample() + self._dir = +1 + self._updateCurrentTimestamp() + + def seekTime(self, timestamp): + """ + slot called to go to the specified time + + :param timestamp: a timestamp in nanosecond resolution + :return: + """ + t = timestamp // (1000000000//self._file.getTimestampResolution()) + nValid = 0 + for p in self._portToIdx: + # binary search + minIdx = -1 + num = self._file.getNumberOfSamples(p) + maxIdx = num + # binary search for timestamp + while maxIdx - minIdx > 1: + testIdx = max(0, min(num-1, (minIdx + maxIdx)//2)) + vTest = self._file.getRcvTimestamp(p, testIdx) + if vTest <= t: + minIdx = testIdx + else: + maxIdx = testIdx + self._portToIdx[p] = minIdx + if minIdx >= 0: + # note: minIdx is always below num + nValid += 1 + if nValid > 0: + self._transmitCurrent() + else: + self._transmitNextSample() + self._updateCurrentTimestamp() + + def setSequence(self, filename): + """ + slot called to set the sequence file name + + :param filename: a string instance + :return: + """ + logger.debug("Set sequence filename=%s", filename) + self._name = filename + + def setTimeFactor(self, factor): + """ + slot called to set the time factor + + :param factor: a float + :return: + """ + self._timeFactor = factor + self.timeRatioChanged.emit(self._timeFactor) + + # overwrites from Filter + + def __init__(self, env): + super().__init__(False, True, env) + self._name = None + self._file = None + self._portToIdx = None + self._timer = None + self._updateTimer = QTimer(self) + self._updateTimer.setInterval(1000) # update new position each second + self._updateTimer.timeout.connect(self._updateCurrentTimestamp) + self._currentTimestamp = None + self._playing = None + self._untilStream = None + self._dir = 1 + self._ports = None + self._timeFactor = 1 + + def onOpen(self): + """ + overloaded from Filter + + :return: + """ + srv = Services.getService("PlaybackControl") + srv.setupConnections(self, self.getNameFilter()) + if isMainThread(): + logger.warning("This GenericReader seems to run in GUI thread. Consider to move it to a seperate thread.") + + def onStart(self): + """ + overloaded from Filter + + :return: + """ + if self._name is not None: + self._file = self.openFile(self._name) # pylint: disable=assignment-from-no-return + if not isinstance(self._file, GenericReaderFile): + logger.error("Unexpected instance returned from openFile(...) method of instance %s", (repr(self))) + # sanity checks for the timestamp resolutions + # spit out some errors because when these checks fail, the timestamp logic doesn't work + # note that nexxT tries to avoid applying floating point arithmetics to timestamps due to possible loss + # of accuracy + tsResolution = self._file.getTimestampResolution() + f = DataSample.TIMESTAMP_RES*tsResolution + if (f > 1 and f % 1.0 != 0.0) or (f < 1 and (1/f) % 1.0 != 0.0): + logger.error("timestamp resolution of opened instance %s is no integer multiple of internal resolution", + repr(self._file)) + if (1000000000/tsResolution) % 1.0 != 0.0: + logger.error("timestamp resolution of opened instance %s is no integer multiple of nanoseconds", + repr(self._file)) + self._portToIdx = {} + self._ports = self.getDynamicOutputPorts() + for s in self._file.allStreams(): + if s in [p.name() for p in self._ports]: + if self._file.getNumberOfSamples(s) > 0: + self._portToIdx[s] = -1 + else: + logger.warning("Stream %s does not contain any samples.", s) + else: + logger.warning("No matching output port for stream %s. Consider to create a port for it.", s) + for p in self._ports: + if not p.name() in self._portToIdx: + logger.warning("No matching stream for output port %s. HDF5 file not matching the configuration?", + p.name()) + self._timer = QTimer(parent=self) + self._timer.timeout.connect(self._transmitNextSample) + self._playing = False + self._currentTimestamp = None + span = self._timeSpan() + self.sequenceOpened.emit(self._name, span[0], span[1], sorted(self._portToIdx.keys())) + self.timeRatioChanged.emit(self._timeFactor) + self.playbackPaused.emit() + + def onStop(self): + """ + overloaded from Filter + + :return: + """ + if self._file is not None: + self._file.close() + self._file = None + self._portToIdx = None + self._timer.stop() + self._timer = None + self._playing = None + self._currentTimestamp = None + + def onClose(self): + """ + overloaded from Filter + + :return: + """ + srv = Services.getService("PlaybackControl") + srv.removeConnections(self) + + def onSuggestDynamicPorts(self): + """ + overloaded from Filter + + :return: + """ + try: + fn, ok = QFileDialog.getOpenFileName(caption="Choose template hdf5 file", + filter="Support files (%s)" % (" ".join(self.getNameFilter()))) + if ok: + f = self.openFile(fn) # pylint: disable=assignment-from-no-return + if not isinstance(f, GenericReaderFile): + logger.error("Unexpected instance returned from openFile(...) method of instance %s", (repr(self))) + return [], list(f.allStreams()) + except Exception: # pylint: disable=broad-except + logger.exception("Caught exception during onSuggestDynamicPorts") + return [], [] + + # private slots and methods + + def _timeSpan(self): + tmin = math.inf + tmax = -math.inf + for p in self._portToIdx: + t = self._file.getRcvTimestamp(p, 0) + tmin = min(t, tmin) + t = self._file.getRcvTimestamp(p, self._file.getNumberOfSamples(p)-1) + tmax = max(t, tmax) + return (tmin*(1000000000//self._file.getTimestampResolution()), + tmax*(1000000000//self._file.getTimestampResolution())) + + def _getNextSample(self): + # check which port has the next sample to deliver according to rcv timestamps + nextPort = None + for p in self._portToIdx: + idx = self._portToIdx[p] + idx = idx + self._dir + if 0 <= idx < self._file.getNumberOfSamples(p): + ts = self._file.getRcvTimestamp(p, idx) + # pylint: disable=unsubscriptable-object + # actually, nextPort can be either None or a 2-tuple + if nextPort is None or (ts < nextPort[0] and self._dir > 0) or (ts > nextPort[0] and self._dir < 0): + nextPort = (ts, p) + return nextPort + + @handleException + def _transmitNextSample(self): + startTime = time.perf_counter_ns() + nextPort = self._getNextSample() + # when next data sample arrives sooner than this threshold, do not use the QTimer but perform busy waiting + noSleepThreshold_ns = 0.005*1e9 # 5 ms + # maximum time in busy-wait strategy (measured from the beginning of the function) + maxTimeInMethod = 0.05*1e9 # yield all 50 ms + factorTStoNS = 1e9/self._file.getTimestampResolution() + while nextPort is not None: + ts, pname = nextPort + self._portToIdx[pname] += self._dir + lastTransmit = self._transmit(pname) + if not self._playing: + return pname + nextPort = self._getNextSample() + if nextPort is not None: + newTs, _ = nextPort + nowTime = time.perf_counter_ns() + deltaT_ns = max(0, (newTs - ts) * factorTStoNS / self._timeFactor - (nowTime - lastTransmit)) + if deltaT_ns < noSleepThreshold_ns and nowTime - startTime + deltaT_ns < maxTimeInMethod: + while time.perf_counter_ns() - nowTime < deltaT_ns: + pass + else: + self._timer.start(deltaT_ns//1000000) + break + else: + self.pausePlayback() + + def _transmit(self, pname): + idx = self._portToIdx[pname] + # read data sample from HDF5 file + content, dataType, dataTimestamp, rcvTimestamp = self._file.readSample(pname, idx) + # create sample to transmit + f = 1/(DataSample.TIMESTAMP_RES * self._file.getTimestampResolution()) + if f >= 1: + f = round(f) + tsData = dataTimestamp * f + else: + f = round(1/f) + tsData = dataTimestamp // f + sample = DataSample(content, dataType, tsData) + res = time.perf_counter_ns() + # transmit sample over corresponding port + self._ports[[p.name() for p in self._ports].index(pname)].transmit(sample) + self._currentTimestampChanged(rcvTimestamp*(1000000000//self._file.getTimestampResolution())) + if self._untilStream is not None: + if self._untilStream == pname or self._untilStream == '': + self.pausePlayback() + return res + + def _transmitCurrent(self): + ports = list(self._portToIdx.keys()) + values = [self._file.getRcvTimestamp(p, self._portToIdx[p]) for p in ports] + sortedIdx = sorted(range(len(values)), key=lambda x: values[x]) + # transmit most recent sample + self._transmit(ports[sortedIdx[-1]]) + + def _currentTimestampChanged(self, timestamp): + self._currentTimestamp = timestamp + + def _updateCurrentTimestamp(self): + if self._currentTimestamp is not None: + self.currentTimestampChanged.emit(self._currentTimestamp) diff --git a/nexxT/filters/hdf5.py b/nexxT/filters/hdf5.py index 356cea7..b2aa51c 100644 --- a/nexxT/filters/hdf5.py +++ b/nexxT/filters/hdf5.py @@ -16,10 +16,10 @@ import os import numpy as np import h5py -from PySide2.QtCore import Signal, QDateTime, QTimer -from PySide2.QtWidgets import QFileDialog -from nexxT.interface import Filter, Services, DataSample +from PySide2.QtCore import Signal +from nexxT.interface import Filter, Services from nexxT.core.Utils import handleException, isMainThread +from nexxT.filters.GenericReader import GenericReader, GenericReaderFile logger = logging.getLogger(__name__) @@ -210,325 +210,88 @@ def onPortDataChanged(self, port): os.POSIX_FADV_DONTNEED) self.statusUpdate.emit(self._name, rcvTimestamp*1e-6, self._currentFile.id.get_filesize()) -class Hdf5Reader(Filter): +class Hdf5File(GenericReaderFile): """ - Generic nexxT filter for reading HDF5 files created by Hdf5Writer + Adaptation of hdf5 file format """ + def __init__(self, filename): + self._file = h5py.File(filename, "r") - # signals for playback device - playbackStarted = Signal() - playbackPaused = Signal() - sequenceOpened = Signal(str, QDateTime, QDateTime, list) - currentTimestampChanged = Signal(QDateTime) - timeRatioChanged = Signal(float) - - # slots for playback device - - def startPlayback(self): - """ - slot called when the playback shall be started - - :return: - """ - if not self._playing and self._timer is not None: - self._playing = True - self._timer.start(0) - self._updateTimer.start() - self.playbackStarted.emit() - - def pausePlayback(self): - """ - slot called when the playback shall be paused - - :return: - """ - if self._playing and self._timer is not None: - self._playing = False - self._untilStream = None - self._dir = 1 - self._timer.stop() - self._updateTimer.stop() - self._updateCurrentTimestamp() - self.playbackPaused.emit() - - def stepForward(self, stream): - """ - slot called to step one frame in stream forward - - :param stream: a string instance or None (all streams are selected) - :return: - """ - self._untilStream = stream if stream is not None else '' - self.startPlayback() - - def stepBackward(self, stream): - """ - slot called to step one frame in stream backward - - :param stream: a string instance or None (all streams are selected) - :return: - """ - self._dir = -1 - self._untilStream = stream if stream is not None else '' - self.startPlayback() - - def seekBeginning(self): - """ - slot called to go to the beginning of the file - - :return: - """ - self.pausePlayback() - for p in self._portToIdx: - self._portToIdx[p] = -1 - self._transmitNextSample() - self._updateCurrentTimestamp() - - def seekEnd(self): + def close(self): """ - slot called to go to the end of the file + Closes the file. :return: """ - self.pausePlayback() - for p in self._portToIdx: - self._portToIdx[p] = len(self._currentFile["streams"][p]) - self._dir = -1 - self._transmitNextSample() - self._dir = +1 - self._updateCurrentTimestamp() - - def seekTime(self, tgtDatetime): - """ - slot called to go to the specified time + self._file.close() - :param tgtDatetime: a QDateTime instance - :return: + def getNumberOfSamples(self, stream): """ - t = tgtDatetime.toMSecsSinceEpoch()*1000 - nValid = 0 - for p in self._portToIdx: - s = self._currentFile["streams"][p] - # binary search - minIdx = -1 - num = len(self._currentFile["streams"][p]) - maxIdx = num - while maxIdx - minIdx > 1: - testIdx = max(0, min(num-1, (minIdx + maxIdx)//2)) - vTest = s[testIdx]["rcvTimestamp"] - if vTest <= t: - minIdx = testIdx - else: - maxIdx = testIdx - self._portToIdx[p] = minIdx - if minIdx >= 0: - # note: minIdx is always below num - nValid += 1 - if nValid > 0: - self._transmitCurrent() - else: - self._transmitNextSample() - self._updateCurrentTimestamp() + Returns the number of samples in the given stream - def setSequence(self, filename): + :param stream: the name of the stream as a string + :return: the number of samples in the stream """ - slot called to set the sequence file name + return len(self._file["streams"][stream]) - :param filename: a string instance - :return: + def getTimestampResolution(self): """ - logger.debug("Set sequence filename=%s", filename) - self._name = filename + Returns the resolution of the timestamps in ticks per second. - def setTimeFactor(self, factor): + :return: ticks per second as an integer """ - slot called to set the time factor + return 1000000 - :param factor: a float - :return: + def allStreams(self): """ - self._timeFactor = factor - self.timeRatioChanged.emit(self._timeFactor) - - # overwrites from Filter + Returns the streams in this file. - def __init__(self, env): - super().__init__(False, True, env) - self._name = None - self._currentFile = None - self._portToIdx = None - self._timer = None - self._updateTimer = QTimer(self) - self._updateTimer.setInterval(1000) # update new position each second - self._updateTimer.timeout.connect(self._updateCurrentTimestamp) - self._currentTimestamp = None - self._playing = None - self._untilStream = None - self._dir = 1 - self._ports = None - self._timeFactor = 1 - - def onOpen(self): + :return: a list of strings """ - overloaded from Filter + return list(self._file["streams"].keys()) - :return: + def readSample(self, stream, streamIdx): """ - srv = Services.getService("PlaybackControl") - srv.setupConnections(self, ["*.h5", "*.hdf5", "*.hdf"]) - if isMainThread(): - logger.warning("Hdf5Reader seems to run in GUI thread. Consider to move it to a seperate thread.") + Returns the referenced sample as a tuple (content, dataType, dataTimestamp, rcvTimestamp). - def onStart(self): + :param stream: the stream + :param idx: the index of the sample in the stream + :return: (content: QByteArray, dataType: str, dataTimestamp: int, receiveTimestamp: int) """ - overloaded from Filter + content, dataType, dataTimestamp, receiveTimestamp = self._file["streams"][stream][streamIdx] + if isinstance(dataType, bytes): + # this is happening now with h5py >= 3.x + dataType = dataType.decode() + return content.tobytes(), dataType, dataTimestamp, receiveTimestamp - :return: + def getRcvTimestamp(self, stream, streamIdx): """ - if self._name is not None: - self._currentFile = h5py.File(self._name, "r") - self._portToIdx = {} - self._ports = self.getDynamicOutputPorts() - for s in self._currentFile["streams"]: - if s in [p.name() for p in self._ports]: - if len(self._currentFile["streams"][s]) > 0: - self._portToIdx[s] = -1 - else: - logger.warning("Stream %s does not contain any samples.", s) - else: - logger.warning("No matching output port for stream %s. Consider to create a port for it.", s) - for p in self._ports: - if not p.name() in self._portToIdx: - logger.warning("No matching stream for output port %s. HDF5 file not matching the configuration?", - p.name()) - self._timer = QTimer(parent=self) - self._timer.timeout.connect(self._transmitNextSample) - self._playing = False - self._currentTimestamp = None - span = self._timeSpan() - self.sequenceOpened.emit(self._name, span[0], span[1], sorted(self._portToIdx.keys())) - self.timeRatioChanged.emit(self._timeFactor) - self.playbackPaused.emit() + Returns the recevie timestamp of the given (stream, streamIdx) sample. The default implementation uses + readSample(...). It may be replaced by a more efficient implementation. - def onStop(self): + :param stream: the name of the stream as a string + :param streamIdx: the stream index as an integer + :return: the timestamp as an integer (see also getTimestampResolution) """ - overloaded from Filter + return self._file["streams"][stream][streamIdx]["rcvTimestamp"] - :return: - """ - if self._currentFile is not None: - self._currentFile.close() - self._currentFile = None - self._portToIdx = None - self._timer.stop() - self._timer = None - self._playing = None - self._currentTimestamp = None +class Hdf5Reader(GenericReader): + """ + Reader for the nexxT default file format based on hdf5. + """ - def onClose(self): + def getNameFilter(self): """ - overloaded from Filter + Returns the name filter associated with the input files. - :return: + :return: a list of strings, e.g. ["*.h5", "*.hdf5"] """ - srv = Services.getService("PlaybackControl") - srv.removeConnections(self) + return ["*.h5", "*.hdf5", "*.hdf"] - def onSuggestDynamicPorts(self): + def openFile(self, filename): """ - overloaded from Filter + Opens the given file and return an instance of GenericReaderFile. - :return: + :return: an instance of GenericReaderFile """ - try: - fn, ok = QFileDialog.getOpenFileName(caption="Choose template hdf5 file", - filter="HDF5 files (*.h5 *.hdf5 *.hdf)") - if ok: - f = h5py.File(fn, "r") - return [], list(f["streams"].keys()) - except Exception: # pylint: disable=broad-except - logger.exception("Caught exception during onSuggestDynamicPorts") - return [], [] - - # private slots and methods - - def _timeSpan(self): - tmin = np.inf - tmax = -np.inf - for p in self._portToIdx: - t = self._currentFile["streams"][p][0]["rcvTimestamp"] - tmin = min(t, tmin) - t = self._currentFile["streams"][p][-1]["rcvTimestamp"] - tmax = max(t, tmax) - return QDateTime.fromMSecsSinceEpoch(tmin//1000), QDateTime.fromMSecsSinceEpoch(tmax//1000) - - def _getNextSample(self): - # check which port has the next sample to deliver according to rcv timestamps - nextPort = None - for p in self._portToIdx: - idx = self._portToIdx[p] - idx = idx + self._dir - if 0 <= idx < len(self._currentFile["streams"][p]): - ts = self._currentFile["streams"][p][idx]["rcvTimestamp"] - # pylint: disable=unsubscriptable-object - # actually, nextPort can be either None or a 2-tuple - if nextPort is None or (ts < nextPort[0] and self._dir > 0) or (ts > nextPort[0] and self._dir < 0): - nextPort = (ts, p) - return nextPort - - @handleException - def _transmitNextSample(self): - startTime = time.perf_counter_ns() - nextPort = self._getNextSample() - # when next data sample arrives sooner than this threshold, do not use the QTimer but perform busy waiting - noSleepThreshold_ns = 0.005*1e9 # 5 ms - # maximum time in busy-wait strategy (measured from the beginning of the function) - maxTimeInMethod = 0.05*1e9 # yield all 50 ms - while nextPort is not None: - ts, pname = nextPort - self._portToIdx[pname] += self._dir - lastTransmit = self._transmit(pname) - if not self._playing: - return pname - nextPort = self._getNextSample() - if nextPort is not None: - newTs, _ = nextPort - nowTime = time.perf_counter_ns() - deltaT_ns = max(0, (newTs - ts) * 1000 / self._timeFactor - (nowTime - lastTransmit)) - if deltaT_ns < noSleepThreshold_ns and nowTime - startTime + deltaT_ns < maxTimeInMethod: - while time.perf_counter_ns() - nowTime < deltaT_ns: - pass - else: - self._timer.start(deltaT_ns//1000000) - break - else: - self.pausePlayback() - - def _transmit(self, pname): - idx = self._portToIdx[pname] - # read data sample from HDF5 file - content, dataType, dataTimestamp, rcvTimestamp = self._currentFile["streams"][pname][idx] - # create sample to transmit - sample = DataSample(content.tobytes(), dataType, dataTimestamp) - res = time.perf_counter_ns() - # transmit sample over corresponding port - self._ports[[p.name() for p in self._ports].index(pname)].transmit(sample) - self._currentTimestampChanged(QDateTime.fromMSecsSinceEpoch(rcvTimestamp//1000)) - if self._untilStream is not None: - if self._untilStream == pname or self._untilStream == '': - self.pausePlayback() - return res - - def _transmitCurrent(self): - ports = list(self._portToIdx.keys()) - values = [self._currentFile["streams"][p][self._portToIdx[p]]["rcvTimestamp"] for p in ports] - sortedIdx = sorted(range(len(values)), key=lambda x: values[x]) - # transmit most recent sample - self._transmit(ports[sortedIdx[-1]]) - - def _currentTimestampChanged(self, timestamp): - self._currentTimestamp = timestamp - - def _updateCurrentTimestamp(self): - if self._currentTimestamp is not None: - self.currentTimestampChanged.emit(self._currentTimestamp) + return Hdf5File(filename) diff --git a/nexxT/interface/PropertyCollections.py b/nexxT/interface/PropertyCollections.py index def4fee..3c3341d 100644 --- a/nexxT/interface/PropertyCollections.py +++ b/nexxT/interface/PropertyCollections.py @@ -165,7 +165,7 @@ def onPropertyChanged(self, pc, name): propertyChanged = Signal(object, str) """ QT signal which is emitted after a property value of the collection has been changed by the user. - + :param pc: the PropertyCollection instance (i.e., the same as self.sender()) :param propName: the name of the property which has been changed. """ diff --git a/nexxT/services/SrvPlaybackControl.py b/nexxT/services/SrvPlaybackControl.py index a4cde17..de8ef70 100644 --- a/nexxT/services/SrvPlaybackControl.py +++ b/nexxT/services/SrvPlaybackControl.py @@ -10,7 +10,7 @@ import pathlib import logging -from PySide2.QtCore import QObject, Signal, Slot, QDateTime, Qt, QDir, QMutex, QMutexLocker +from PySide2.QtCore import QObject, Signal, Slot, Qt, QDir, QMutex, QMutexLocker from nexxT.interface import FilterState from nexxT.core.Exceptions import NexTRuntimeError from nexxT.core.Application import Application @@ -18,10 +18,169 @@ logger = logging.getLogger(__name__) +class PlaybackDeviceProxy(QObject): + """ + This class acts as a proxy and is connected to exactly one playback device over QT signals slots for providing + thread safety. + """ + def __init__(self, playbackControl, playbackDevice, nameFilters): + super().__init__() + # private variables + self._playbackControl = playbackControl + self._nameFilters = nameFilters + self._controlsFile = False # is set to True when setSequence is called with a matching file name + self._featureSet = {} + # this instance is called in the playbackDevice's thread, move it to the playbackControl thread (= main thread) + self.moveToThread(playbackControl.thread()) + # setup mandatory connections from control to playback + if not self._startPlayback.connect(playbackDevice.startPlayback): + raise NexTRuntimeError("cannot connect to slot startPlayback()") + if not self._pausePlayback.connect(playbackDevice.pausePlayback): + raise NexTRuntimeError("cannot connect to slot pausePlayback()") + # setup optional connections from control to playback + self._featureSet = set(["startPlayback", "pausePlayback"]) + for feature in ["stepForward", "stepBackward", "seekTime", "seekBeginning", "seekEnd", + "setTimeFactor", "setSequence"]: + signal = getattr(self, "_" + feature) + slot = getattr(playbackDevice, feature, None) + if slot is not None and signal.connect(slot): + self._featureSet.add(feature) + # setup optional connections from playback to control + for feature in ["sequenceOpened", "currentTimestampChanged", "playbackStarted", "playbackPaused", + "timeRatioChanged"]: + slot = getattr(self, "_" + feature) + signal = getattr(playbackDevice, feature, None) + if signal is not None and signal.connect(slot): + self._featureSet.add(feature) + + def startPlayback(self): + """ + Proxy function, checks whether this proxy has control and emits the signal if necessary + """ + if self._controlsFile: + self._startPlayback.emit() + + def pausePlayback(self): + """ + Proxy function, checks whether this proxy has control and emits the signal if necessary + """ + if self._controlsFile: + self._pausePlayback.emit() + + def stepForward(self, stream): + """ + Proxy function, checks whether this proxy has control and emits the signal if necessary + """ + if self._controlsFile: + self._stepForward.emit(stream) + + def stepBackward(self, stream): + """ + Proxy function, checks whether this proxy has control and emits the signal if necessary + """ + if self._controlsFile: + self._stepBackward.emit(stream) + + def seekBeginning(self): + """ + Proxy function, checks whether this proxy has control and emits the signal if necessary + """ + if self._controlsFile: + self._seekBeginning.emit() + + def seekEnd(self): + """ + Proxy function, checks whether this proxy has control and emits the signal if necessary + """ + if self._controlsFile: + self._seekEnd.emit() + + def seekTime(self, timestamp_ns): + """ + Proxy function, checks whether this proxy has control and emits the signal if necessary + + :param timestamp_ns: the timestamp in nanoseconds + """ + if self._controlsFile: + self._seekTime.emit(timestamp_ns) + + def setSequence(self, filename): + """ + Proxy function, checks whether filename matches the filter's name filter list and takes control if necessary. + + :param filename: a string instance or None + """ + if filename is not None and not QDir.match(self._nameFilters, pathlib.Path(filename).name): + filename = None + self._controlsFile = filename is not None + self._setSequence.emit(filename) + + def setTimeFactor(self, factor): + """ + Proxy function, checks whether this proxy has control and emits the signal if necessary + + :param factor: time factor as a float + """ + if self._controlsFile: + self._setTimeFactor.emit(factor) + + def hasControl(self): + """ + Returns whether this proxy object has control over the player + + :return: true if this proxy object has control + """ + return self._controlsFile + + def featureSet(self): + """ + Returns the player device's feature set + + :return: a set of strings + """ + return self._featureSet + + _startPlayback = Signal() + _pausePlayback = Signal() + _stepForward = Signal(str) + _stepBackward = Signal(str) + _seekBeginning = Signal() + _seekEnd = Signal() + _seekTime = Signal('qint64') + _setSequence = Signal(object) + _setTimeFactor = Signal(float) + sequenceOpened = Signal(str, 'qint64', 'qint64', object) + currentTimestampChanged = Signal('qint64') + playbackStarted = Signal() + playbackPaused = Signal() + timeRatioChanged = Signal(float) + + def _sequenceOpened(self, filename, begin, end, streams): + if self._controlsFile: + self.sequenceOpened.emit(filename, begin, end, streams) + + def _currentTimestampChanged(self, currentTime): + if self._controlsFile: + self.currentTimestampChanged.emit(currentTime) + + def _playbackStarted(self): + if self._controlsFile: + self.playbackStarted.emit() + + def _playbackPaused(self): + if self._controlsFile: + self.playbackPaused.emit() + + def _timeRatioChanged(self, newRatio): + if self._controlsFile: + self.timeRatioChanged.emit(newRatio) + + class MVCPlaybackControlBase(QObject): """ - Base class for interacting with playback controller, usually this is connected to a - harddisk player. + Base class for interacting with playback controller, usually this is connected to a harddisk player. This class + provides the functions setupConnections and removeConnections which can be called by filters to register and + deregister themselfs as a playback device. """ _startPlayback = Signal() _pausePlayback = Signal() @@ -29,8 +188,8 @@ class MVCPlaybackControlBase(QObject): _stepBackward = Signal(str) _seekBeginning = Signal() _seekEnd = Signal() - _seekTime = Signal(QDateTime) - _setSequence = Signal(str) + _seekTime = Signal('qint64') + _setSequence = Signal(object) _setTimeFactor = Signal(float) def __init__(self): @@ -38,6 +197,7 @@ def __init__(self): self._deviceId = 0 self._registeredDevices = {} self._mutex = QMutex() + self._setSequence.connect(self._stopSetSequenceStart) @Slot(QObject, "QStringList") def setupConnections(self, playbackDevice, nameFilters): @@ -50,19 +210,19 @@ def setupConnections(self, playbackDevice, nameFilters): - startPlayback() (starts generating DataSamples) - pausePlayback() (pause mode, stop generating DataSamples) - stepForward(QString stream) (optional; in case given, a single step operation shall be performed. - if stream is not None, the playback shall stop when receiving the next data sample - of stream; otherwise the playback shall proceed to the next data sample of any stream) + if stream is not None, the playback shall stop when receiving the next data sample + of stream; otherwise the playback shall proceed to the next data sample of any stream) - stepBackward(QString stream) (optional; see stepForward) - seekBeginning(QString stream) (optional; goes to the beginning of the sequence) - seekEnd() (optional; goes to the end of the stream) - - seekTime(QString QDateTime) (optional; goes to the specified time stamp) + - seekTime(qint64) (optional; goes to the specified time stamp) - setSequence(QString) (optional; opens the given sequence) - setTimeFactor(float) (optional; sets the playback time factor, factor > 0) It expects playbackDevice to provide the following signals (all signals are optional): - - sequenceOpened(QString filename, QDateTime begin, QDateTime end, QStringList streams) - - currentTimestampChanged(QDateTime currentTime) + - sequenceOpened(QString filename, qint64 begin, qint64 end, QStringList streams) + - currentTimestampChanged(qint64 currentTime) - playbackStarted() - playbackPaused() - timeRatioChanged(float) @@ -76,56 +236,27 @@ def setupConnections(self, playbackDevice, nameFilters): if self._registeredDevices[devid]["object"] is playbackDevice: raise NexTRuntimeError("Trying to register a playbackDevice object twice.") - if not self._startPlayback.connect(playbackDevice.startPlayback): - raise NexTRuntimeError("cannot connect to slot startPlayback()") - if not self._pausePlayback.connect(playbackDevice.pausePlayback): - raise NexTRuntimeError("cannot connect to slot pausePlayback()") + proxy = PlaybackDeviceProxy(self, playbackDevice, nameFilters) + featureset = proxy.featureSet() - connections = [(self._startPlayback, playbackDevice.startPlayback), - (self._pausePlayback, playbackDevice.pausePlayback)] - featureset = set(["startPlayback", "pausePlayback"]) for feature in ["stepForward", "stepBackward", "seekTime", "seekBeginning", "seekEnd", - "setTimeFactor"]: + "setTimeFactor", "startPlayback", "pausePlayback"]: signal = getattr(self, "_" + feature) - slot = getattr(playbackDevice, feature, None) - if slot is not None and signal.connect(slot, Qt.UniqueConnection): - featureset.add(feature) - connections.append((signal, slot)) - - @handleException - def setSequenceWrapper(filename): - assertMainThread() - if Application.activeApplication is None: - return - if Application.activeApplication.getState() not in [FilterState.ACTIVE, FilterState.OPENED]: - return - if QDir.match(nameFilters, pathlib.Path(filename).name): - logger.debug("setSequence %s", filename) - if Application.activeApplication.getState() == FilterState.ACTIVE: - Application.activeApplication.stop() - MethodInvoker(dict(object=playbackDevice, method="setSequence"), Qt.QueuedConnection, filename) - Application.activeApplication.start() - logger.debug("setSequence done") - else: - logger.debug("%s does not match filters: %s", filename, nameFilters) - MethodInvoker(dict(object=playbackDevice, method="setSequence"), Qt.QueuedConnection, None) - - # setSequence is called only if filename matches the given filters - if self._setSequence.connect(setSequenceWrapper, Qt.DirectConnection): - featureset.add("setSequence") - connections.append((self._setSequence, setSequenceWrapper)) + slot = getattr(proxy, feature, None) + if slot is not None: + signal.connect(slot) + for feature in ["sequenceOpened", "currentTimestampChanged", "playbackStarted", "playbackPaused", "timeRatioChanged"]: slot = getattr(self, "_" + feature) - signal = getattr(playbackDevice, feature, None) - if signal is not None and signal.connect(slot, Qt.UniqueConnection): - featureset.add(feature) - connections.append((signal, slot)) + signal = getattr(proxy, feature, None) + if signal is not None: + signal.connect(slot, Qt.UniqueConnection) self._registeredDevices[self._deviceId] = dict(object=playbackDevice, featureset=featureset, nameFilters=nameFilters, - connections=connections) + proxy=proxy) self._deviceId += 1 MethodInvoker(dict(object=self, method="_updateFeatureSet", thread=mainThread()), Qt.QueuedConnection) @@ -145,29 +276,43 @@ def removeConnections(self, playbackDevice): found.append(devid) if len(found) > 0: for devid in found: - for signal, slot in self._registeredDevices[devid]["connections"]: - signal.disconnect(slot) del self._registeredDevices[devid] logger.debug("disconnected connections of playback device. number of devices left: %d", len(self._registeredDevices)) MethodInvoker(dict(object=self, method="_updateFeatureSet", thread=mainThread()), Qt.QueuedConnection) + @handleException + def _stopSetSequenceStart(self, filename): + assertMainThread() + if Application.activeApplication is None: + logger.warning("playbackControl.setSequence is called without an active application.") + return + state = Application.activeApplication.getState() + if state not in [FilterState.ACTIVE, FilterState.OPENED]: + logger.warning("playbackControl.setSequence is called with unexpected application state %s", + FilterState.state2str(state)) + return + if state == FilterState.ACTIVE: + Application.activeApplication.stop() + assert Application.activeApplication.getState() == FilterState.OPENED + for _, spec in self._registeredDevices.items(): + spec["proxy"].setSequence(filename) + # only one filter will get the playback control + if spec["proxy"].hasControl(): + filename = None + logger.debug("found playback device with explicit control") + if filename is not None: + logger.warning("did not find a playback device taking control") + Application.activeApplication.start() + assert Application.activeApplication.getState() == FilterState.ACTIVE + def _updateFeatureSet(self): assertMainThread() featureset = set() nameFilters = set() - featureCount = {} for devid in self._registeredDevices: - for f in self._registeredDevices[devid]["featureset"]: - if not f in featureCount: - featureCount[f] = 0 - featureCount[f] += 1 featureset = featureset.union(self._registeredDevices[devid]["featureset"]) nameFilters = nameFilters.union(self._registeredDevices[devid]["nameFilters"]) - for f in featureCount: - if featureCount[f] > 1 and f in ["seekTime", "setSequence", "setTimeFactor"]: - logger.warning("Multiple playback devices are providing slots intended for single usage." - "Continuing anyways.") self._supportedFeaturesChanged(featureset, nameFilters) def _supportedFeaturesChanged(self, featureset, nameFilters): @@ -227,8 +372,8 @@ class PlaybackControlConsole(MVCPlaybackControlBase): The GUI service inherits from this class, so that the GUI can also be scripted in the same way. """ supportedFeaturesChanged = Signal(object, object) - sequenceOpened = Signal(str, QDateTime, QDateTime, object) - currentTimestampChanged = Signal(QDateTime) + sequenceOpened = Signal(str, 'qint64', 'qint64', object) + currentTimestampChanged = Signal('qint64') playbackStarted = Signal() playbackPaused = Signal() timeRatioChanged = Signal(float) @@ -288,14 +433,14 @@ def seekEnd(self): """ self._seekEnd.emit() - def seekTime(self, datetime): + def seekTime(self, timestamp_ns): """ Seek to the specified time - :param datetime: a QDateTime instance + :param timestamp_ns: the timestamp in nanoseconds :return: """ - self._seekTime.emit(datetime) + self._seekTime.emit(timestamp_ns) def setSequence(self, file): """ diff --git a/nexxT/services/gui/BrowserWidget.py b/nexxT/services/gui/BrowserWidget.py index 9ee7a46..c19b3d9 100644 --- a/nexxT/services/gui/BrowserWidget.py +++ b/nexxT/services/gui/BrowserWidget.py @@ -373,6 +373,8 @@ def main(): :return: """ + # pylint: disable-import-outside-toplevel + # this is just the test function part from PySide2.QtWidgets import QApplication app = QApplication() diff --git a/nexxT/services/gui/Configuration.py b/nexxT/services/gui/Configuration.py index 625d2e7..3b7a672 100644 --- a/nexxT/services/gui/Configuration.py +++ b/nexxT/services/gui/Configuration.py @@ -47,7 +47,8 @@ def __init__(self, configuration): self.actSave = QAction(QIcon.fromTheme("document-save", style.standardIcon(QStyle.SP_DialogSaveButton)), "Save config", self) self.actSave.triggered.connect(self._execSaveConfig) - self.actSaveWithGuiState = QAction(QIcon.fromTheme("document-save", style.standardIcon(QStyle.SP_DialogSaveButton)), + self.actSaveWithGuiState = QAction(QIcon.fromTheme("document-save", + style.standardIcon(QStyle.SP_DialogSaveButton)), "Save config sync gui state", self) self.actSaveWithGuiState.triggered.connect(self._execSaveConfigWithGuiState) self.actNew = QAction(QIcon.fromTheme("document-new", style.standardIcon(QStyle.SP_FileIcon)), @@ -401,10 +402,12 @@ def activeAppStateChange(self, newState): if startPlay: pbsrv.playbackPaused.connect(self._singleShotPlay) QTimer.singleShot(2000, self._disconnectSingleShotPlay) - MethodInvoker(pbsrv.setSequence, Qt.QueuedConnection, pbfile) + MethodInvoker(pbsrv.browser.setActive, Qt.QueuedConnection, pbfile) self.actDeactivate.setEnabled(True) + self.actSaveWithGuiState.setEnabled(False) else: self.actDeactivate.setEnabled(False) + self.actSaveWithGuiState.setEnabled(True) def restoreState(self): """ diff --git a/nexxT/services/gui/MainWindow.py b/nexxT/services/gui/MainWindow.py index 1f2418c..8351e82 100644 --- a/nexxT/services/gui/MainWindow.py +++ b/nexxT/services/gui/MainWindow.py @@ -15,7 +15,8 @@ from PySide2.QtWidgets import (QMainWindow, QMdiArea, QMdiSubWindow, QDockWidget, QAction, QWidget, QGridLayout, QMenuBar, QMessageBox) from PySide2.QtCore import (QObject, Signal, Slot, Qt, QByteArray, QDataStream, QIODevice, QRect, QPoint, QSettings, - QTimer) + QTimer, QUrl) +from PySide2.QtGui import QDesktopServices import nexxT from nexxT.interface import Filter from nexxT.core.Application import Application @@ -159,7 +160,7 @@ class MainWindow(QMainWindow): Main Window service for the nexxT frameworks. Other services usually create dock windows, filters use the subplot functionality to create grid-layouted views. """ - mdiSubWindowCreated = Signal(QMdiSubWindow) # TODO: remove, is not necessary anymore with subplot feature + mdiSubWindowCreated = Signal(QMdiSubWindow) # TODO: deprecated, can be removed in later versions aboutToClose = Signal(object) def __init__(self, config): @@ -171,13 +172,17 @@ def __init__(self, config): self.mdi.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.setCentralWidget(self.mdi) self.menu = self.menuBar().addMenu("&Windows") - self.aboutMenu = QMenuBar(self) + self.aboutMenu = QMenuBar(self.menuBar()) self.menuBar().setCornerWidget(self.aboutMenu) - m = self.aboutMenu.addMenu("&About") + m = self.aboutMenu.addMenu("&Help") + self.helpNexxT = QAction("Help ...") self.aboutNexxT = QAction("About nexxT ...") self.aboutQt = QAction("About Qt ...") self.aboutPython = QAction("About Python ...") + m.addActions([self.helpNexxT]) + m.addSeparator() m.addActions([self.aboutNexxT, self.aboutQt, self.aboutPython]) + self.helpNexxT.triggered.connect(lambda: QDesktopServices.openUrl(QUrl("https://nexxT.readthedocs.org"))) self.aboutNexxT.triggered.connect(lambda: QMessageBox.about(self, "About nexxT", """\ This program uses nexxT %(version)s, a generic hybrid python/c++ framework for developing computer vision algorithms.

@@ -236,7 +241,6 @@ def restoreState(self): if v is not None: self.restoreGeometry(v) if self.toolbar is not None: - # TODO: add toolbar to windows menu, so we don't need this self.toolbar.show() def saveState(self): @@ -360,7 +364,10 @@ def subplot(self, windowId, theFilter, widget): self.managedSubplots[title]["layout"].addWidget(widget, row, col) self.managedSubplots[title]["mdiSubWindow"].updateGeometry() widget.setParent(self.managedSubplots[title]["swwidget"]) - QTimer.singleShot(0, lambda: ( + # note: there seems to be a race condition when decreasing the single shot timeout to 0 + # sometimes the window size is then not correctly adjusted + # with the 100 ms timeout this couldn't be reproduced + QTimer.singleShot(100, lambda: ( self.managedSubplots[title]["mdiSubWindow"].adjustSize() if widget.parent().size().height() < widget.minimumSizeHint().height() or widget.parent().size().height() < widget.minimumSize().height() else None diff --git a/nexxT/services/gui/PlaybackControl.py b/nexxT/services/gui/PlaybackControl.py index 2970787..a9d4f4d 100644 --- a/nexxT/services/gui/PlaybackControl.py +++ b/nexxT/services/gui/PlaybackControl.py @@ -10,7 +10,7 @@ import functools import logging from pathlib import Path -from PySide2.QtCore import Signal, QDateTime, Qt, QTimer +from PySide2.QtCore import Signal, Qt, QTimer, QDir from PySide2.QtGui import QIcon from PySide2.QtWidgets import (QWidget, QGridLayout, QLabel, QBoxLayout, QSlider, QToolBar, QAction, QApplication, QStyle, QActionGroup) @@ -67,17 +67,17 @@ def __init__(self, config): # pylint: disable=unnecessary-lambda # let's stay on the safe side and do not use emit as a slot... - self.actStart.triggered.connect(lambda: self._startPlayback.emit()) - self.actPause.triggered.connect(lambda: self._pausePlayback.emit()) - self.actStepFwd.triggered.connect(lambda: self._stepForward.emit(self.selectedStream())) - self.actStepBwd.triggered.connect(lambda: self._stepBackward.emit(self.selectedStream())) - self.actSeekEnd.triggered.connect(lambda: self._seekEnd.emit()) - self.actSeekBegin.triggered.connect(lambda: self._seekBeginning.emit()) + self.actStart.triggered.connect(self.startPlayback) + self.actPause.triggered.connect(self.pausePlayback) + self.actStepFwd.triggered.connect(lambda: self.stepForward(self.selectedStream())) + self.actStepBwd.triggered.connect(lambda: self.stepBackward(self.selectedStream())) + self.actSeekEnd.triggered.connect(self.seekEnd) + self.actSeekBegin.triggered.connect(self.seekBeginning) # pylint: enable=unnecessary-lambda def setTimeFactor(newFactor): logger.debug("new time factor %f", newFactor) - self._setTimeFactor.emit(newFactor) + self.setTimeFactor(newFactor) for r in self.actSetTimeFactor: logger.debug("adding action for time factor %f", r) @@ -163,6 +163,12 @@ def __del__(self): def _onNameFiltersChanged(self, nameFilt): self.browser.setFilter(nameFilt) + if isinstance(nameFilt, str): + nameFilt = [nameFilt] + for a in self.recentSeqs: + if a.isVisible(): + m = QDir.match(nameFilt, Path(a.data()).name) + a.setEnabled(m) def _onShowAllFiles(self, enabled): self.fileSystemModel.setNameFilterDisables(enabled) @@ -223,10 +229,10 @@ def _sequenceOpened(self, filename, begin, end, streams): assertMainThread() self.beginTime = begin self.preventSeek = True - self.positionSlider.setRange(0, end.toMSecsSinceEpoch() - begin.toMSecsSinceEpoch()) + self.positionSlider.setRange(0, (end - begin)//1000000) self.preventSeek = False - self.beginLabel.setText(begin.toString("hh:mm:ss.zzz")) - self.endLabel.setText(end.toString("hh:mm:ss.zzz")) + self.beginLabel.setText(self._timeToString(0)) + self.endLabel.setText(self._timeToString((end - begin)//1000000)) self._currentTimestampChanged(begin) try: self.browser.blockSignals(True) @@ -248,6 +254,20 @@ def _sequenceOpened(self, filename, begin, end, streams): QTimer.singleShot(250, self.scrollToCurrent) super()._sequenceOpened(filename, begin, end, streams) + @staticmethod + def _splitTime(milliseconds): + hours = milliseconds // (60 * 60 * 1000) + milliseconds -= hours * (60 * 60 * 1000) + minutes = milliseconds // (60 * 1000) + milliseconds -= minutes * (60 * 1000) + seconds = milliseconds // 1000 + milliseconds -= seconds * 1000 + return hours, minutes, seconds, milliseconds + + @staticmethod + def _timeToString(milliseconds): + return "%02d:%02d:%02d.%03d" % (MVCPlaybackControlGUI._splitTime(milliseconds)) + def _currentTimestampChanged(self, currentTime): """ Notifies about a changed timestamp @@ -259,13 +279,13 @@ def _currentTimestampChanged(self, currentTime): if self.beginTime is None: self.currentLabel.setText("") else: - sliderVal = currentTime.toMSecsSinceEpoch() - self.beginTime.toMSecsSinceEpoch() + sliderVal = (currentTime - self.beginTime) // 1000000 # nanoseconds to milliseconds self.preventSeek = True self.positionSlider.setValue(sliderVal) self.preventSeek = False self.positionSlider.blockSignals(False) self.currentLabel.setEnabled(True) - self.currentLabel.setText(currentTime.toString("hh:mm:ss.zzz")) + self.currentLabel.setText(self._timeToString(sliderVal)) super()._currentTimestampChanged(currentTime) def onSliderValueChanged(self, value): @@ -279,8 +299,8 @@ def onSliderValueChanged(self, value): if self.beginTime is None or self.preventSeek: return if self.actStart.isEnabled(): - ts = QDateTime.fromMSecsSinceEpoch(self.beginTime.toMSecsSinceEpoch() + value, self.beginTime.timeSpec()) - self._seekTime.emit(ts) + ts = self.beginTime + value * 1000000 + self.seekTime(ts) else: logger.warning("Can't seek while playing.") @@ -295,9 +315,9 @@ def displayPosition(self, value): if self.beginTime is None: return if self.positionSlider.isSliderDown(): - ts = QDateTime.fromMSecsSinceEpoch(self.beginTime.toMSecsSinceEpoch() + value, self.beginTime.timeSpec()) + ts = self.beginTime // 1000000 + value self.currentLabel.setEnabled(False) - self.currentLabel.setText(ts.toString("hh:mm:ss.zzz")) + self.currentLabel.setText(self._timeToString(ts)) def _playbackStarted(self): """ @@ -357,7 +377,7 @@ def browserActivated(self, filename): self.recentSeqs[0].setText(self.compressFileName(filename)) self.recentSeqs[0].setData(filename) self.recentSeqs[0].setVisible(True) - self._setSequence.emit(filename) + self.setSequence(filename) def _timeRatioChanged(self, newRatio): """ @@ -393,6 +413,12 @@ def setSelectedStream(self, stream): """ self._selectedStream = stream + def _defineProperties(self): + propertyCollection = self.config.guiState() + propertyCollection.defineProperty("PlaybackControl_showAllFiles", 0, "show all files setting") + propertyCollection.defineProperty("PlaybackControl_folder", "", "current folder name") + propertyCollection.defineProperty("PlaybackControl_recent", "", "recent opened sequences") + def saveState(self): """ Saves the state of the playback control @@ -400,6 +426,7 @@ def saveState(self): :return: """ assertMainThread() + self._defineProperties() propertyCollection = self.config.guiState() showAllFiles = self.actShowAllFiles.isChecked() folder = self.browser.folder() @@ -420,16 +447,14 @@ def restoreState(self): :return: """ assertMainThread() + self._defineProperties() propertyCollection = self.config.guiState() - propertyCollection.defineProperty("PlaybackControl_showAllFiles", 0, "show all files setting") showAllFiles = propertyCollection.getProperty("PlaybackControl_showAllFiles") self.actShowAllFiles.setChecked(bool(showAllFiles)) - propertyCollection.defineProperty("PlaybackControl_folder", "", "current folder name") folder = propertyCollection.getProperty("PlaybackControl_folder") if Path(folder).is_dir(): logger.debug("Setting current file: %s", folder) self.browser.setFolder(folder) - propertyCollection.defineProperty("PlaybackControl_recent", "", "recent opened sequences") recentFiles = propertyCollection.getProperty("PlaybackControl_recent") idx = 0 for f in recentFiles.split("|"): @@ -437,6 +462,7 @@ def restoreState(self): self.recentSeqs[idx].setData(f) self.recentSeqs[idx].setText(self.compressFileName(f)) self.recentSeqs[idx].setVisible(True) + self.recentSeqs[idx].setEnabled(False) idx += 1 if idx >= len(self.recentSeqs): break diff --git a/nexxT/services/gui/RecordingControl.py b/nexxT/services/gui/RecordingControl.py index ab6c32f..6886d8a 100644 --- a/nexxT/services/gui/RecordingControl.py +++ b/nexxT/services/gui/RecordingControl.py @@ -199,6 +199,13 @@ def _onNotifyError(self, originFilter, errorDesc): lines = lines[1:] self._statusLabel.setText("\n".join(lines)) + def _defineProperties(self): + propertyCollection = self._config.guiState() + propertyCollection.defineProperty("RecordingControl_directory", + str(Path('.').absolute()), + "Target directory for recordings") + + def _saveState(self): """ Saves the state of the playback control @@ -206,6 +213,7 @@ def _saveState(self): :return: """ assertMainThread() + self._defineProperties() propertyCollection = self._config.guiState() try: propertyCollection.setProperty("RecordingControl_directory", self._directory) @@ -219,10 +227,9 @@ def _restoreState(self): :return: """ assertMainThread() + self._defineProperties() propertyCollection = self._config.guiState() logger.debug("before restore dir=%s", self._directory) - propertyCollection.defineProperty("RecordingControl_directory", self._directory, - "Target directory for recordings") d = propertyCollection.getProperty("RecordingControl_directory") if Path(d).exists(): self._directory = d diff --git a/nexxT/tests/integration/basicworkflow_script.py b/nexxT/tests/integration/basicworkflow_script.py index 4131bc4..558c77e 100644 --- a/nexxT/tests/integration/basicworkflow_script.py +++ b/nexxT/tests/integration/basicworkflow_script.py @@ -6,7 +6,7 @@ import glob import logging -from PySide2.QtCore import Qt, QCoreApplication, QTimer, QModelIndex, QDateTime +from PySide2.QtCore import Qt, QCoreApplication, QTimer, QModelIndex from nexxT.interface import Services, FilterState from nexxT.core.Utils import MethodInvoker, waitForSignal from nexxT.core.Application import Application @@ -248,8 +248,7 @@ def execute_2(): waitForSignal(pbc.playbackPaused) logger.info("stepForward[stream2]") - execute_2.i = MethodInvoker(pbc.seekTime, Qt.QueuedConnection, - QDateTime.fromMSecsSinceEpoch((tbegin.toMSecsSinceEpoch() + tend.toMSecsSinceEpoch())//2)) + execute_2.i = MethodInvoker(pbc.seekTime, Qt.QueuedConnection, (tbegin + tend)//2) waitForSignal(pbc.currentTimestampChanged) logger.info("seekTime") @@ -262,13 +261,11 @@ def execute_2(): waitForSignal(pbc.playbackPaused) logger.info("stepBackward[None]") - execute_2.i = MethodInvoker(pbc.seekTime, Qt.QueuedConnection, - QDateTime.fromMSecsSinceEpoch(tbegin.toMSecsSinceEpoch() - 1000)) + execute_2.i = MethodInvoker(pbc.seekTime, Qt.QueuedConnection, tbegin - 1000000000) waitForSignal(pbc.currentTimestampChanged) logger.info("seekTimeBegin") - execute_2.i = MethodInvoker(pbc.seekTime, Qt.QueuedConnection, - QDateTime.fromMSecsSinceEpoch(tend.toMSecsSinceEpoch() + 1000)) + execute_2.i = MethodInvoker(pbc.seekTime, Qt.QueuedConnection, tend + 1000000000) waitForSignal(pbc.currentTimestampChanged) logger.info("seekTimeEnd") diff --git a/nexxT/tests/integration/test_gui.py b/nexxT/tests/integration/test_gui.py index 1560a1b..6542752 100644 --- a/nexxT/tests/integration/test_gui.py +++ b/nexxT/tests/integration/test_gui.py @@ -48,6 +48,7 @@ class ContextMenuEntry(str): CM_FILTER_LIBRARY_TESTS_NEXXT = ContextMenuEntry("nexxT") CM_FILTER_LIBRARY_CSIMPLESOURCE = ContextMenuEntry("CSimpleSource") CM_FILTER_LIBRARY_PYSIMPLESTATICFILTER = ContextMenuEntry("PySimpleStaticFilter") +CM_FILTER_LIBRARY_PYSIMPLEVIEW = ContextMenuEntry("PySimpleView") CM_FILTER_LIBRARY_HDF5WRITER = ContextMenuEntry("HDF5Writer") CM_FILTER_LIBRARY_HDF5READER = ContextMenuEntry("HDF5Reader") CM_RENAME_NODE = ContextMenuEntry("Rename node ...") @@ -58,6 +59,8 @@ class ContextMenuEntry(str): CM_SETTHREAD = ContextMenuEntry("Set thread ...") CM_RENAMEDYNPORT = ContextMenuEntry("Rename dynamic port ...") CM_REMOVEDYNPORT = ContextMenuEntry("Remove dynamic port ...") +CONFIG_MENU_DEINITIALIZE = ContextMenuEntry("Deinitialize") +CONFIG_MENU_INITIALIZE = ContextMenuEntry("Initialize") class GuiTestBase: def __init__(self, qtbot, xvfb, keep_open, delay, tmpdir): @@ -76,7 +79,7 @@ def __init__(self, qtbot, xvfb, keep_open, delay, tmpdir): """ Class encapsulates useful method for gui testing the nexxT application. """ - def activateContextMenu(self, *menuItems): + def activateContextMenu(self, *menuItems, **kwargs): """ In a given context menu navigate to the given index using key presses and activate it using return :param menuItems: Might be either integers referencing the position in the menu or (better) strings referencing @@ -98,23 +101,27 @@ def activeMenuEntry(): act = act.menu().activeAction() return act.text() + if kwargs.get("debug", False): + logger_debug = logger.info + else: + logger_debug = logger.debug try: # navigate to the requested menu item for j in range(len(menuItems)): if isinstance(menuItems[j], int): for i in range(menuItems[j]): self.qtbot.keyClick(None, Qt.Key_Down, delay=self.delay) - logger.debug("(int) Current action: '%s'", activeMenuEntry()) + logger_debug("(int) Current action: '%s'", activeMenuEntry()) else: nonNoneAction = None while activeMenuEntry() is None or activeMenuEntry() != menuItems[j]: - logger.debug("(str) Current action: '%s' != '%s'", activeMenuEntry(), menuItems[j]) + logger_debug("(str) Current action: '%s' != '%s'", activeMenuEntry(), menuItems[j]) self.qtbot.keyClick(None, Qt.Key_Down, delay=self.delay) if nonNoneAction is None: nonNoneAction = activeMenuEntry() else: assert nonNoneAction != activeMenuEntry() - logger.debug("(str) Current action: '%s'", activeMenuEntry()) + logger_debug("(str) Current action: '%s'", activeMenuEntry()) if j < len(menuItems) - 1: self.qtbot.keyClick(None, Qt.Key_Right, delay=self.delay) self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) @@ -165,7 +172,7 @@ def gsContextMenu(self, graphView, pos): self.qtbot.mouseMove(graphView.viewport(), graphView.mapFromScene(ev.scenePos())) graphView.scene().contextMenuEvent(ev) - def cmContextMenu(self, conf, idx, *contextMenuIndices): + def cmContextMenu(self, conf, idx, *contextMenuIndices, **kwargs): """ This function executes a context menu on the configuration tree view :param conf: The configuration gui service @@ -183,13 +190,16 @@ def cmContextMenu(self, conf, idx, *contextMenuIndices): self.qtbot.mouseMove(treeView.viewport(), pos=pos, delay=self.delay) try: intIdx = max([i for i in range(-1, -len(contextMenuIndices)-1, -1) - if isinstance(contextMenuIndices[i], int)]) + if isinstance(contextMenuIndices[i], (int,ContextMenuEntry))]) intIdx += len(contextMenuIndices) except ValueError: + logger.exception("exception contextMenuIndices:%s empty?!?", contextMenuIndices) intIdx = -1 cmIdx = contextMenuIndices[:intIdx+1] texts = contextMenuIndices[intIdx+1:] - QTimer.singleShot(self.delay, lambda: self.activateContextMenu(*cmIdx)) + if kwargs.get("debug", False): + logger.info("contextMenuIndices:%s cmIdx:%s texts:%s", contextMenuIndices, cmIdx, texts) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(*cmIdx, **kwargs)) for i, t in enumerate(texts): QTimer.singleShot(self.delay*(i+2), lambda text=t: self.enterText(text)) conf._execTreeViewContextMenu(pos) @@ -343,7 +353,7 @@ def getCurrentFrameIdx(log): return int(lastmsg.strip().split(" ")[-1]) @staticmethod - def noWarningsInLog(log): + def noWarningsInLog(log, ignore=[]): """ assert that there are no warnings logged :param log: the logging service @@ -355,7 +365,8 @@ def noWarningsInLog(log): level = model.data(model.index(row, 1, QModelIndex()), Qt.DisplayRole) if level not in ["INFO", "DEBUG", "INTERNAL"]: msg = model.data(model.index(row, 2, QModelIndex()), Qt.DisplayRole) - raise RuntimeError("Warnings or errors found in log: %s(%s)", level, msg) + if not msg in ignore: + raise RuntimeError("Warnings or errors found in log: %s(%s)", level, msg) def clickDiscardChanges(self): """ @@ -563,9 +574,7 @@ def _first(self): conf.configuration().activate("application") self.aw() self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) - for i in range(2): - self.qtbot.keyClick(None, Qt.Key_Up, delay=self.delay) - self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_INITIALIZE) rec.dockWidget.raise_() # application runs for 2 seconds self.qtbot.wait(2000) @@ -583,9 +592,7 @@ def _first(self): self.qtbot.wait(2000) # de-initialize application self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) - for i in range(2): - self.qtbot.keyClick(None, Qt.Key_Up, delay=self.delay) - self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) # check that the last log message is from the SimpleStaticFilter and it should have received more than 60 # samples assert self.getLastLogFrameIdx(log) >= 60 @@ -655,9 +662,7 @@ def _first(self): with self.qtbot.waitSignal(conf.configuration().appActivated): conf.configuration().activate("application_2") self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) - for i in range(2): - self.qtbot.keyClick(None, Qt.Key_Up, delay=self.delay) - self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_INITIALIZE) # turn off load monitoring self.qtbot.keyClick(self.aw(), Qt.Key_O, Qt.AltModifier, delay=self.delay) self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) @@ -699,9 +704,8 @@ def _first(self): assert self.getLastLogFrameIdx(log) == lastFrame # de-initialize application self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) - for i in range(2): - self.qtbot.keyClick(None, Qt.Key_Up, delay=self.delay) - self.qtbot.keyClick(None, Qt.Key_Return, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + conf.actSave.trigger() self.qtbot.wait(1000) self.noWarningsInLog(log) @@ -746,7 +750,9 @@ def _second(self): self.qtbot.wait(2000) self.cmContextMenu(conf, appidx, CM_INIT_APP_AND_PLAY, 0) self.qtbot.wait(2000) - self.noWarningsInLog(log) + self.noWarningsInLog(log, ignore=[ + "did not find a playback device taking control", + "The inter-thread connection is set to stopped mode; data sample discarded."]) finally: if not self.keep_open: if conf.configuration().dirty(): @@ -885,7 +891,7 @@ def __init__(self, env): def test(self): """ - first start of nexxT in a clean environment, click through a pretty exhaustive scenario. + test property editing in config editor :return: """ QTimer.singleShot(self.delay, self._properties) @@ -896,3 +902,173 @@ def test(self): def test_properties(qtbot, xvfb, keep_open, delay, tmpdir): test = PropertyTest(qtbot, xvfb, keep_open, delay, tmpdir) test.test() + +class GuiStateTest(GuiTestBase): + """ + Concrete test class for the guistate test case + """ + def __init__(self, qtbot, xvfb, keep_open, delay, tmpdir): + super().__init__(qtbot, xvfb, keep_open, delay, tmpdir) + self.prjfile = self.tmpdir / "test_guistate.json" + self.guistatefile = self.tmpdir / "test_guistate.json.guistate" + + def getMdiWindow(self): + mw = Services.getService("MainWindow") + assert len(mw.managedSubplots) == 1 + title = list(mw.managedSubplots.keys())[0] + return mw.managedSubplots[title]["mdiSubWindow"] + + def _stage0(self): + conf = None + mw = None + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + idxApplications = conf.model.index(1, 0) + # add application + conf.treeView.setMinimumSize(QSize(300,300)) + conf.treeView.scrollTo(idxApplications) + region = conf.treeView.visualRegionForSelection(QItemSelection(idxApplications, idxApplications)) + self.qtbot.mouseMove(conf.treeView.viewport(), region.boundingRect().center(), delay=self.delay) + # mouse click does not trigger context menu :( + #qtbot.mouseClick(conf.treeView.viewport(), Qt.RightButton, pos=region.boundingRect().center()) + QTimer.singleShot(self.delay, lambda: self.activateContextMenu(CM_ADD_APPLICATION)) + conf._execTreeViewContextMenu(region.boundingRect().center()) + app = conf.configuration().applicationByName("application") + # start graph editor + gev = self.startGraphEditor(conf, mw, "application") + self.qtbot.wait(self.delay) + # create a visualization node + pysimpleview = self.addNodeToGraphEditor(gev, QPoint(20,20), + CM_FILTER_LIBRARY, CM_FILTER_LIBRARY_TESTS, + CM_FILTER_LIBRARY_TESTS_NEXXT, CM_FILTER_LIBRARY_PYSIMPLEVIEW) + self.qtbot.keyClick(self.aw(), Qt.Key_Return, delay=self.delay) + self.qtbot.keyClick(self.aw(), Qt.Key_Return, delay=self.delay) + # save the configuration file + QTimer.singleShot(self.delay, lambda: self.enterText(str(self.prjfile))) + conf.actSave.trigger() + self.prjfile_contents = self.prjfile.read_text("utf-8") + assert not self.guistatefile.exists() + # initialize the application, window is shown the first time + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(1000) + self.getMdiWindow().move(QPoint(37, 63)) + self.qtbot.wait(1000) + self.mdigeom = self.getMdiWindow().geometry() + # de-initialize application + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + assert not self.guistatefile.exists() + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def _stage1(self): + conf = None + mw = None + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + idxApplications = conf.model.index(1, 0) + # load recent config + self.qtbot.keyClick(self.aw(), Qt.Key_R, Qt.ControlModifier, delay=self.delay) + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(1000) + assert self.mdigeom == self.getMdiWindow().geometry() + # de-initialize application + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def _stage2(self): + conf = None + mw = None + try: + # load last config + mw = Services.getService("MainWindow") + conf = Services.getService("Configuration") + idxApplications = conf.model.index(1, 0) + # load recent config + self.qtbot.keyClick(self.aw(), Qt.Key_R, Qt.ControlModifier, delay=self.delay) + appidx = conf.model.indexOfSubConfig(conf.configuration().applicationByName("application")) + self.cmContextMenu(conf, appidx, CM_INIT_APP) + self.qtbot.wait(1000) + # should be moved to default location + assert self.mdigeom != self.getMdiWindow().geometry() + self.getMdiWindow().move(QPoint(42, 51)) + self.qtbot.wait(1000) + self.mdigeom = self.getMdiWindow().geometry() + # because the gui state is not correctly saved when an application is active, the action is disabled in + # active state + assert not conf.actSaveWithGuiState.isEnabled() + # de-initialize application + self.qtbot.keyClick(self.aw(), Qt.Key_C, Qt.AltModifier, delay=self.delay) + self.activateContextMenu(CONFIG_MENU_DEINITIALIZE) + self.qtbot.wait(self.delay) + # action should be enabled in non-active state + assert conf.actSaveWithGuiState.isEnabled() + conf.actSaveWithGuiState.trigger() + finally: + if not self.keep_open: + if conf.configuration().dirty(): + QTimer.singleShot(self.delay, self.clickDiscardChanges) + mw.close() + + def test(self): + """ + first start of nexxT in a clean environment, click through a pretty exhaustive scenario. + :return: + """ + # create application and move window to non-default location + QTimer.singleShot(self.delay, self._stage0) + startNexT(None, None, [], [], True) + assert self.guistatefile.exists() + self.guistate_contents = self.guistatefile.read_text("utf-8") + logger.info("guistate_contents: %s", self.guistate_contents) + + QTimer.singleShot(self.delay, self._stage1) + startNexT(None, None, [], [], True) + guistate_contents = self.guistatefile.read_text("utf-8") + logger.info("guistate_contents: %s", guistate_contents) + assert self.guistate_contents == guistate_contents + + # remove gui state -> the window should be placed in default location + os.remove(str(self.guistatefile)) + QTimer.singleShot(self.delay, self._stage2) + startNexT(None, None, [], [], True) + guistate_contents = self.guistatefile.read_text("utf-8") + logger.info("guistate_contents: %s", guistate_contents) + assert self.guistate_contents != guistate_contents + self.guistate_contents = guistate_contents + + QTimer.singleShot(self.delay, self._stage1) + startNexT(None, None, [], [], True) + guistate_contents = self.guistatefile.read_text("utf-8") + logger.info("guistate_contents: %s", guistate_contents) + assert self.guistate_contents == guistate_contents + + # remove gui state -> the window should still be placed in non-default location + os.remove(str(self.guistatefile)) + QTimer.singleShot(self.delay, self._stage1) + startNexT(None, None, [], [], True) + guistate_contents = self.guistatefile.read_text("utf-8") + logger.info("guistate_contents: %s", guistate_contents) + assert self.guistate_contents == guistate_contents + + + +@pytest.mark.gui +@pytest.mark.parametrize("delay", [300]) +def test_guistate(qtbot, xvfb, keep_open, delay, tmpdir): + test = GuiStateTest(qtbot, xvfb, keep_open, delay, tmpdir) + test.test() diff --git a/nexxT/tests/interface/AviFilePlayback.py b/nexxT/tests/interface/AviFilePlayback.py index 4359ae7..7ca40b0 100644 --- a/nexxT/tests/interface/AviFilePlayback.py +++ b/nexxT/tests/interface/AviFilePlayback.py @@ -6,7 +6,7 @@ import logging import time -from PySide2.QtCore import Signal, Slot, QTimer, QDateTime, QUrl +from PySide2.QtCore import Signal, Slot, QTimer, QUrl from PySide2.QtMultimedia import QMediaPlayer, QMediaPlaylist, QAbstractVideoSurface, QVideoFrame from PySide2.QtMultimediaWidgets import QVideoWidget from nexxT.interface import Filter, OutputPort, DataSample, Services @@ -53,8 +53,8 @@ def stop(self): class VideoPlaybackDevice(Filter): playbackStarted = Signal() playbackPaused = Signal() - sequenceOpened = Signal(str, QDateTime, QDateTime, list) - currentTimestampChanged = Signal(QDateTime) + sequenceOpened = Signal(str, qint64, qint64, list) + currentTimestampChanged = Signal(qint64) def __init__(self, environment): super().__init__(False, False, environment) @@ -67,8 +67,8 @@ def __init__(self, environment): def newDuration(self, newDuration): logger.debug("newDuration %s", newDuration) self.sequenceOpened.emit(self.filename, - QDateTime.fromMSecsSinceEpoch(0), - QDateTime.fromMSecsSinceEpoch(newDuration), + 0, + newDuration * 1000000, ["video"]) def currentMediaChanged(self, media): diff --git a/nexxT/tests/interface/test_dataSample.py b/nexxT/tests/interface/test_dataSample.py index c9bba6f..7c5c46b 100644 --- a/nexxT/tests/interface/test_dataSample.py +++ b/nexxT/tests/interface/test_dataSample.py @@ -5,6 +5,10 @@ # import logging +import math +import platform +import time +import pytest from nexxT.interface import DataSample logging.getLogger(__name__).debug("executing test_dataSample.py") @@ -20,5 +24,29 @@ def test_basic(): # but the modification is not affecting the original data assert dataSample.getContent().data() == b'Hello' +@pytest.mark.skipif(platform.system() == "Windows" and platform.release() == "7", + reason="windows 10 or higher, windows 7 seems to have millisecond resolution on timestamps.") +def test_currentTime(): + shortestDelta = math.inf + ts = time.time() + lastT = DataSample.currentTime() + factor = round(DataSample.TIMESTAMP_RES / 1e-9) + deltas = [] + while time.time() - ts < 3: + t = DataSample.currentTime() + # assert that the impementation is consistent with time.time() + deltas.append(abs(t - (time.time_ns() // factor))*DataSample.TIMESTAMP_RES) + if t != lastT: + shortestDelta = min(t - lastT, shortestDelta) + lastT = t + + # make sure that the average delta is smaller than 1 millisecond + assert sum(deltas)/len(deltas) < 1e-3 + shortestDelta = shortestDelta * DataSample.TIMESTAMP_RES + # we want at least 10 microseconds resolution + print("shortestDelta: %s" % shortestDelta) + assert shortestDelta <= 1e-5 + if __name__ == "__main__": - test_basic() \ No newline at end of file + test_basic() + test_currentTime() \ No newline at end of file diff --git a/nexxT/tests/src/AviFilePlayback.cpp b/nexxT/tests/src/AviFilePlayback.cpp index ecb0003..0b98d7c 100644 --- a/nexxT/tests/src/AviFilePlayback.cpp +++ b/nexxT/tests/src/AviFilePlayback.cpp @@ -10,7 +10,6 @@ #include "PropertyCollection.hpp" #include "Logger.hpp" #include "ImageFormat.h" -#include #include #include #include @@ -155,14 +154,14 @@ void VideoPlaybackDevice::newDuration(qint64 duration) { NEXXT_LOG_DEBUG(QString("newDuration %1").arg(duration)); emit sequenceOpened(filename, - QDateTime::fromMSecsSinceEpoch(0, Qt::UTC), - QDateTime::fromMSecsSinceEpoch(duration, Qt::UTC), + 0, + duration*1000000, QStringList() << "video"); } void VideoPlaybackDevice::newPosition(qint64 position) { - emit currentTimestampChanged(QDateTime::fromMSecsSinceEpoch(position, Qt::UTC)); + emit currentTimestampChanged(position*1000000); } void VideoPlaybackDevice::currentMediaChanged(const QMediaContent &) @@ -205,10 +204,10 @@ void VideoPlaybackDevice::seekEnd() if(player) player->setPosition(player->duration()-1); } -void VideoPlaybackDevice::seekTime(const QDateTime &pos) +void VideoPlaybackDevice::seekTime(qint64 pos) { NEXXT_LOG_DEBUG("seekTime called"); - if(player) player->setPosition(pos.toMSecsSinceEpoch()); + if(player) player->setPosition(pos / 1000000); } void VideoPlaybackDevice::setSequence(const QString &_filename) diff --git a/nexxT/tests/src/AviFilePlayback.hpp b/nexxT/tests/src/AviFilePlayback.hpp index 1ae2d4d..e53a9b1 100644 --- a/nexxT/tests/src/AviFilePlayback.hpp +++ b/nexxT/tests/src/AviFilePlayback.hpp @@ -41,10 +41,10 @@ class VideoPlaybackDevice : public Filter void playbackStarted(); void playbackPaused(); void sequenceOpened(const QString &file, - const QDateTime &begin, - const QDateTime &end, + const qint64 begin, + const qint64 end, const QStringList &streams); - void currentTimestampChanged(const QDateTime &); + void currentTimestampChanged(qint64); void timeRatioChanged(double); public slots: @@ -61,7 +61,7 @@ public slots: void stepForward(const QString &stream); void seekBeginning(); void seekEnd(); - void seekTime(const QDateTime &pos); + void seekTime(qint64); void setSequence(const QString &_filename); void setTimeFactor(double factor); protected: diff --git a/setup.py b/setup.py index dbcce1a..8a93aeb 100644 --- a/setup.py +++ b/setup.py @@ -146,7 +146,7 @@ def get_option_dict(self, k): "PySide2==5.14.2.3", "shiboken2==5.14.2.3", "jsonschema>=3.2.0", - "h5py>=2.10.0,<3.0.0", + "h5py>=2.10.0", "setuptools>=41.0.0", 'importlib-metadata >= 1.0 ; python_version < "3.8"', "pip-licenses",