diff --git a/README.md b/README.md
index 0e18fce..28e8c0d 100644
--- a/README.md
+++ b/README.md
@@ -94,13 +94,19 @@ There are some extra steps for installation on Windows:
### Running from source
-1. Once everything is installed launch the application:
+1. Compile the UI files into Python:
- ```shell
- python main.py
- ```
+ ```powershell
+ ./scripts/compile_ui.ps1
+ ```
+
+1. Launch the application:
+
+ ```shell
+ python main.py
+ ```
-2. Follow the on-screen instructions to load an image of the scoreboard and extract the text.
+1. Follow the on-screen instructions to load an image of the scoreboard and extract the text.
### Build an executable
diff --git a/camera_view.py b/camera_view.py
index 41b6887..62c5533 100644
--- a/camera_view.py
+++ b/camera_view.py
@@ -1,5 +1,3 @@
-import platform
-import time
from PySide6.QtWidgets import (
QGraphicsView,
QGraphicsScene,
@@ -8,19 +6,22 @@
from PySide6.QtCore import Qt
from PySide6.QtGui import QImage, QPixmap, QPainter
from PySide6.QtCore import QThread, Signal
+
+import platform
+import time
import cv2
import numpy as np
+import datetime
+from datetime import datetime
+
from camera_info import CameraInfo
from ndi import NDICapture
from screen_capture_source import ScreenCapture
-
from storage import TextDetectionTargetMemoryStorage
from tesseract import TextDetector
-import datetime
-from datetime import datetime
-
from text_detection_target import TextDetectionTargetWithResult
from sc_logging import logger
+from frame_stabilizer import FrameStabilizer
# Function to set the resolution
@@ -80,68 +81,6 @@ def set_camera_highest_resolution(cap):
set_resolution(cap, *highest_res)
-class FrameStabilizer:
- def __init__(self):
- self.stabilizationFrame = None
- self.stabilizationFrameCount = 0
- self.stabilizationBurnInCompleted = False
- self.stabilizationKPs = None
- self.stabilizationDesc = None
- self.orb = None
- self.matcher = None
-
- def reset(self):
- self.stabilizationFrame = None
- self.stabilizationFrameCount = 0
- self.stabilizationBurnInCompleted = False
- self.stabilizationKPs = None
- self.stabilizationDesc = None
-
- def stabilize_frame(self, frame_rgb):
- if self.stabilizationFrame is None:
- self.stabilizationFrame = frame_rgb
- self.stabilizationFrameCount = 0
- elif not self.stabilizationBurnInCompleted:
- self.stabilizationFrameCount += 1
- # add the new frame to the stabilization frame
- frame_rgb = cv2.addWeighted(frame_rgb, 0.5, self.stabilizationFrame, 0.5, 0)
- if self.stabilizationFrameCount == 10:
- self.stabilizationBurnInCompleted = True
- # extract ORB features from the stabilization frame
- self.orb = cv2.ORB_create()
- self.stabilizationKPs, self.stabilizationDesc = (
- self.orb.detectAndCompute(self.stabilizationFrame, None)
- )
- self.matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
-
- if self.stabilizationBurnInCompleted:
- # stabilization burn-in period is over, start stabilization
- # extract features from the current frame
- kps, desc = self.orb.detectAndCompute(frame_rgb, None)
- # match the features
- matches = self.matcher.match(self.stabilizationDesc, desc)
- # sort the matches by distance
- matches = sorted(matches, key=lambda x: x.distance)
- # calculate an affine transform from the matched keypoints
- src_pts = np.float32(
- [self.stabilizationKPs[m.queryIdx].pt for m in matches]
- ).reshape(-1, 1, 2)
- dst_pts = np.float32([kps[m.trainIdx].pt for m in matches]).reshape(
- -1, 1, 2
- )
- h, _ = cv2.estimateAffinePartial2D(src_pts, dst_pts)
- # warp the frame
- if h is not None:
- frame_rgb = cv2.warpAffine(
- frame_rgb,
- h,
- (frame_rgb.shape[1], frame_rgb.shape[0]),
- flags=cv2.WARP_INVERSE_MAP | cv2.INTER_LINEAR,
- )
-
- return frame_rgb
-
-
class TimerThread(QThread):
update_signal = Signal(object)
update_error = Signal(object)
diff --git a/frame_stabilizer.py b/frame_stabilizer.py
new file mode 100644
index 0000000..4217601
--- /dev/null
+++ b/frame_stabilizer.py
@@ -0,0 +1,74 @@
+import cv2
+import numpy as np
+
+
+# This class is used to stabilize the frames of the video.
+# It uses ORB features to match keypoints between frames and calculate an affine transform to
+# warp the frame.
+class FrameStabilizer:
+ def __init__(self):
+ self.stabilizationFrame = None
+ self.stabilizationFrameCount = 0
+ self.stabilizationBurnInCompleted = False
+ self.stabilizationKPs = None
+ self.stabilizationDesc = None
+ self.orb = None
+ self.matcher = None
+
+ def reset(self):
+ self.stabilizationFrame = None
+ self.stabilizationFrameCount = 0
+ self.stabilizationBurnInCompleted = False
+ self.stabilizationKPs = None
+ self.stabilizationDesc = None
+
+ def stabilize_frame(self, frame_rgb):
+ if self.stabilizationFrame is None:
+ self.stabilizationFrame = frame_rgb
+ self.stabilizationFrameCount = 0
+ elif not self.stabilizationBurnInCompleted:
+ self.stabilizationFrameCount += 1
+ # add the new frame to the stabilization frame
+ frame_rgb = cv2.addWeighted(frame_rgb, 0.5, self.stabilizationFrame, 0.5, 0)
+ if self.stabilizationFrameCount == 10:
+ self.stabilizationBurnInCompleted = True
+ # extract ORB features from the stabilization frame
+ self.orb = cv2.ORB_create()
+ self.stabilizationKPs, self.stabilizationDesc = (
+ self.orb.detectAndCompute(self.stabilizationFrame, None)
+ )
+ self.matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
+
+ if (
+ self.stabilizationBurnInCompleted
+ and self.stabilizationFrame is not None
+ and self.orb is not None
+ and self.matcher is not None
+ and self.stabilizationKPs is not None
+ and self.stabilizationDesc is not None
+ ):
+ # stabilization burn-in period is over, start stabilization
+ # extract features from the current frame
+ kps, desc = self.orb.detectAndCompute(frame_rgb, None)
+ # match the features
+ matches = self.matcher.match(self.stabilizationDesc, desc)
+ # sort the matches by distance
+ matches = sorted(matches, key=lambda x: x.distance)
+ # calculate an affine transform from the matched keypoints
+ src_pts = np.float32(
+ [self.stabilizationKPs[m.queryIdx].pt for m in matches]
+ ).reshape(-1, 1, 2)
+ dst_pts = np.float32([kps[m.trainIdx].pt for m in matches]).reshape(
+ -1, 1, 2
+ )
+ h, _ = cv2.estimateAffinePartial2D(src_pts, dst_pts)
+ # warp the frame
+ if h is not None:
+ frame_rgb = cv2.warpAffine(
+ frame_rgb,
+ h,
+ (frame_rgb.shape[1], frame_rgb.shape[0]),
+ flags=cv2.WARP_INVERSE_MAP | cv2.INTER_LINEAR,
+ )
+
+ return frame_rgb
diff --git a/main.py b/main.py
index a92cf86..c7c9a74 100644
--- a/main.py
+++ b/main.py
@@ -408,7 +408,7 @@ def selectOutputFolder(self):
folder = QFileDialog.getExistingDirectory(
self,
"Select Output Folder",
- fetch_data("scoresight.json", "output_folder"),
+ fetch_data("scoresight.json", "output_folder", ""),
options=QFileDialog.Option.ShowDirsOnly,
)
if folder and len(folder) > 0:
@@ -522,7 +522,7 @@ def vmixUiSetup(self):
if mapping:
self.vmixUpdater.set_field_mapping(mapping)
- self.ui.tableView_vmixMapping.model().itemChanged.connect(
+ self.ui.tableView_vmixMapping.model().dataChanged.connect(
self.vmixMappingChanged
)
@@ -759,9 +759,9 @@ def connectObs(self):
if self.obs_connect_modal is not None:
self.obs_websocket_client = open_obs_websocket(
{
- "ip": self.obs_modal_ui.obs_connect_modal.lineEdit_ip.text(),
- "port": self.obs_modal_ui.obs_connect_modal.lineEdit_port.text(),
- "password": self.obs_modal_ui.obs_connect_modal.lineEdit_password.text(),
+ "ip": self.obs_modal_ui.lineEdit_ip.text(),
+ "port": self.obs_modal_ui.lineEdit_port.text(),
+ "password": self.obs_modal_ui.lineEdit_password.text(),
}
)
else:
@@ -865,6 +865,8 @@ def sourceChanged(self, index):
self, "Open Video File", "", "Video Files (*.mp4 *.avi *.mov)"
)
if not file:
+ # no file selected - change source to "Select a source"
+ self.ui.comboBox_camera_source.setCurrentText("Select a source")
return
self.source_name = file
if self.source_name == "URL Source (HTTP, RTSP)":
@@ -872,16 +874,17 @@ def sourceChanged(self, index):
url_dialog = QDialog()
ui_urlsource = Ui_UrlSource()
ui_urlsource.setupUi(url_dialog)
-
url_dialog.setWindowTitle("URL Source")
# focus on url input
ui_urlsource.lineEdit_url.setFocus()
url_dialog.exec() # wait for the dialog to close
# check if the dialog was accepted
if url_dialog.result() != QDialog.DialogCode.Accepted:
+ self.ui.comboBox_camera_source.setCurrentText("Select a source")
return
self.source_name = ui_urlsource.lineEdit_url.text()
if self.source_name == "":
+ self.ui.comboBox_camera_source.setCurrentText("Select a source")
return
if self.source_name == "Screen Capture":
# open a dialog to select the screen
@@ -898,6 +901,7 @@ def sourceChanged(self, index):
screen_dialog.exec()
# check if the dialog was accepted
if screen_dialog.result() != QDialog.DialogCode.Accepted:
+ self.ui.comboBox_camera_source.setCurrentText("Select a source")
return
# get the window ID from the comboBox_window
window_id = ui_screencapture.comboBox_window.currentData()
@@ -932,6 +936,9 @@ def sourceSelectionSucessful(self):
self.ui.frame_source_view.setEnabled(False)
if self.ui.comboBox_camera_source.currentData() == "file":
+ if self.source_name is None:
+ logger.error("No file selected")
+ return
camera_info = CameraInfo(
self.source_name,
self.source_name,
@@ -939,6 +946,9 @@ def sourceSelectionSucessful(self):
CameraInfo.CameraType.FILE,
)
elif self.ui.comboBox_camera_source.currentData() == "url":
+ if self.source_name is None:
+ logger.error("No url entered")
+ return
camera_info = CameraInfo(
self.source_name,
self.source_name,
@@ -946,6 +956,9 @@ def sourceSelectionSucessful(self):
CameraInfo.CameraType.URL,
)
elif self.ui.comboBox_camera_source.currentData() == "screen_capture":
+ if self.source_name is None:
+ logger.error("No screen capture selected")
+ return
camera_info = CameraInfo(
self.source_name,
self.source_name,
@@ -1056,7 +1069,8 @@ def ocrResult(self, results: list[TextDetectionTargetWithResult]):
if targetWithResult.result is None:
continue
if (
- "skip_empty" in targetWithResult.settings
+ targetWithResult.settings is not None
+ and "skip_empty" in targetWithResult.settings
and targetWithResult.settings["skip_empty"]
and len(targetWithResult.result) == 0
):
@@ -1067,7 +1081,10 @@ def ocrResult(self, results: list[TextDetectionTargetWithResult]):
):
continue
- if self.obs_websocket_client is not None:
+ if (
+ self.obs_websocket_client is not None
+ and targetWithResult.settings is not None
+ ):
# find the source name for the target from the default boxes
update_text_source(
self.obs_websocket_client,
@@ -1202,12 +1219,12 @@ def removeBox(self):
self.detectionTargetsStorage.remove_item(item.text())
def createOBSScene(self):
- self.ui.statusbar().showMessage("Creating OBS scene")
+ 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")
+ 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 e95e0be..8f1f37a 100644
--- a/mainwindow.ui
+++ b/mainwindow.ui
@@ -7,7 +7,7 @@
About ScoreSight
ScoreSight is a cutting-edge software solution designed to simplify visual reading of scoreboards.
License
MIT License
Copyright (c) 2024 OCC AI: Open tools for Content Creators and Streamers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIE'
- 'D, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Third-Party Software
ScoreSight incorporates components from third-party sources under their respective licenses:
Detailed licensing information for these components is included within the software distribution.
Qt Application Framework
This application uses the Qt application framework, which is a comprehensive C++ library for cross-platform development of GUI applications. Qt is used under the terms of the GNU Lesser General Public License (LGPL) version 3. Qt is a registered trademark of The Qt Company Ltd and is developed and maintained by The Qt Project and various contributors.
For more information about Qt, including source code of Qt libraries used by thi' - 's application and guidance on how to obtain or replace Qt libraries, please visit the Qt Project\'s official website at http://www.qt.io.
We are committed to ensuring compliance with the LGPL v3 license and support the principles of open source software development. If you have any questions or concerns regarding our use of Qt, please contact us directly.
Disclaimer of Warranty
ScoreSight is provided "AS IS", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and noninfringement. In no event shall Roy Shilkrot be liable for any claim, damages, or other liability, whether in an action of contract, tort or otherwise, arising from, out of, or in connection with the software or the use or other dealings in the software.
Contact Information
For support, feedback, or more information, please visit https://scoresight.live or contact us at info@scoresight.live.
About ScoreSight
ScoreSight is a cutting-edge software solution designed to simplify visual reading of scoreboards.
License
MIT License
Copyright (c) 2024 OCC AI: Open tools for Content Creators and Streamers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIE"
+ "D, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Third-Party Software
ScoreSight incorporates components from third-party sources under their respective licenses:
Detailed licensing information for these components is included within the software distribution.
Qt Application Framework
This application uses the Qt application framework, which is a comprehensive C++ library for cross-platform development of GUI applications. Qt is used under the terms of the GNU Lesser General Public License (LGPL) version 3. Qt is a registered trademark of The Qt Company Ltd and is developed and maintained by The Qt Project and various contributors.
For more information about Qt, including source code of Qt libraries used by thi" + "s application and guidance on how to obtain or replace Qt libraries, please visit the Qt Project's official website at http://www.qt.io.
We are committed to ensuring compliance with the LGPL v3 license and support the principles of open source software development. If you have any questions or concerns regarding our use of Qt, please contact us directly.
Disclaimer of Warranty
ScoreSight is provided "AS IS", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and noninfringement. In no event shall Roy Shilkrot be liable for any claim, damages, or other liability, whether in an action of contract, tort or otherwise, arising from, out of, or in connection with the software or the use or other dealings in the software.
Contact Information
For support, feedback, or more information, please visit https://scoresight.live or contact us at info@scoresight.live.
HTML Scoreboard: http://localhost:18099/scoresight
JSON: http://localhost:18099/json (optional: ?pivot)
XML: http://localhost:18099/xml (optional: ?pivot)
", None)) self.tabWidget_outputs.setTabText(self.tabWidget_outputs.indexOf(self.tab_browser), QCoreApplication.translate("MainWindow", u"Browser", None)) + self.pushButton_connectObs.setText(QCoreApplication.translate("MainWindow", u"Connect OBS", None)) self.lineEdit_sceneName.setText(QCoreApplication.translate("MainWindow", u"ScoreSight Scene", None)) - self.checkBox_recreate.setText(QCoreApplication.translate("MainWindow", u"Recreate if exists", None)) self.pushButton_createOBSScene.setText(QCoreApplication.translate("MainWindow", u"Create OBS Scene", None)) - self.pushButton_connectObs.setText(QCoreApplication.translate("MainWindow", u"Connect OBS", None)) + self.checkBox_recreate.setText(QCoreApplication.translate("MainWindow", u"Recreate", None)) self.tabWidget_outputs.setTabText(self.tabWidget_outputs.indexOf(self.tab_obs), QCoreApplication.translate("MainWindow", u"OBS", None)) - self.label_6.setText(QCoreApplication.translate("MainWindow", u"Output Mapping", None)) - self.pushButton_startvmix.setText(QCoreApplication.translate("MainWindow", u"Start", None)) self.connectionLabel.setText(QCoreApplication.translate("MainWindow", u"Connection", None)) self.lineEdit_vmixHost.setText(QCoreApplication.translate("MainWindow", u"localhost", None)) self.label_5.setText(QCoreApplication.translate("MainWindow", u":", None)) self.lineEdit_vmixPort.setText(QCoreApplication.translate("MainWindow", u"8099", None)) + self.pushButton_startvmix.setText(QCoreApplication.translate("MainWindow", u"Start", None)) self.vmixinputLabel.setText(QCoreApplication.translate("MainWindow", u"Input", None)) self.inputLineEdit_vmix.setText(QCoreApplication.translate("MainWindow", u"1", None)) self.tabWidget_outputs.setTabText(self.tabWidget_outputs.indexOf(self.tab_vmix), QCoreApplication.translate("MainWindow", u"VMix", None))