From 8ef0e71b51930c12055425a8cb80bc8a754d7cfb Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Wed, 6 Jan 2021 10:55:19 +0100 Subject: [PATCH 01/31] change help menu, reference the readthedocs help --- nexxT/services/gui/MainWindow.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/nexxT/services/gui/MainWindow.py b/nexxT/services/gui/MainWindow.py index 1f2418c..8102231 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 @@ -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.

From 80928d5a23fedd4e9abf82cf4e1dc981722ddfc9 Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Wed, 6 Jan 2021 14:18:19 +0100 Subject: [PATCH 02/31] test and fixes for the guistate features - fixes for saveWithGuiState(...) and other guistate-related features - fix for window geometry sometimes not correctly restored (zero-size) --- nexxT/core/ConfigFiles.py | 3 +- nexxT/services/gui/Configuration.py | 4 +- nexxT/services/gui/MainWindow.py | 5 +- nexxT/services/gui/PlaybackControl.py | 11 +- nexxT/services/gui/RecordingControl.py | 11 +- nexxT/tests/integration/test_gui.py | 213 ++++++++++++++++++++++--- 6 files changed, 219 insertions(+), 28 deletions(-) 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/services/gui/Configuration.py b/nexxT/services/gui/Configuration.py index 625d2e7..2fe58d0 100644 --- a/nexxT/services/gui/Configuration.py +++ b/nexxT/services/gui/Configuration.py @@ -401,10 +401,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 8102231..3aaa36c 100644 --- a/nexxT/services/gui/MainWindow.py +++ b/nexxT/services/gui/MainWindow.py @@ -365,7 +365,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..1e86069 100644 --- a/nexxT/services/gui/PlaybackControl.py +++ b/nexxT/services/gui/PlaybackControl.py @@ -393,6 +393,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 +406,7 @@ def saveState(self): :return: """ assertMainThread() + self._defineProperties() propertyCollection = self.config.guiState() showAllFiles = self.actShowAllFiles.isChecked() folder = self.browser.folder() @@ -420,16 +427,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("|"): 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/test_gui.py b/nexxT/tests/integration/test_gui.py index 1560a1b..6216d62 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) @@ -563,9 +573,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 +591,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 +661,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 +703,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) @@ -885,7 +888,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 +899,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() From 0da1117b7b490eb538e1050809ab170ea4c29022 Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Sat, 9 Jan 2021 12:39:42 +0100 Subject: [PATCH 03/31] first step of player redesign - add proper support for multiple players in one application (only one player is active at the same time) - disable not-supported file types in recent files (only possible in playback menu) --- nexxT/services/SrvPlaybackControl.py | 209 +++++++++++++++++++------- nexxT/services/gui/PlaybackControl.py | 27 ++-- nexxT/tests/integration/test_gui.py | 7 +- 3 files changed, 176 insertions(+), 67 deletions(-) diff --git a/nexxT/services/SrvPlaybackControl.py b/nexxT/services/SrvPlaybackControl.py index a4cde17..475fed4 100644 --- a/nexxT/services/SrvPlaybackControl.py +++ b/nexxT/services/SrvPlaybackControl.py @@ -18,6 +18,121 @@ 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): + if self._controlsFile: + self._startPlayback.emit() + + def pausePlayback(self): + if self._controlsFile: + self._pausePlayback.emit() + + def stepForward(self, stream): + if self._controlsFile: + self._stepForward.emit(stream) + + def stepBackward(self, stream): + if self._controlsFile: + self._stepBackward.emit(stream) + + def seekBeginning(self): + if self._controlsFile: + self._seekBeginning.emit() + + def seekEnd(self): + if self._controlsFile: + self._seekEnd.emit() + + def seekTime(self, qdatetime): + if self._controlsFile: + self._seekTime.emit(qdatetime) + + def setSequence(self, filename): + 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): + if self._controlsFile: + self._setTimeFactor.emit(factor) + + def hasControl(self): + return self._controlsFile + + def featureSet(self): + return self._featureSet + + _startPlayback = Signal() + _pausePlayback = Signal() + _stepForward = Signal(str) + _stepBackward = Signal(str) + _seekBeginning = Signal() + _seekEnd = Signal() + _seekTime = Signal(QDateTime) + _setSequence = Signal(object) + _setTimeFactor = Signal(float) + sequenceOpened = Signal(str, QDateTime, QDateTime, object) + currentTimestampChanged = Signal(QDateTime) + 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 @@ -30,7 +145,7 @@ class MVCPlaybackControlBase(QObject): _seekBeginning = Signal() _seekEnd = Signal() _seekTime = Signal(QDateTime) - _setSequence = Signal(str) + _setSequence = Signal(object) _setTimeFactor = Signal(float) def __init__(self): @@ -38,6 +153,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,8 +166,8 @@ 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) @@ -76,59 +192,55 @@ 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) + @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 devId, 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 + @Slot(QObject) def removeConnections(self, playbackDevice): """ @@ -145,8 +257,6 @@ 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)) @@ -156,18 +266,9 @@ 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): diff --git a/nexxT/services/gui/PlaybackControl.py b/nexxT/services/gui/PlaybackControl.py index 1e86069..029104a 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, QDateTime, 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) @@ -280,7 +286,7 @@ def onSliderValueChanged(self, value): return if self.actStart.isEnabled(): ts = QDateTime.fromMSecsSinceEpoch(self.beginTime.toMSecsSinceEpoch() + value, self.beginTime.timeSpec()) - self._seekTime.emit(ts) + self.seekTime(ts) else: logger.warning("Can't seek while playing.") @@ -357,7 +363,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): """ @@ -442,6 +448,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/tests/integration/test_gui.py b/nexxT/tests/integration/test_gui.py index 6216d62..d36aafd 100644 --- a/nexxT/tests/integration/test_gui.py +++ b/nexxT/tests/integration/test_gui.py @@ -353,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 @@ -365,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): """ @@ -749,7 +750,7 @@ 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"]) finally: if not self.keep_open: if conf.configuration().dirty(): From 6e2adc71af3cf9d3a17d62e3bde33eebe4239f5d Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Sun, 10 Jan 2021 12:24:18 +0100 Subject: [PATCH 04/31] refactor hdf5 reader - split it into a generic part (useful for all file formats) and a hdf5 specific part - pylint cleanups --- nexxT/core/Exceptions.py | 6 + nexxT/core/FilterMockup.py | 2 - nexxT/core/PortImpl.py | 2 + nexxT/core/SubConfiguration.py | 3 +- nexxT/core/Utils.py | 57 ++-- nexxT/examples/__init__.py | 2 +- nexxT/filters/GenericReader.py | 432 +++++++++++++++++++++++++ nexxT/filters/hdf5.py | 338 +++---------------- nexxT/interface/PropertyCollections.py | 2 +- nexxT/services/SrvPlaybackControl.py | 92 ++++-- nexxT/services/gui/BrowserWidget.py | 2 + nexxT/services/gui/Configuration.py | 3 +- nexxT/services/gui/MainWindow.py | 3 +- 13 files changed, 595 insertions(+), 349 deletions(-) create mode 100644 nexxT/filters/GenericReader.py 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/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..0d69daf --- /dev/null +++ b/nexxT/filters/GenericReader.py @@ -0,0 +1,432 @@ +# 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, QDateTime, 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. + + See :py:class:`nexxT.filters.hdf5.Hdf5File` 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, QDateTime, QDateTime, list) + currentTimestampChanged = Signal(QDateTime) + 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, tgtDatetime): + """ + slot called to go to the specified time + + :param tgtDatetime: a QDateTime instance + :return: + """ + t = (tgtDatetime.toMSecsSinceEpoch()*self._file.getTimestampResolution())//1000 + 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 + 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 + 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 (QDateTime.fromMSecsSinceEpoch((tmin*1000)//self._file.getTimestampResolution()), + QDateTime.fromMSecsSinceEpoch((tmax*1000)//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 + sample = DataSample(content, dataType, int(dataTimestamp/(DataSample.TIMESTAMP_RES * + self._file.getTimestampResolution()))) + 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// + 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..3811d79 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,85 @@ 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] + 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 475fed4..9a2444f 100644 --- a/nexxT/services/SrvPlaybackControl.py +++ b/nexxT/services/SrvPlaybackControl.py @@ -54,47 +54,90 @@ def __init__(self, playbackControl, playbackDevice, nameFilters): 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, qdatetime): + """ + Proxy function, checks whether this proxy has control and emits the signal if necessary + + :param qdatetime: a QDateTime instance + """ if self._controlsFile: self._seekTime.emit(qdatetime) 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() @@ -135,8 +178,9 @@ def _timeRatioChanged(self, 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() @@ -216,6 +260,27 @@ def setupConnections(self, playbackDevice, nameFilters): self._deviceId += 1 MethodInvoker(dict(object=self, method="_updateFeatureSet", thread=mainThread()), Qt.QueuedConnection) + @Slot(QObject) + def removeConnections(self, playbackDevice): + """ + unregisters the given playbackDevice and disconnects all. It is intended that this function is called in the + onClose(...) method of a filter. + + :param playbackDevice: the playback device to be unregistered. + :return: None + """ + with QMutexLocker(self._mutex): + found = [] + for devid in self._registeredDevices: + if self._registeredDevices[devid]["object"] is playbackDevice: + found.append(devid) + if len(found) > 0: + for devid in found: + 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() @@ -230,7 +295,7 @@ def _stopSetSequenceStart(self, filename): if state == FilterState.ACTIVE: Application.activeApplication.stop() assert Application.activeApplication.getState() == FilterState.OPENED - for devId, spec in self._registeredDevices.items(): + for _, spec in self._registeredDevices.items(): spec["proxy"].setSequence(filename) # only one filter will get the playback control if spec["proxy"].hasControl(): @@ -241,27 +306,6 @@ def _stopSetSequenceStart(self, filename): Application.activeApplication.start() assert Application.activeApplication.getState() == FilterState.ACTIVE - @Slot(QObject) - def removeConnections(self, playbackDevice): - """ - unregisters the given playbackDevice and disconnects all. It is intended that this function is called in the - onClose(...) method of a filter. - - :param playbackDevice: the playback device to be unregistered. - :return: None - """ - with QMutexLocker(self._mutex): - found = [] - for devid in self._registeredDevices: - if self._registeredDevices[devid]["object"] is playbackDevice: - found.append(devid) - if len(found) > 0: - for devid in found: - 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) - def _updateFeatureSet(self): assertMainThread() featureset = set() 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 2fe58d0..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)), diff --git a/nexxT/services/gui/MainWindow.py b/nexxT/services/gui/MainWindow.py index 3aaa36c..8351e82 100644 --- a/nexxT/services/gui/MainWindow.py +++ b/nexxT/services/gui/MainWindow.py @@ -160,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): @@ -241,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): From eb3c7ae02b347619cb14e8199be86ad739e7ef87 Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Sun, 10 Jan 2021 14:52:37 +0100 Subject: [PATCH 05/31] add a test case for DataSample::currentTime --- nexxT/tests/interface/test_dataSample.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/nexxT/tests/interface/test_dataSample.py b/nexxT/tests/interface/test_dataSample.py index c9bba6f..7593ea0 100644 --- a/nexxT/tests/interface/test_dataSample.py +++ b/nexxT/tests/interface/test_dataSample.py @@ -5,6 +5,8 @@ # import logging +import math +import time from nexxT.interface import DataSample logging.getLogger(__name__).debug("executing test_dataSample.py") @@ -20,5 +22,23 @@ def test_basic(): # but the modification is not affecting the original data assert dataSample.getContent().data() == b'Hello' +def test_currentTime(): + shortestDelta = math.inf + ts = time.time() + lastT = DataSample.currentTime() + factor = round(DataSample.TIMESTAMP_RES / 1e-9) + while time.time() - ts < 3: + t = DataSample.currentTime() + # assert that the impementation is consistent with time.time() + assert abs(t - (time.time_ns() // factor))*DataSample.TIMESTAMP_RES < 1e-3 + if t != lastT: + shortestDelta = min(t - lastT, shortestDelta) + lastT = t + 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 From b5a056372ac1da0298efc7ecaed9b58f3f71e6fc Mon Sep 17 00:00:00 2001 From: pfrydlewicz <30785616+pfrydlewicz@users.noreply.github.com> Date: Mon, 1 Mar 2021 16:46:43 +0100 Subject: [PATCH 06/31] Update tutorial.rst --- doc/manual/tutorial.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/manual/tutorial.rst b/doc/manual/tutorial.rst index f80633e..8c1f77c 100644 --- a/doc/manual/tutorial.rst +++ b/doc/manual/tutorial.rst @@ -306,3 +306,8 @@ For being able to announce the C++ filters, the plugin needs to be defined. This .. literalinclude:: ../../nexxT/tests/src/Plugins.cpp :language: c +Debugging ++++++++++ + +Visual Studio Code +^^^^^^^^^^^^^^^^^^ From ce575d04a1760638a18e264771759a9d929fa755 Mon Sep 17 00:00:00 2001 From: pfrydlewicz <30785616+pfrydlewicz@users.noreply.github.com> Date: Mon, 1 Mar 2021 16:51:11 +0100 Subject: [PATCH 07/31] Update tutorial.rst --- doc/manual/tutorial.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/manual/tutorial.rst b/doc/manual/tutorial.rst index 8c1f77c..a46d638 100644 --- a/doc/manual/tutorial.rst +++ b/doc/manual/tutorial.rst @@ -311,3 +311,4 @@ Debugging Visual Studio Code ^^^^^^^^^^^^^^^^^^ +To start with VS Code make sure the Python extension for VS Code is installed (`see here `_). From b6fd6a1d0f35660c97a86f64249e3635435c2c60 Mon Sep 17 00:00:00 2001 From: pfrydlewicz <30785616+pfrydlewicz@users.noreply.github.com> Date: Mon, 1 Mar 2021 17:05:12 +0100 Subject: [PATCH 08/31] Update tutorial.rst --- doc/manual/tutorial.rst | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/doc/manual/tutorial.rst b/doc/manual/tutorial.rst index a46d638..915199b 100644 --- a/doc/manual/tutorial.rst +++ b/doc/manual/tutorial.rst @@ -312,3 +312,29 @@ Debugging 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 .`. +First we need to tell VS Code which python interpreter to use. +Open the settings.json file in your .vscode directory (or create it) and add following variable definition: :code:`"python.pythonPath": "/folder/of/to/python/interpreter/python.exe" +The path should be an absolute path. + +If the interpreter is located in a virtual environment, VS Code will recognize it and activate it automatically. +Important: Make sure at least one .py file is opened in the editor, otherwise the venv will not be activated. + +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:` +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Modul", + "type": "python", + "request": "launch", + "module": "nexxT.core.AppConsole", + "justMyCode": false + } + ] +} +` +The module information 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. From 1dc85bb0dd0d97d0bb70892287b0ec6e5e4f658f Mon Sep 17 00:00:00 2001 From: pfrydlewicz <30785616+pfrydlewicz@users.noreply.github.com> Date: Mon, 1 Mar 2021 17:10:29 +0100 Subject: [PATCH 09/31] Update tutorial.rst --- doc/manual/tutorial.rst | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/doc/manual/tutorial.rst b/doc/manual/tutorial.rst index 915199b..001bc1a 100644 --- a/doc/manual/tutorial.rst +++ b/doc/manual/tutorial.rst @@ -313,28 +313,28 @@ 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 .`. +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). First we need to tell VS Code which python interpreter to use. -Open the settings.json file in your .vscode directory (or create it) and add following variable definition: :code:`"python.pythonPath": "/folder/of/to/python/interpreter/python.exe" -The path should be an absolute path. +Open the settings.json file in your .vscode directory (or create it) and add following variable definition: :code:`"python.pythonPath": "/folder/of/to/python/interpreter/python.exe"`. The path should be an absolute path. If the interpreter is located in a virtual environment, VS Code will recognize it and activate it automatically. -Important: Make sure at least one .py file is opened in the editor, otherwise the venv will not be activated. +Important: Make sure that at least one .py file is opened in the editor when starting debugging, otherwise the venv will not be activated. +Btw, with these settings at hand, the venv will also be started automatically when we open a new terminal ("Terminal/New Terminal"). 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:` -{ - "version": "0.2.0", - "configurations": [ - { - "name": "Python: Modul", - "type": "python", - "request": "launch", - "module": "nexxT.core.AppConsole", - "justMyCode": false - } - ] -} -` +.. code-block: JSON + { + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Modul", + "type": "python", + "request": "launch", + "module": "nexxT.core.AppConsole", + "justMyCode": false + } + ] + } + The module information 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. From 3d8419de4113bb1bf5228191ed5829cb161b50da Mon Sep 17 00:00:00 2001 From: pfrydlewicz <30785616+pfrydlewicz@users.noreply.github.com> Date: Mon, 1 Mar 2021 17:11:02 +0100 Subject: [PATCH 10/31] Update tutorial.rst --- doc/manual/tutorial.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/manual/tutorial.rst b/doc/manual/tutorial.rst index 001bc1a..c57b714 100644 --- a/doc/manual/tutorial.rst +++ b/doc/manual/tutorial.rst @@ -322,7 +322,7 @@ Important: Make sure that at least one .py file is opened in the editor when sta Btw, with these settings at hand, the venv will also be started automatically when we open a new terminal ("Terminal/New Terminal"). 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 +.. code-block:: JSON { "version": "0.2.0", "configurations": [ From ec0d5a6658867519657929e2dbca67726a7b4f70 Mon Sep 17 00:00:00 2001 From: pfrydlewicz <30785616+pfrydlewicz@users.noreply.github.com> Date: Mon, 1 Mar 2021 17:11:25 +0100 Subject: [PATCH 11/31] Update tutorial.rst --- doc/manual/tutorial.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/manual/tutorial.rst b/doc/manual/tutorial.rst index c57b714..3f2ee01 100644 --- a/doc/manual/tutorial.rst +++ b/doc/manual/tutorial.rst @@ -322,6 +322,7 @@ Important: Make sure that at least one .py file is opened in the editor when sta Btw, with these settings at hand, the venv will also be started automatically when we open a new terminal ("Terminal/New Terminal"). 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", From 969c3987927354bf8970b78844abfc14624dade1 Mon Sep 17 00:00:00 2001 From: pfrydlewicz <30785616+pfrydlewicz@users.noreply.github.com> Date: Mon, 1 Mar 2021 17:13:09 +0100 Subject: [PATCH 12/31] Update conf.py --- doc/manual/conf.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/doc/manual/conf.py b/doc/manual/conf.py index b9a0296..993112a 100644 --- a/doc/manual/conf.py +++ b/doc/manual/conf.py @@ -73,8 +73,7 @@ exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', 'manual'] # The name of the Pygments (syntax highlighting) style to use. -pygments_style = None - +pygments_style = 'sphinx' # -- Options for HTML output ------------------------------------------------- From 17f6f84ea993538377c9f43c30bee1a0d5f0f510 Mon Sep 17 00:00:00 2001 From: pfrydlewicz <30785616+pfrydlewicz@users.noreply.github.com> Date: Mon, 1 Mar 2021 17:13:40 +0100 Subject: [PATCH 13/31] Update tutorial.rst --- doc/manual/tutorial.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/manual/tutorial.rst b/doc/manual/tutorial.rst index 3f2ee01..9317247 100644 --- a/doc/manual/tutorial.rst +++ b/doc/manual/tutorial.rst @@ -323,7 +323,7 @@ Btw, with these settings at hand, the venv will also be started automatically wh 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 +.. code-block:: { "version": "0.2.0", "configurations": [ From 7964732b0bfa192b102c5e3342fc5f2cbf27af33 Mon Sep 17 00:00:00 2001 From: pfrydlewicz <30785616+pfrydlewicz@users.noreply.github.com> Date: Mon, 1 Mar 2021 17:20:42 +0100 Subject: [PATCH 14/31] Update tutorial.rst --- doc/manual/tutorial.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/manual/tutorial.rst b/doc/manual/tutorial.rst index 9317247..4f167d5 100644 --- a/doc/manual/tutorial.rst +++ b/doc/manual/tutorial.rst @@ -323,7 +323,8 @@ Btw, with these settings at hand, the venv will also be started automatically wh 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:: +.. code-block:: JSON + { "version": "0.2.0", "configurations": [ From efb8711e0f9d6c2d120c0e878221f1f31cb30cb1 Mon Sep 17 00:00:00 2001 From: pfrydlewicz <30785616+pfrydlewicz@users.noreply.github.com> Date: Mon, 1 Mar 2021 17:23:31 +0100 Subject: [PATCH 15/31] Update tutorial.rst --- doc/manual/tutorial.rst | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/doc/manual/tutorial.rst b/doc/manual/tutorial.rst index 4f167d5..18bd309 100644 --- a/doc/manual/tutorial.rst +++ b/doc/manual/tutorial.rst @@ -309,13 +309,21 @@ For being able to announce the C++ filters, the plugin needs to be defined. This Debugging +++++++++ -Visual Studio Code -^^^^^^^^^^^^^^^^^^ +Python 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). First we need to tell VS Code which python interpreter to use. -Open the settings.json file in your .vscode directory (or create it) and add following variable definition: :code:`"python.pythonPath": "/folder/of/to/python/interpreter/python.exe"`. The path should be an absolute path. +Open the settings.json file in your .vscode directory (or create it) and add following variable definition: + +.. code-block:: JSON + + { + "python.pythonPath": "/folder/of/to/python/interpreter/python.exe" + } + +The path should be an absolute path. If the interpreter is located in a virtual environment, VS Code will recognize it and activate it automatically. Important: Make sure that at least one .py file is opened in the editor when starting debugging, otherwise the venv will not be activated. @@ -338,5 +346,5 @@ Next step is to create the launch.json file for our debug session (manually or v ] } -The module information 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. +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. From 583753e05de9f38da20ef696da2302d0c1fbe294 Mon Sep 17 00:00:00 2001 From: pfrydlewicz <30785616+pfrydlewicz@users.noreply.github.com> Date: Mon, 1 Mar 2021 17:30:12 +0100 Subject: [PATCH 16/31] Update tutorial.rst --- doc/manual/tutorial.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/manual/tutorial.rst b/doc/manual/tutorial.rst index 18bd309..593b40a 100644 --- a/doc/manual/tutorial.rst +++ b/doc/manual/tutorial.rst @@ -348,3 +348,5 @@ Next step is to create the launch.json file for our debug session (manually or v 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. From 8445a7bea12cf4e12b384a17829f98d39f3a634e Mon Sep 17 00:00:00 2001 From: pfrydlewicz <30785616+pfrydlewicz@users.noreply.github.com> Date: Mon, 1 Mar 2021 17:33:59 +0100 Subject: [PATCH 17/31] Update tutorial.rst --- doc/manual/tutorial.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/manual/tutorial.rst b/doc/manual/tutorial.rst index 593b40a..0d9cb58 100644 --- a/doc/manual/tutorial.rst +++ b/doc/manual/tutorial.rst @@ -309,7 +309,7 @@ For being able to announce the C++ filters, the plugin needs to be defined. This Debugging +++++++++ -Python with Visual Studio Code +Python debugging with Visual Studio Code ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ To start with VS Code make sure the Python extension for VS Code is installed (`see here `_). From e566a823701796e7cd3929a977396dccf9354590 Mon Sep 17 00:00:00 2001 From: pfrydlewicz <30785616+pfrydlewicz@users.noreply.github.com> Date: Mon, 1 Mar 2021 17:40:42 +0100 Subject: [PATCH 18/31] Update tutorial.rst --- doc/manual/tutorial.rst | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/doc/manual/tutorial.rst b/doc/manual/tutorial.rst index 0d9cb58..25597ed 100644 --- a/doc/manual/tutorial.rst +++ b/doc/manual/tutorial.rst @@ -310,11 +310,14 @@ Debugging +++++++++ 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). -First we need to tell VS Code which python interpreter to use. + +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 tell VS Code which python interpreter to use. Use the python executable in that particular venv. Open the settings.json file in your .vscode directory (or create it) and add following variable definition: .. code-block:: JSON @@ -329,7 +332,9 @@ If the interpreter is located in a virtual environment, VS Code will recognize i Important: Make sure that at least one .py file is opened in the editor when starting debugging, otherwise the venv will not be activated. Btw, with these settings at hand, the venv will also be started automatically when we open a new terminal ("Terminal/New Terminal"). -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: +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 From 561e8cde6b036da7f7fc4bde6a5984c6e8f46549 Mon Sep 17 00:00:00 2001 From: pfrydlewicz <30785616+pfrydlewicz@users.noreply.github.com> Date: Mon, 1 Mar 2021 17:46:51 +0100 Subject: [PATCH 19/31] Update tutorial.rst --- doc/manual/tutorial.rst | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/doc/manual/tutorial.rst b/doc/manual/tutorial.rst index 25597ed..685fd25 100644 --- a/doc/manual/tutorial.rst +++ b/doc/manual/tutorial.rst @@ -317,20 +317,21 @@ Open VS Code in your source code directory via menu ("File/Open Folder") or cd i 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 tell VS Code which python interpreter to use. Use the python executable in that particular venv. -Open the settings.json file in your .vscode directory (or create it) and add following variable definition: +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": "/folder/of/to/python/interpreter/python.exe" + "python.pythonPath": "/path/to/your/python/interpreter/python.exe", + "python.venvPath": "/path/to/your/venv", + "python.terminal.activateEnvironment": true } -The path should be an absolute path. +The paths should be an absolute path. If :code:`"python.pythonPath"` is inside your venv, :code:`"python.venvPath"` is not required, VS Code will recognize it and activate venv automatically. +Important: 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). -If the interpreter is located in a virtual environment, VS Code will recognize it and activate it automatically. -Important: Make sure that at least one .py file is opened in the editor when starting debugging, otherwise the venv will not be activated. -Btw, with these settings at hand, the venv will also be started automatically when we open a new terminal ("Terminal/New Terminal"). +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 *********************** From 67687be3828327c542d08a84a32a62cbda552a60 Mon Sep 17 00:00:00 2001 From: pfrydlewicz <30785616+pfrydlewicz@users.noreply.github.com> Date: Mon, 1 Mar 2021 17:47:49 +0100 Subject: [PATCH 20/31] Update tutorial.rst --- doc/manual/tutorial.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/manual/tutorial.rst b/doc/manual/tutorial.rst index 685fd25..50c7ddb 100644 --- a/doc/manual/tutorial.rst +++ b/doc/manual/tutorial.rst @@ -329,7 +329,7 @@ Open the settings.json file in your .vscode directory (or create it). Your setti } The paths should be an absolute path. If :code:`"python.pythonPath"` is inside your venv, :code:`"python.venvPath"` is not required, VS Code will recognize it and activate venv automatically. -Important: 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). +Important: 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"). From 9e519c2dc7c595c11f7806c417032f44ffd023e3 Mon Sep 17 00:00:00 2001 From: pfrydlewicz <30785616+pfrydlewicz@users.noreply.github.com> Date: Mon, 1 Mar 2021 17:48:18 +0100 Subject: [PATCH 21/31] Update tutorial.rst --- doc/manual/tutorial.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/manual/tutorial.rst b/doc/manual/tutorial.rst index 50c7ddb..b75dbe6 100644 --- a/doc/manual/tutorial.rst +++ b/doc/manual/tutorial.rst @@ -328,7 +328,7 @@ Open the settings.json file in your .vscode directory (or create it). Your setti "python.terminal.activateEnvironment": true } -The paths should be an absolute path. If :code:`"python.pythonPath"` is inside your venv, :code:`"python.venvPath"` is not required, VS Code will recognize it and activate venv automatically. +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. Important: 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"). From cb43c02e23116d977950b7fe0405e3ee4e419f02 Mon Sep 17 00:00:00 2001 From: pfrydlewicz <30785616+pfrydlewicz@users.noreply.github.com> Date: Mon, 1 Mar 2021 17:49:43 +0100 Subject: [PATCH 22/31] Update tutorial.rst --- doc/manual/tutorial.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/manual/tutorial.rst b/doc/manual/tutorial.rst index b75dbe6..f78a8b3 100644 --- a/doc/manual/tutorial.rst +++ b/doc/manual/tutorial.rst @@ -329,7 +329,8 @@ Open the settings.json file in your .vscode directory (or create it). Your setti } 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. -Important: 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). + +**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"). From 658adaabe9113f4a3f5e71f92530007c328b7fa1 Mon Sep 17 00:00:00 2001 From: pfrydlewicz <30785616+pfrydlewicz@users.noreply.github.com> Date: Thu, 4 Mar 2021 15:05:50 +0100 Subject: [PATCH 23/31] Update conf.py --- doc/manual/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/manual/conf.py b/doc/manual/conf.py index 993112a..c28ec3f 100644 --- a/doc/manual/conf.py +++ b/doc/manual/conf.py @@ -73,7 +73,7 @@ exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', 'manual'] # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = 'None' # -- Options for HTML output ------------------------------------------------- From 65c26d5106caa775c2af1727c9cb7cb15fa14ebe Mon Sep 17 00:00:00 2001 From: pfrydlewicz <30785616+pfrydlewicz@users.noreply.github.com> Date: Thu, 4 Mar 2021 15:10:23 +0100 Subject: [PATCH 24/31] Update tutorial.rst --- doc/manual/tutorial.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/manual/tutorial.rst b/doc/manual/tutorial.rst index f78a8b3..8975327 100644 --- a/doc/manual/tutorial.rst +++ b/doc/manual/tutorial.rst @@ -308,6 +308,7 @@ For being able to announce the C++ filters, the plugin needs to be defined. This 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 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ From 33616dbeee448c59201aa3009d637fe6b8d2b39c Mon Sep 17 00:00:00 2001 From: pfrydlewicz <30785616+pfrydlewicz@users.noreply.github.com> Date: Thu, 4 Mar 2021 18:10:27 +0100 Subject: [PATCH 25/31] Update conf.py --- doc/manual/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/manual/conf.py b/doc/manual/conf.py index c28ec3f..0875ccf 100644 --- a/doc/manual/conf.py +++ b/doc/manual/conf.py @@ -73,7 +73,7 @@ exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', 'manual'] # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'None' +pygments_style = None # -- Options for HTML output ------------------------------------------------- From cef763f18f10bfd316ef119c4d5359063e9fb80f Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Sat, 6 Mar 2021 10:17:59 +0100 Subject: [PATCH 26/31] update docs for GenericReader, test for correct return value of openFile, fix gui test case --- nexxT/filters/GenericReader.py | 11 ++++++++++- nexxT/tests/integration/test_gui.py | 4 +++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/nexxT/filters/GenericReader.py b/nexxT/filters/GenericReader.py index 0d69daf..c3040f9 100644 --- a/nexxT/filters/GenericReader.py +++ b/nexxT/filters/GenericReader.py @@ -22,7 +22,12 @@ class GenericReaderFile: """ Interface for adaptations of new file formats. - See :py:class:`nexxT.filters.hdf5.Hdf5File` for an example. + 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 @@ -281,6 +286,8 @@ def onStart(self): """ 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))) self._portToIdx = {} self._ports = self.getDynamicOutputPorts() for s in self._file.allStreams(): @@ -339,6 +346,8 @@ def onSuggestDynamicPorts(self): 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") diff --git a/nexxT/tests/integration/test_gui.py b/nexxT/tests/integration/test_gui.py index d36aafd..6542752 100644 --- a/nexxT/tests/integration/test_gui.py +++ b/nexxT/tests/integration/test_gui.py @@ -750,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, ignore = ["did not find a playback device taking control"]) + 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(): From 8ca11d42ee7dcd68cd2215db5581955308015987 Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Sat, 6 Mar 2021 12:20:00 +0100 Subject: [PATCH 27/31] support h5py >= 3.x - avoid unloading h5py python modules when switching between configurations - adapt to new string handling in h5py 3.x (2.x is still supported) - add new environment variable MEXXT_BLACKLISTED_PACKAGES --- nexxT/core/AppConsole.py | 4 ++++ nexxT/core/PluginManager.py | 28 ++++++++++++++++++++++++++-- nexxT/filters/hdf5.py | 3 +++ setup.py | 2 +- 4 files changed, 34 insertions(+), 3 deletions(-) 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/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/filters/hdf5.py b/nexxT/filters/hdf5.py index 3811d79..b2aa51c 100644 --- a/nexxT/filters/hdf5.py +++ b/nexxT/filters/hdf5.py @@ -259,6 +259,9 @@ def readSample(self, stream, streamIdx): :return: (content: QByteArray, dataType: str, dataTimestamp: int, receiveTimestamp: int) """ 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 def getRcvTimestamp(self, stream, streamIdx): 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", From 3ca183b26dab82508d524e9fb8ca66621421414b Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Sun, 7 Mar 2021 14:46:14 +0100 Subject: [PATCH 28/31] playback service interface change: replace QDateTime with qint64 (nanoseconds) in interface functions --- nexxT/filters/GenericReader.py | 41 +++++++++++++------ nexxT/services/SrvPlaybackControl.py | 32 +++++++-------- nexxT/services/gui/PlaybackControl.py | 32 +++++++++++---- .../tests/integration/basicworkflow_script.py | 11 ++--- nexxT/tests/interface/AviFilePlayback.py | 10 ++--- nexxT/tests/src/AviFilePlayback.cpp | 11 +++-- nexxT/tests/src/AviFilePlayback.hpp | 8 ++-- 7 files changed, 86 insertions(+), 59 deletions(-) diff --git a/nexxT/filters/GenericReader.py b/nexxT/filters/GenericReader.py index c3040f9..718b5e1 100644 --- a/nexxT/filters/GenericReader.py +++ b/nexxT/filters/GenericReader.py @@ -11,7 +11,7 @@ import time import logging import math -from PySide2.QtCore import Signal, QDateTime, QTimer +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 @@ -99,8 +99,8 @@ class GenericReader(Filter): # signals for playback device playbackStarted = Signal() playbackPaused = Signal() - sequenceOpened = Signal(str, QDateTime, QDateTime, list) - currentTimestampChanged = Signal(QDateTime) + sequenceOpened = Signal(str, 'qint64', 'qint64', list) + currentTimestampChanged = Signal('qint64') timeRatioChanged = Signal(float) # methods to be overloaded @@ -197,14 +197,14 @@ def seekEnd(self): self._dir = +1 self._updateCurrentTimestamp() - def seekTime(self, tgtDatetime): + def seekTime(self, timestamp): """ slot called to go to the specified time - :param tgtDatetime: a QDateTime instance + :param timestamp: a timestamp in nanosecond resolution :return: """ - t = (tgtDatetime.toMSecsSinceEpoch()*self._file.getTimestampResolution())//1000 + t = timestamp // (1000000000//self._file.getTimestampResolution()) nValid = 0 for p in self._portToIdx: # binary search @@ -288,6 +288,18 @@ def onStart(self): 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(): @@ -363,8 +375,8 @@ def _timeSpan(self): tmin = min(t, tmin) t = self._file.getRcvTimestamp(p, self._file.getNumberOfSamples(p)-1) tmax = max(t, tmax) - return (QDateTime.fromMSecsSinceEpoch((tmin*1000)//self._file.getTimestampResolution()), - QDateTime.fromMSecsSinceEpoch((tmax*1000)//self._file.getTimestampResolution())) + 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 @@ -414,13 +426,18 @@ def _transmit(self, pname): # read data sample from HDF5 file content, dataType, dataTimestamp, rcvTimestamp = self._file.readSample(pname, idx) # create sample to transmit - sample = DataSample(content, dataType, int(dataTimestamp/(DataSample.TIMESTAMP_RES * - self._file.getTimestampResolution()))) + 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(QDateTime.fromMSecsSinceEpoch(rcvTimestamp*1000// - self._file.getTimestampResolution())) + self._currentTimestampChanged(rcvTimestamp*(1000000000//self._file.getTimestampResolution())) if self._untilStream is not None: if self._untilStream == pname or self._untilStream == '': self.pausePlayback() diff --git a/nexxT/services/SrvPlaybackControl.py b/nexxT/services/SrvPlaybackControl.py index 9a2444f..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 @@ -95,14 +95,14 @@ def seekEnd(self): if self._controlsFile: self._seekEnd.emit() - def seekTime(self, qdatetime): + def seekTime(self, timestamp_ns): """ Proxy function, checks whether this proxy has control and emits the signal if necessary - :param qdatetime: a QDateTime instance + :param timestamp_ns: the timestamp in nanoseconds """ if self._controlsFile: - self._seekTime.emit(qdatetime) + self._seekTime.emit(timestamp_ns) def setSequence(self, filename): """ @@ -146,11 +146,11 @@ def featureSet(self): _stepBackward = Signal(str) _seekBeginning = Signal() _seekEnd = Signal() - _seekTime = Signal(QDateTime) + _seekTime = Signal('qint64') _setSequence = Signal(object) _setTimeFactor = Signal(float) - 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) @@ -188,7 +188,7 @@ class MVCPlaybackControlBase(QObject): _stepBackward = Signal(str) _seekBeginning = Signal() _seekEnd = Signal() - _seekTime = Signal(QDateTime) + _seekTime = Signal('qint64') _setSequence = Signal(object) _setTimeFactor = Signal(float) @@ -215,14 +215,14 @@ def setupConnections(self, playbackDevice, nameFilters): - 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) @@ -372,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) @@ -433,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/PlaybackControl.py b/nexxT/services/gui/PlaybackControl.py index 029104a..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, QDir +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) @@ -229,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) @@ -254,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 @@ -265,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): @@ -285,7 +299,7 @@ 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()) + ts = self.beginTime + value * 1000000 self.seekTime(ts) else: logger.warning("Can't seek while playing.") @@ -301,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): """ 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/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/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: From 8693d48fb7d6ca5b65af12cdbd557f4c80e618f4 Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Sun, 7 Mar 2021 15:14:18 +0100 Subject: [PATCH 29/31] fix double references of c++ and command line utilities --- doc/manual/nexxT.rst | 2 -- 1 file changed, 2 deletions(-) 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 --------------- From 64cc68ea225893725c6db470a8b1f72c9d9cfc8f Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Sun, 7 Mar 2021 15:47:25 +0100 Subject: [PATCH 30/31] fix test cases on windows --- nexxT/tests/interface/test_dataSample.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nexxT/tests/interface/test_dataSample.py b/nexxT/tests/interface/test_dataSample.py index 7593ea0..fa0a5b3 100644 --- a/nexxT/tests/interface/test_dataSample.py +++ b/nexxT/tests/interface/test_dataSample.py @@ -27,13 +27,17 @@ def test_currentTime(): 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() - assert abs(t - (time.time_ns() // factor))*DataSample.TIMESTAMP_RES < 1e-3 + 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) From 958eda3d5c258efa0497bfd6e74329054131ff84 Mon Sep 17 00:00:00 2001 From: Christoph Wiedemann <62332054+cwiede@users.noreply.github.com> Date: Sun, 7 Mar 2021 16:23:27 +0100 Subject: [PATCH 31/31] fix test cases on windows --- nexxT/tests/interface/test_dataSample.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nexxT/tests/interface/test_dataSample.py b/nexxT/tests/interface/test_dataSample.py index fa0a5b3..7c5c46b 100644 --- a/nexxT/tests/interface/test_dataSample.py +++ b/nexxT/tests/interface/test_dataSample.py @@ -6,7 +6,9 @@ import logging import math +import platform import time +import pytest from nexxT.interface import DataSample logging.getLogger(__name__).debug("executing test_dataSample.py") @@ -22,6 +24,8 @@ 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()