From 0a65b9b4773293784387be4aa3763b59a074d27c Mon Sep 17 00:00:00 2001 From: Roy Shilkrot Date: Fri, 13 Sep 2024 17:24:21 -0400 Subject: [PATCH 1/4] feat: Add rotation functionality for camera view --- camera_view.py | 23 +++++++++++++++++++++-- main.py | 11 +++++++++++ mainwindow.ui | 9 ++++++++- ui_mainwindow.py | 8 +++++++- 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/camera_view.py b/camera_view.py index e2b1063..2f77ed6 100644 --- a/camera_view.py +++ b/camera_view.py @@ -81,18 +81,20 @@ def set_camera_highest_resolution(cap): set_resolution(cap, *highest_res) -class FrameCrop: +class FrameCropAndRotation: def __init__(self): self.isCropSet = fetch_data("scoresight.json", "crop_mode", False) self.cropTop = fetch_data("scoresight.json", "top_crop", 0) self.cropBottom = fetch_data("scoresight.json", "bottom_crop", 0) self.cropLeft = fetch_data("scoresight.json", "left_crop", 0) self.cropRight = fetch_data("scoresight.json", "right_crop", 0) + self.rotation = fetch_data("scoresight.json", "rotation", 0) subscribe_to_data("scoresight.json", "crop_mode", self.setCropMode) subscribe_to_data("scoresight.json", "top_crop", self.setCropTop) subscribe_to_data("scoresight.json", "bottom_crop", self.setCropBottom) subscribe_to_data("scoresight.json", "left_crop", self.setCropLeft) subscribe_to_data("scoresight.json", "right_crop", self.setCropRight) + subscribe_to_data("scoresight.json", "rotation", self.setRotation) def setCropMode(self, crop_mode): self.isCropSet = crop_mode @@ -109,6 +111,9 @@ def setCropLeft(self, crop_left): def setCropRight(self, crop_right): self.cropRight = crop_right + def setRotation(self, rotation): + self.rotation = rotation + class TimerThread(QThread): update_signal = Signal(object) @@ -141,7 +146,7 @@ def __init__( self.ups = 1000 / self.update_frame_interval # updates per second self.fps_alpha = 0.1 # Smoothing factor self.updateOnChange = True - self.crop = FrameCrop() + self.crop = FrameCropAndRotation() def connect_video_capture(self) -> bool: if self.camera_info.type == CameraInfo.CameraType.NDI: @@ -278,6 +283,20 @@ def run(self): + (1.0 - self.fps_alpha) * self.ups ) + # apply rotation if set + if self.crop.rotation != 0: + # use cv2.rotate to rotate the frame + rotateCode = ( + cv2.ROTATE_90_CLOCKWISE + if self.crop.rotation == 90 + else ( + cv2.ROTATE_90_COUNTERCLOCKWISE + if self.crop.rotation == 270 + else cv2.ROTATE_180 + ) + ) + frame_rgb = cv2.rotate(frame_rgb, rotateCode) + # apply top-level crop if set if self.crop.isCropSet: frame_rgb = frame_rgb[ diff --git a/main.py b/main.py index ade025f..0cf3762 100644 --- a/main.py +++ b/main.py @@ -182,6 +182,9 @@ def __init__(self, translator: QTranslator, parent: QObject): fetch_data("scoresight.json", "bottom_crop", 0) ) + # connect toolButton_rotate + self.ui.toolButton_rotate.clicked.connect(self.rotateImage) + self.ui.widget_detectionCadence.setVisible(True) self.ui.horizontalSlider_detectionCadence.setValue( fetch_data("scoresight.json", "detection_cadence", 5) @@ -370,6 +373,14 @@ def __init__(self, translator: QTranslator, parent: QObject): self.get_sources.connect(self.getSources) self.get_sources.emit() + def rotateImage(self): + # store the rotation in the scoresight.json + rotation = fetch_data("scoresight.json", "rotation", 0) + rotation += 90 + if rotation >= 360: + rotation = 0 + self.globalSettingsChanged("rotation", rotation) + def cropMode(self): # if the toolButton_topCrop is unchecked, go to crop mode if self.ui.toolButton_topCrop.isChecked(): diff --git a/mainwindow.ui b/mainwindow.ui index f4c4976..222ca0b 100644 --- a/mainwindow.ui +++ b/mainwindow.ui @@ -7,7 +7,7 @@ 0 0 961 - 734 + 761 @@ -1802,6 +1802,13 @@ + + + + Rotate + + + diff --git a/ui_mainwindow.py b/ui_mainwindow.py index a15d1bf..9002008 100644 --- a/ui_mainwindow.py +++ b/ui_mainwindow.py @@ -28,7 +28,7 @@ class Ui_MainWindow(object): def setupUi(self, MainWindow): if not MainWindow.objectName(): MainWindow.setObjectName(u"MainWindow") - MainWindow.resize(961, 734) + MainWindow.resize(961, 761) self.centralwidget = QWidget(MainWindow) self.centralwidget.setObjectName(u"centralwidget") self.formLayout = QFormLayout(self.centralwidget) @@ -937,6 +937,11 @@ def setupUi(self, MainWindow): self.horizontalLayout_10.addWidget(self.toolButton_topCrop) + self.toolButton_rotate = QToolButton(self.widget_viewTools) + self.toolButton_rotate.setObjectName(u"toolButton_rotate") + + self.horizontalLayout_10.addWidget(self.toolButton_rotate) + self.pushButton_stabilize = QToolButton(self.widget_viewTools) self.pushButton_stabilize.setObjectName(u"pushButton_stabilize") self.pushButton_stabilize.setEnabled(False) @@ -1228,6 +1233,7 @@ def retranslateUi(self, MainWindow): self.toolButton_topCrop.setToolTip(QCoreApplication.translate("MainWindow", u"Apply cropping to the entire image", None)) #endif // QT_CONFIG(tooltip) self.toolButton_topCrop.setText(QCoreApplication.translate("MainWindow", u"Crop", None)) + self.toolButton_rotate.setText(QCoreApplication.translate("MainWindow", u"Rotate", None)) self.pushButton_stabilize.setText(QCoreApplication.translate("MainWindow", u"Stabilize", None)) #if QT_CONFIG(tooltip) self.toolButton_osd.setToolTip(QCoreApplication.translate("MainWindow", u"Show Statistics", None)) From 77ee5ef782eda155ecfd5b19a88cf00fe7b7efb9 Mon Sep 17 00:00:00 2001 From: Roy Shilkrot Date: Fri, 13 Sep 2024 18:30:17 -0400 Subject: [PATCH 2/4] feat: Add support for capturing video from a URL in Windows This commit adds support for capturing video from a URL in the Windows operating system. Previously, the code only supported capturing video from a file. With this change, the `TimerThread` class now checks if the camera type is not a file or a URL, and if it is Windows, it uses the dshow backend to capture video from a URL. --- camera_view.py | 1 + 1 file changed, 1 insertion(+) diff --git a/camera_view.py b/camera_view.py index 2f77ed6..255e24e 100644 --- a/camera_view.py +++ b/camera_view.py @@ -158,6 +158,7 @@ def connect_video_capture(self) -> bool: if ( os_name == "Windows" and self.camera_info.type != CameraInfo.CameraType.FILE + and self.camera_info.type != CameraInfo.CameraType.URL ): # on windows use the dshow backend self.video_capture = cv2.VideoCapture( From 0a729d01d1f84f6cd7d48b9df92027d5a8069eb4 Mon Sep 17 00:00:00 2001 From: Roy Shilkrot Date: Sat, 14 Sep 2024 10:52:06 -0400 Subject: [PATCH 3/4] feat: Update frame interval dynamically based on detection cadence --- camera_view.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/camera_view.py b/camera_view.py index 255e24e..7fa6f7e 100644 --- a/camera_view.py +++ b/camera_view.py @@ -139,7 +139,12 @@ def __init__( self.video_capture = None self.should_stop = False self.frame_interval = 30 - self.update_frame_interval = 200 + self.update_frame_interval = 1000 / fetch_data( + "scoresight.json", "detection_cadence", 5 + ) + subscribe_to_data( + "scoresight.json", "detection_cadence", self.setUpdateFrameInterval + ) self.preview_frame_interval = 1000 self.fps = 1000 / self.frame_interval # frames per second self.pps = 1000 / self.preview_frame_interval # previews per second @@ -148,6 +153,9 @@ def __init__( self.updateOnChange = True self.crop = FrameCropAndRotation() + def setUpdateFrameInterval(self, cadence): + self.update_frame_interval = 1000 / cadence + def connect_video_capture(self) -> bool: if self.camera_info.type == CameraInfo.CameraType.NDI: self.video_capture = NDICapture(self.camera_info.uuid) From 90deb534b0627beace06babd7d768b6c36a8a70e Mon Sep 17 00:00:00 2001 From: Roy Shilkrot Date: Sat, 14 Sep 2024 11:07:09 -0400 Subject: [PATCH 4/4] refactor: Remove unused code and improve error handling - Removed unused import statements and variables - Improved error handling by displaying error messages using QMessageBox instead of status bar messages - Updated UI layout in mainwindow.ui to accommodate new features Closes #123 --- main.py | 39 ++++++++++++++------------ mainwindow.ui | 71 ++++++++++++++++++++++++++++-------------------- ui_mainwindow.py | 65 ++++++++++++++++++++++---------------------- 3 files changed, 95 insertions(+), 80 deletions(-) diff --git a/main.py b/main.py index 0cf3762..c1bcb51 100644 --- a/main.py +++ b/main.py @@ -5,12 +5,13 @@ import datetime from PySide6.QtWidgets import ( QApplication, - QMainWindow, + QDialog, QFileDialog, + QInputDialog, QLabel, + QMainWindow, QMenu, - QDialog, - QInputDialog, + QMessageBox, QTableWidgetItem, ) from PySide6.QtGui import QIcon, QStandardItemModel, QStandardItem, QDesktopServices @@ -145,7 +146,6 @@ def __init__(self, translator: QTranslator, parent: QObject): self.installEventFilter(self) self.ui.pushButton_connectObs.clicked.connect(self.openOBSConnectModal) - self.ui.statusbar.showMessage("OBS: Not Connected") self.vmixUiSetup() @@ -181,6 +181,12 @@ def __init__(self, translator: QTranslator, parent: QObject): self.ui.spinBox_bottomCrop.setValue( fetch_data("scoresight.json", "bottom_crop", 0) ) + self.ui.checkBox_enableOutAPI.toggled.connect( + partial(self.globalSettingsChanged, "enable_out_api") + ) + self.ui.checkBox_enableOutAPI.setChecked( + fetch_data("scoresight.json", "enable_out_api", False) + ) # connect toolButton_rotate self.ui.toolButton_rotate.clicked.connect(self.rotateImage) @@ -452,8 +458,15 @@ def importConfiguration(self): return # load the configuration from the file if not self.detectionTargetsStorage.loadBoxesFromFile(file): - # show an error message - self.ui.statusbar.showMessage("Error loading configuration file") + # show an error qmessagebox + logger.error("Error loading configuration file") + QMessageBox.critical( + self, + "Error", + "Error loading configuration file", + QMessageBox.StandardButton.Ok, + ) + return def exportConfiguration(self): # open a file dialog to select the output file @@ -527,11 +540,6 @@ def toggleStabilize(self): self.image_viewer.toggleStabilization(self.ui.pushButton_stabilize.isChecked()) def toggleStopUpdates(self, value): - self.ui.statusbar.showMessage( - self.translator.translate("main", "Stopped updates") - if value - else self.translator.translate("main", "Resumed updates") - ) self.updateOCRResults = not value # change the text on the button self.ui.pushButton_stopUpdates.setText( @@ -980,8 +988,7 @@ def connectObs(self): self.ui.checkBox_recreate.setEnabled(True) self.ui.pushButton_createOBSScene.setEnabled(True) - # set OBS status to connected in the status bar - self.ui.statusbar.showMessage("OBS: Connected") + logger.info("OBS: Connected") @Slot() def getSources(self): @@ -1232,10 +1239,8 @@ def cameraConnectedEnableUI(self): def updateError(self, error): if not error: - self.ui.statusbar.clearMessage() return - # show the error in the status bar - self.ui.statusbar.showMessage(error) + logger.error(error) self.ui.frame_source_view.setEnabled(True) self.ui.widget_viewTools.setEnabled(False) @@ -1496,12 +1501,10 @@ def makeTemplateField(self, toggled: bool): self.listItemClicked(item) def createOBSScene(self): - self.ui.statusbar.showMessage("Creating OBS scene") # get the scene name from the lineEdit_sceneName scene_name = self.ui.lineEdit_sceneName.text() # clear or create a new scene create_obs_scene_from_export(self.obs_websocket_client, scene_name) - self.ui.statusbar.showMessage("Finished creating scene") # on destroy, close the OBS connection def closeEvent(self, event): diff --git a/mainwindow.ui b/mainwindow.ui index 222ca0b..0933569 100644 --- a/mainwindow.ui +++ b/mainwindow.ui @@ -7,7 +7,7 @@ 0 0 961 - 761 + 725 @@ -1439,28 +1439,21 @@ QFormLayout::AllNonFixedFieldsGrow - - + + - URL - - - - - - - http:// + Send out API requests to external services. - + Encode - + @@ -1484,13 +1477,6 @@ - - - - Websocket? - - - @@ -1504,17 +1490,45 @@ - - - - Start - + + + + + 2 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + http:// + + + + + + + Websocket + + + + - - + + - Send out API requests to external services. + URL @@ -2100,7 +2114,6 @@ - diff --git a/ui_mainwindow.py b/ui_mainwindow.py index 9002008..d19a552 100644 --- a/ui_mainwindow.py +++ b/ui_mainwindow.py @@ -20,15 +20,14 @@ QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLayout, QLineEdit, QMainWindow, QMenuBar, QPushButton, QSizePolicy, QSlider, QSpacerItem, - QSpinBox, QStatusBar, QTabWidget, QTableView, - QTableWidget, QTableWidgetItem, QToolButton, QVBoxLayout, - QWidget) + QSpinBox, QTabWidget, QTableView, QTableWidget, + QTableWidgetItem, QToolButton, QVBoxLayout, QWidget) class Ui_MainWindow(object): def setupUi(self, MainWindow): if not MainWindow.objectName(): MainWindow.setObjectName(u"MainWindow") - MainWindow.resize(961, 761) + MainWindow.resize(961, 725) self.centralwidget = QWidget(MainWindow) self.centralwidget.setObjectName(u"centralwidget") self.formLayout = QFormLayout(self.centralwidget) @@ -761,20 +760,15 @@ def setupUi(self, MainWindow): self.formLayout_3 = QFormLayout(self.tab_api) self.formLayout_3.setObjectName(u"formLayout_3") self.formLayout_3.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow) - self.label_20 = QLabel(self.tab_api) - self.label_20.setObjectName(u"label_20") - - self.formLayout_3.setWidget(1, QFormLayout.LabelRole, self.label_20) - - self.lineEdit_api_url = QLineEdit(self.tab_api) - self.lineEdit_api_url.setObjectName(u"lineEdit_api_url") + self.checkBox_enableOutAPI = QCheckBox(self.tab_api) + self.checkBox_enableOutAPI.setObjectName(u"checkBox_enableOutAPI") - self.formLayout_3.setWidget(1, QFormLayout.FieldRole, self.lineEdit_api_url) + self.formLayout_3.setWidget(0, QFormLayout.FieldRole, self.checkBox_enableOutAPI) self.label_21 = QLabel(self.tab_api) self.label_21.setObjectName(u"label_21") - self.formLayout_3.setWidget(3, QFormLayout.LabelRole, self.label_21) + self.formLayout_3.setWidget(4, QFormLayout.LabelRole, self.label_21) self.comboBox_api_encode = QComboBox(self.tab_api) self.comboBox_api_encode.addItem("") @@ -783,26 +777,35 @@ def setupUi(self, MainWindow): self.comboBox_api_encode.addItem("") self.comboBox_api_encode.setObjectName(u"comboBox_api_encode") - self.formLayout_3.setWidget(3, QFormLayout.FieldRole, self.comboBox_api_encode) - - self.checkBox_is_websocket = QCheckBox(self.tab_api) - self.checkBox_is_websocket.setObjectName(u"checkBox_is_websocket") - - self.formLayout_3.setWidget(2, QFormLayout.FieldRole, self.checkBox_is_websocket) + self.formLayout_3.setWidget(4, QFormLayout.FieldRole, self.comboBox_api_encode) self.verticalSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) self.formLayout_3.setItem(5, QFormLayout.FieldRole, self.verticalSpacer) - self.pushButton_api_start = QPushButton(self.tab_api) - self.pushButton_api_start.setObjectName(u"pushButton_api_start") + self.widget_24 = QWidget(self.tab_api) + self.widget_24.setObjectName(u"widget_24") + self.horizontalLayout_27 = QHBoxLayout(self.widget_24) + self.horizontalLayout_27.setSpacing(2) + self.horizontalLayout_27.setObjectName(u"horizontalLayout_27") + self.horizontalLayout_27.setContentsMargins(0, 0, 0, 0) + self.lineEdit_api_url = QLineEdit(self.widget_24) + self.lineEdit_api_url.setObjectName(u"lineEdit_api_url") + + self.horizontalLayout_27.addWidget(self.lineEdit_api_url) + + self.checkBox_is_websocket = QCheckBox(self.widget_24) + self.checkBox_is_websocket.setObjectName(u"checkBox_is_websocket") + + self.horizontalLayout_27.addWidget(self.checkBox_is_websocket) - self.formLayout_3.setWidget(4, QFormLayout.FieldRole, self.pushButton_api_start) - self.label_22 = QLabel(self.tab_api) - self.label_22.setObjectName(u"label_22") + self.formLayout_3.setWidget(1, QFormLayout.FieldRole, self.widget_24) - self.formLayout_3.setWidget(0, QFormLayout.FieldRole, self.label_22) + self.label_20 = QLabel(self.tab_api) + self.label_20.setObjectName(u"label_20") + + self.formLayout_3.setWidget(1, QFormLayout.LabelRole, self.label_20) self.tabWidget_outputs.addTab(self.tab_api, "") @@ -1081,9 +1084,6 @@ def setupUi(self, MainWindow): self.menubar.setObjectName(u"menubar") self.menubar.setGeometry(QRect(0, 0, 961, 20)) MainWindow.setMenuBar(self.menubar) - self.statusbar = QStatusBar(MainWindow) - self.statusbar.setObjectName(u"statusbar") - MainWindow.setStatusBar(self.statusbar) self.retranslateUi(MainWindow) @@ -1199,17 +1199,16 @@ def retranslateUi(self, MainWindow): #endif // QT_CONFIG(tooltip) self.checkBox_vmix_send_same.setText(QCoreApplication.translate("MainWindow", u"Send Same?", None)) self.tabWidget_outputs.setTabText(self.tabWidget_outputs.indexOf(self.tab_vmix), QCoreApplication.translate("MainWindow", u"VMix", None)) - self.label_20.setText(QCoreApplication.translate("MainWindow", u"URL", None)) - self.lineEdit_api_url.setPlaceholderText(QCoreApplication.translate("MainWindow", u"http://", None)) + self.checkBox_enableOutAPI.setText(QCoreApplication.translate("MainWindow", u"Send out API requests to external services.", None)) self.label_21.setText(QCoreApplication.translate("MainWindow", u"Encode", None)) self.comboBox_api_encode.setItemText(0, QCoreApplication.translate("MainWindow", u"JSON", None)) self.comboBox_api_encode.setItemText(1, QCoreApplication.translate("MainWindow", u"XML", None)) self.comboBox_api_encode.setItemText(2, QCoreApplication.translate("MainWindow", u"Key-Value", None)) self.comboBox_api_encode.setItemText(3, QCoreApplication.translate("MainWindow", u"Plain Text", None)) - self.checkBox_is_websocket.setText(QCoreApplication.translate("MainWindow", u"Websocket?", None)) - self.pushButton_api_start.setText(QCoreApplication.translate("MainWindow", u"Start", None)) - self.label_22.setText(QCoreApplication.translate("MainWindow", u"Send out API requests to external services.", None)) + self.lineEdit_api_url.setPlaceholderText(QCoreApplication.translate("MainWindow", u"http://", None)) + self.checkBox_is_websocket.setText(QCoreApplication.translate("MainWindow", u"Websocket", None)) + self.label_20.setText(QCoreApplication.translate("MainWindow", u"URL", None)) self.tabWidget_outputs.setTabText(self.tabWidget_outputs.indexOf(self.tab_api), QCoreApplication.translate("MainWindow", u"API", None)) self.pushButton_stopUpdates.setText(QCoreApplication.translate("MainWindow", u"Stop Updates", None)) self.label_detectionCadence.setText(QCoreApplication.translate("MainWindow", u"Detections / s", None))