diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..4e283a6 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,221 @@ +name: Cross-Platform Build with PyInstaller + +on: + pull_request: + branches: [ main ] + workflow_call: + +jobs: + check-format: + name: Check Formatting 🔍 + uses: ./.github/workflows/check-format.yaml + permissions: + contents: read + + build: + needs: check-format + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + include: + - os: macos-latest + python-version: '3.11' + target: macos-x86 + runs-on: [self-hosted, macOS] + - os: ubuntu-latest + python-version: '3.11' + target: linux + runs-on: ubuntu-latest + - os: windows-latest + python-version: '3.11' + target: windows + runs-on: [self-hosted, Windows] + + runs-on: ${{ matrix.runs-on }} + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + if: matrix.os != 'windows-latest' + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install tesserocr for Windows + if: matrix.os == 'windows-latest' + run: | + Invoke-WebRequest -Uri https://github.com/simonflueckiger/tesserocr-windows_build/releases/download/tesserocr-v2.6.0-tesseract-5.3.1/tesserocr-2.6.0-cp311-cp311-win_amd64.whl -OutFile tesserocr-2.6.0-cp311-cp311-win_amd64.whl + python -m pip install tesserocr-2.6.0-cp311-cp311-win_amd64.whl + + - name: Install tesserocr for MacOS arm64 + if: matrix.os == 'macos-latest-xlarge' + run: | + brew install tesseract + pip install --no-binary tesserocr tesserocr + + # - name: Install pyinstaller for Windows + # if: matrix.os == 'windows-latest' + # # $env:PYINSTALLER_COMPILE_BOOTLOADER="True" + # run: | + # Invoke-WebRequest -Uri https://github.com/pyinstaller/pyinstaller/archive/refs/tags/v6.5.0.zip -OutFile pyinstaller-6.5.0.zip + # Expand-Archive -Path pyinstaller-6.5.0.zip -DestinationPath . + # cd pyinstaller-6.5.0 + # python -m pip install . + # cd .. + # Remove-Item -Recurse -Force pyinstaller-6.5.0 + # Remove-Item -Force pyinstaller-6.5.0.zip + + - name: Install dependencies + run: | + python -m pip install -r requirements.txt + + - name: Install MacOS dependencies + if: matrix.os == 'macos-latest' + run: | + pip install -r requirements-mac.txt + + - name: Install Windows dependencies + if: matrix.os == 'windows-latest' + run: | + python -m pip install -r requirements-win.txt + + # - name: Import Apple Certificate + # if: matrix.os == 'macos-latest' || matrix.os == 'macos-latest-xlarge' && github.runner != 'self-hosted' + # run: | + # if security list-keychains | grep -q "github_build.keychain"; then + # security delete-keychain github_build.keychain + # fi + # security create-keychain -p "" github_build.keychain + # security default-keychain -s github_build.keychain + # security set-keychain-settings -lut 21600 github_build.keychain + # echo "${{ secrets.APPLE_CERTIFICATE }}" | base64 --decode > apple_certificate.p12 + # security import apple_certificate.p12 -k github_build.keychain -P "${{ secrets.APPLE_CERTIFICATE_PASSWORD }}" \ + # -t cert -f pkcs12 -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/xcrun + # security unlock-keychain -p "" github_build.keychain + # security set-key-partition-list -S 'apple-tool:,apple:' -s -k "" github_build.keychain + # security list-keychain -d user -s github_build.keychain 'login-keychain' + # env: + # APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + # APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + + - name: Unlock keychain on Mac + if: matrix.os == 'macos-latest' || matrix.os == 'macos-latest-xlarge' + run: | + security unlock-keychain -p "" github_build.keychain + security set-key-partition-list -S apple-tool:,apple: -k "" -D "Developer" -t private github_build.keychain + + - name: List available signing identities + if: matrix.os == 'macos-latest' || matrix.os == 'macos-latest-xlarge' + run: | + security find-identity -v -p codesigning + + # write a .env file with the secrets + - name: Write .env file Mac & Linux + if: matrix.os != 'windows-latest' + run: | + echo "LOCAL_RELEASE_TAG=${GITHUB_REF_NAME}" >> .env + echo "LOCAL_RELEASE_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> .env + echo "UPDATE_CHECK_URL=https://download.scoresight.live/release_info.env" >> .env + + - name: Write .env file Windows + if: matrix.os == 'windows-latest' + run: | + @" + LOCAL_RELEASE_TAG=$env:GITHUB_REF_NAME + LOCAL_RELEASE_DATE=$(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ') + UPDATE_CHECK_URL=https://download.scoresight.live/release_info.env + "@ | Out-File -FilePath .env -Encoding ASCII + shell: pwsh + + - name: Build device enumeration module on Windows + if: matrix.os == 'windows-latest' + run: | + cd win32DeviceEnum + python setup.py build_ext --inplace + cd .. + + - name: Build with PyInstaller (MacOS) + if: matrix.os == 'macos-latest' || matrix.os == 'macos-latest-xlarge' + run: | + pyinstaller --clean --noconfirm scoresight.spec -- --mac_osx + env: + APPLE_APP_DEVELOPER_ID: ${{ secrets.APPLE_APP_DEVELOPER_ID }} + + - name: Build with PyInstaller (Windows) + if: matrix.os == 'windows-latest' + run: | + if ("${{ github.event_name }}" -eq "pull_request") { + pyinstaller --clean --noconfirm scoresight.spec -- --win --debug + } else { + pyinstaller --clean --noconfirm scoresight.spec -- --win + } + + - name: Build with PyInstaller (Linux) + if: matrix.os == 'ubuntu-latest' + run: | + pyinstaller --clean --noconfirm scoresight.spec + + - name: Zip Application for Notarization + if: matrix.os == 'macos-latest' && github.event_name != 'pull_request' + run: | + ditto -c -k --keepParent dist/scoresight.app scoresight.zip + + - name: Notarize and Staple + if: matrix.os == 'macos-latest' && github.event_name != 'pull_request' + run: | + xcrun notarytool submit scoresight.zip --apple-id \ + "${{ secrets.APPLE_DEVELOPER_ID_USER }}" --password \ + "${{ secrets.APPLE_DEVELOPER_ID_PASSWORD }}" --team-id \ + "${{ secrets.APPLE_DEVELOPER_ID_TEAM }}" --wait --verbose + chmod 755 dist/scoresight.app + xcrun stapler staple dist/scoresight.app + + - name: Verify Notarization + if: matrix.os == 'macos-latest' && github.event_name != 'pull_request' + run: | + spctl -a -v dist/scoresight.app + rm scoresight.zip + + - name: Add version to .iss file + if: matrix.os == 'windows-latest' + run: | + $version = (Get-Content -Path scoresight.iss -Raw) -replace '@SCORESIGHT_VERSION@', $env:GITHUB_REF_NAME + $version | Out-File -FilePath scoresight.iss -Encoding ASCII + shell: pwsh + + - name: Compile .ISS to .EXE Installer + if: matrix.os == 'windows-latest' + uses: Minionguyjpro/Inno-Setup-Action@v1.2.4 + with: + path: scoresight.iss + options: /O+ + + - name: Create tar Linux + if: matrix.os == 'ubuntu-latest' + # strip the folder name from the tar + run: | + chmod a+x dist/scoresight + tar -cvf scoresight.tar -C dist scoresight + + - name: Create dmg MacOS + if: matrix.os == 'macos-latest' || matrix.os == 'macos-latest-xlarge' + run: | + chmod a+x dist/scoresight.app + hdiutil create -volname "ScoreSight" -srcfolder dist/scoresight.app -ov -format UDRO scoresight.dmg + + - name: Create zip on Windows + if: matrix.os == 'windows-latest' + run: | + Compress-Archive -Path "dist/scoresight-setup.exe" -DestinationPath "./scoresight.zip" + shell: pwsh + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: scoresight-${{ matrix.target }} + # only upload exe on windows, tar on linux, dmg on macos + path: | + scoresight.dmg + scoresight.tar + scoresight.zip diff --git a/.github/workflows/check-format.yaml b/.github/workflows/check-format.yaml new file mode 100644 index 0000000..ed78296 --- /dev/null +++ b/.github/workflows/check-format.yaml @@ -0,0 +1,23 @@ +name: Check Python Formatting + +on: + workflow_call: + +jobs: + check-format: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.x + + - name: Install dependencies + run: pip install black + + - name: Check formatting + run: black --check . diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..175b87e --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,166 @@ +# only run this workflow on the main branch and when a tag is pushed +# this workflow will create a release draft and upload the build artifacts +# to the release draft +name: Release +run-name: ${{ github.ref_name }} release run 🚀 +on: + push: + branches: + - main + tags: + - '*' +permissions: + contents: write +concurrency: + group: '${{ github.workflow }} @ ${{ github.ref }}' + cancel-in-progress: ${{ github.ref_type == 'tag' }} +jobs: + build-project: + name: Build Project 🧱 + if: github.ref_type == 'tag' + uses: ./.github/workflows/build.yaml + secrets: inherit + permissions: + contents: read + + create-release: + name: Create Release 🛫 + if: github.ref_type == 'tag' + runs-on: ubuntu-22.04 + needs: build-project + defaults: + run: + shell: bash + steps: + - name: Check Release Tag ☑️ + id: check + run: | + : Check Release Tag ☑️ + if [[ "${RUNNER_DEBUG}" ]]; then set -x; fi + shopt -s extglob + + case "${GITHUB_REF_NAME}" in + +([0-9]).+([0-9]).+([0-9]) ) + echo 'validTag=true' >> $GITHUB_OUTPUT + echo 'prerelease=false' >> $GITHUB_OUTPUT + echo "version=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT + ;; + +([0-9]).+([0-9]).+([0-9])-@(beta|rc)*([0-9]) ) + echo 'validTag=true' >> $GITHUB_OUTPUT + echo 'prerelease=true' >> $GITHUB_OUTPUT + echo "version=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT + ;; + *) echo 'validTag=false' >> $GITHUB_OUTPUT ;; + esac + + - name: Download Build Artifacts 📥 + uses: actions/download-artifact@v4 + if: fromJSON(steps.check.outputs.validTag) + id: download + + - name: Print downloaded artifacts 📥 + if: fromJSON(steps.check.outputs.validTag) + run: | + : Print downloaded artifacts 📥 + if [[ "${RUNNER_DEBUG}" ]]; then set -x; fi + shopt -s extglob + + ls -laR ${{ steps.download.outputs.artifacts }} + + - name: Rename Files 🏷️ + if: fromJSON(steps.check.outputs.validTag) + run: | + : Rename Files 🏷️ + if [[ "${RUNNER_DEBUG}" ]]; then set -x; fi + shopt -s extglob + shopt -s nullglob + + root_dir="$(pwd)" + commit_hash="${GITHUB_SHA:0:9}" + + variants=( + 'linux' + 'macos-x86' + 'windows' + ) + + mkdir -p "${root_dir}/uploads" + + for variant in "${variants[@]}"; do + + candidates=(*-${variant}/@(*)) + + for candidate in "${candidates[@]}"; do + cp "${candidate}" "${root_dir}/uploads/scoresight-${variant}-${GITHUB_REF_NAME}-${commit_hash}.${candidate##*.}" + cp "${candidate}" "${root_dir}/uploads/scoresight-${variant}-latest.${candidate##*.}" + done + done + + - name: Create Latest Release Info File + if: fromJSON(steps.check.outputs.validTag) + run: | + echo "LATEST_RELEASE_TAG=${GITHUB_REF_NAME}" > release_info.env + echo "LATEST_COMMIT_HASH=${GITHUB_SHA}" >> release_info.env + echo "LATEST_RELEASE_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> release_info.env + cp release_info.env "$(pwd)/uploads/release_info.env" + + - name: Upload to S3 📤 + if: fromJSON(steps.check.outputs.validTag) + uses: shallwefootball/s3-upload-action@master + with: + aws_key_id: ${{ secrets.AWS_KEY_ID }} + aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY}} + aws_bucket: ${{ secrets.AWS_BUCKET }} + source_dir: './uploads/' + destination_dir: '' + + - name: Create a release note on Shopify + if: fromJSON(steps.check.outputs.validTag) + run: | + : Create a release note on Shopify + if [[ "${RUNNER_DEBUG}" ]]; then set -x; fi + shopt -s extglob + + echo "Creating a release note on Shopify" + echo "Get the blog id from Shopify" + BLOG_ID=$(curl -s -X GET "https://${{ secrets.SHOPIFY_STORE_ID }}.myshopify.com/admin/api/2024-01/blogs.json" \ + -H 'Content-Type: application/json' \ + -H "X-Shopify-Access-Token: ${{ secrets.SHOPIFY_ACCESS_TOKEN }}" | jq ".blogs[0].id") + echo "Blog ID: ${BLOG_ID}" + if [ -z "${BLOG_ID}" ] || [ "${BLOG_ID}" == "null" ]; then + echo "Blog ID is empty" + exit 1 + fi + + PUBLISHED_AT=$(date -u +"%a %b %d %T %Z %Y") + echo "Create a new article on Shopify" + curl -s -X POST "https://${{ secrets.SHOPIFY_STORE_ID }}.myshopify.com/admin/api/2024-01/blogs/${BLOG_ID}/articles.json" \ + -H 'Content-Type: application/json' \ + -H "X-Shopify-Access-Token: ${{ secrets.SHOPIFY_ACCESS_TOKEN }}" \ + -d "{\"article\": {\"title\": \"New Release: ${GITHUB_REF_NAME}\",\"author\": \"ScoreSight\",\"tags\": \"release\",\"published_at\": \"${PUBLISHED_AT}\",\"body_html\": \"

Version: ${GITHUB_REF_NAME}

Bugfixes, features and improvements.

Release Date: ${PUBLISHED_AT}

\"}}" + echo "Release note created on Shopify" + + # - name: Generate Checksums 🪪 + # if: fromJSON(steps.check.outputs.validTag) + # run: | + # : Generate Checksums 🪪 + # if [[ "${RUNNER_DEBUG}" ]]; then set -x; fi + # shopt -s extglob + + # echo "### Checksums" > ${{ github.workspace }}/CHECKSUMS.txt + # # find the files from the above step and generate checksums + # for file in ${{ github.workspace }}/scoresight-*; do + # echo " ${file##*/}: $(sha256sum "${file}" | cut -d " " -f 1)" >> ${{ github.workspace }}/CHECKSUMS.txt + # done + + # - name: Create Release 🛫 + # if: fromJSON(steps.check.outputs.validTag) + # id: create_release + # uses: softprops/action-gh-release@v1 + # with: + # draft: true + # body_path: ${{ github.workspace }}/CHECKSUMS.txt + # files: | + # ${{ github.workspace }}/scoresight-*.exe + # ${{ github.workspace }}/scoresight-*.dmg + # ${{ github.workspace }}/scoresight-*.tar diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4080792 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Python +*.pyc +__pycache__/ +*.pyo +*.pyd +*.pyw +*.pyz +*.pyzw +*.pycachefile +*.egg-info/ +dist/ +build/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride +Icon +._* +.Spotlight-V100 +.Trashes +__MACOSX/ + +.env +output/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e9eed1c --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# ScoreSight - OCR Scoreboard Application + +This is an OCR (Optical Character Recognition) application designed to read scoreboards. +It is written in Python and utilizes the following technologies: + +- Qt6: A cross-platform GUI toolkit for creating graphical user interfaces. +- OpenCV: A computer vision library for image and video processing. +- Tesseract OCR: An open-source OCR engine for recognizing text from images. + +## Features + +- Extracts text from scoreboards using image processing techniques. +- Provides a user-friendly interface for interacting with the application. +- Supports multiple platforms thanks to PyInstaller packaging. + +## Usage + +Videos and tutorials will be provided shortly. + +## Installation + +See the releases page for downloadable executables and installers. + +### Prerequisites + +- Python 3.11 +- git + +### Procedure + +1. Clone the repository: + + ```shell + git clone https://github.com/occ-ai/scoresight.git + ``` + +2. Install the required dependencies: + + ```shell + pip install -r requirements.txt + ``` + +For Mac and Windows there are further dependencies in `requirements-mac.txt` and `requirements-win.txt` + +3. Create a `.env` file. See the contents of the file in the `.github/worksflows/build.yaml` file + +### Windows + +There are some extra steps for installation on Windows: + - Download and install https://visualstudio.microsoft.com/visual-cpp-build-tools/ C++ Build Tools + - Build the win32DeviceEnum pyd by `$ cd win32DeviceEnum && python.exe setup.py build_ext --inplace` + +## Usage + +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. + +## Contributing + +Contributions are welcome! If you would like to contribute to this project, please follow these steps: + +1. Fork the repository. +2. Create a new branch for your feature or bug fix. +3. Make your changes and commit them. +4. Push your changes to your forked repository. +5. Submit a pull request. + +## License + +This project is released under the MIT license. + +## Contact + +If you have any questions or suggestions, feel free to leave an issue on the repository. +You may also email [support@scoresight.live](mailto:support@scoresight.live). + +## Business Inquiries + +If you wish to contract the development team to productionize ScoreSight for your business need, +please contact [info@scoresight.live](mailto:info@scoresight.live). diff --git a/about.ui b/about.ui new file mode 100644 index 0000000..4e65c08 --- /dev/null +++ b/about.ui @@ -0,0 +1,108 @@ + + + Dialog + + + + 0 + 0 + 400 + 459 + + + + + 0 + 0 + + + + + 400 + 16777215 + + + + Dialog + + + + + + true + + + + + 0 + 0 + 366 + 984 + + + + + + + <html><head/><body><p><span style=" font-weight:600;">About ScoreSight</span></p><p>ScoreSight is a cutting-edge software solution designed to simplify visual reading of scoreboards.</p><p><span style=" font-weight:600;">License</span></p><p>MIT License<br/><br/>Copyright (c) 2024 OCC AI: Open tools for Content Creators and Streamers</p><p>Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the &quot;Software&quot;), 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:<br/><br/>The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.<br/><br/>THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.</p><p><span style=" font-weight:600;">Third-Party Software</span><br/>ScoreSight incorporates components from third-party sources under their respective licenses:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-weight:600;">OpenCV</span> is used under the Apache 2 License, permitting the use, distribution, and modification of the software provided that certain conditions are met. More information can be found at OpenCV License.</li><li style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-weight:600;">Tesseract OCR</span> is used under the Apache 2 License, which allows for the use, distribution, and modification of the software in accordance with the terms set forth in the license. More details are available at Tesseract OCR License.</li></ul><p>Detailed licensing information for these components is included within the software distribution.</p><p><span style=" font-weight:696;">Qt Application Framework</span></p><p>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.</p><p>For more information about Qt, including source code of Qt libraries used by this application and guidance on how to obtain or replace Qt libraries, please visit the Qt Project's official website at <a href="http://www.qt.io/"><span style=" text-decoration: underline; color:#007af4;">http://www.qt.io</span></a>.</p><p>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.</p><p><span style=" font-weight:600;">Disclaimer of Warranty</span><br/>ScoreSight is provided &quot;AS IS&quot;, 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.</p><p><span style=" font-weight:600;">Contact Information</span><br/>For support, feedback, or more information, please visit <a href="https://scoresight.live/"><span style=" text-decoration: underline; color:#0000ff;">https://scoresight.live</span></a> or contact us at <a href="mailto:info@scoresight.live"><span style=" text-decoration: underline; color:#0000ff;">info@scoresight.live</span></a>.</p></body></html> + + + true + + + true + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/camera_info.py b/camera_info.py new file mode 100644 index 0000000..dd1bb05 --- /dev/null +++ b/camera_info.py @@ -0,0 +1,18 @@ +class CameraInfo: + class CameraType: + OPENCV = "OPENCV" + NDI = "NDI" + IP = "IP" + VIRTUAL = "Virtual" + FILE = "File" + URL = "URL" + SCREEN_CAPTURE = "Screen Capture" + + def __init__(self, description: str, uuid: str, id: str | int, type: str): + self.description = description + self.uuid = uuid + self.id = id + self.type = type + + def __str__(self): + return f"{self.description} ({self.id})" diff --git a/camera_view.py b/camera_view.py new file mode 100644 index 0000000..4f36047 --- /dev/null +++ b/camera_view.py @@ -0,0 +1,499 @@ +import platform +import time +from PyQt6.QtWidgets import ( + QGraphicsView, + QGraphicsScene, + QGraphicsPixmapItem, +) +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QImage, QPixmap, QPainter +from PyQt6.QtCore import QThread, pyqtSignal +import cv2 +import numpy as np +from camera_info import CameraInfo +from ndi import NDICapture +from screen_capture_source import ScreenCapture + +from tesseract import TextDetector +import datetime +from datetime import datetime + +from text_detection_target import TextDetectionTargetWithResult +from sc_logging import logger + + +# Function to set the resolution +def set_resolution(cap, width, height): + cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) + + +# Function to get the resolution +def get_resolution(cap): + width = cap.get(cv2.CAP_PROP_FRAME_WIDTH) + height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT) + return width, height + + +# Function to set the camera to the highest resolution +def set_camera_highest_resolution(cap): + # List of common resolutions to try + resolutions = [(1920, 1080), (1280, 720), (1024, 768), (800, 600), (640, 480)] + + # grab one frame to make sure the camera is initialized + ret, _ = cap.read() + if not ret: + logger.warn("Error: camera not initialized") + return + + # grab the current resolution + width, height = get_resolution(cap) + + # If the current resolution is already the highest, return + if width >= resolutions[0][0] and height >= resolutions[0][1]: + logger.info( + "Camera is already at the highest resolution: %d x %d", width, height + ) + return + + # Try each resolution and select the highest one that works + highest_res = resolutions[0] + for resolution in resolutions: + logger.debug("Trying camera resolution: %d x %d", resolution[0], resolution[1]) + set_resolution(cap, *resolution) + test_width, test_height = get_resolution(cap) + logger.debug("Found camera resolution: %d x %d", test_width, test_height) + # if found resolution is within close range of the target resolution, use it + if ( + abs(test_width - resolution[0]) < 100 + and abs(test_height - resolution[1]) < 100 + ): + logger.debug( + "Camera highest resolution set to: %d x %d", test_width, test_height + ) + highest_res = (test_width, test_height) + break + + # Set the highest resolution + logger.info("Setting camera resolution to: %d x %d", highest_res[0], highest_res[1]) + 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 = pyqtSignal(object) + update_error = pyqtSignal(object) + ocr_result_signal = pyqtSignal(list) + + def __init__(self, camera_info: CameraInfo, detectionTargetsStorage): + super().__init__() + self.camera_info = camera_info + self.homography = None + self.detectionTargetsStorage = detectionTargetsStorage + self.textDetector = TextDetector() # initialize tesseract + self.show_binary = False + self.retry_count = 0 + self.retry_count_max = 50 + self.retry_high_water_mark = 25 + self.stabilizationEnabled = False + self.framestabilizer = FrameStabilizer() + self.video_capture = None + self.should_stop = False + self.frame_interval = 30 + self.update_frame_interval = 200 + self.preview_frame_interval = 1000 + self.fps = 1000 / self.frame_interval # frames per second + self.pps = 1000 / self.preview_frame_interval # previews per second + self.ups = 1000 / self.update_frame_interval # updates per second + self.fps_alpha = 0.1 # Smoothing factor + + def connect_video_capture(self) -> bool: + if self.camera_info.type == CameraInfo.CameraType.NDI: + self.video_capture = NDICapture(self.camera_info.uuid) + elif self.camera_info.type == CameraInfo.CameraType.SCREEN_CAPTURE: + self.video_capture = ScreenCapture(self.camera_info.id) + else: + os_name = platform.system() + if ( + os_name == "Windows" + and self.camera_info.type != CameraInfo.CameraType.FILE + ): + # on windows use the dshow backend + self.video_capture = cv2.VideoCapture( + self.camera_info.id, cv2.CAP_DSHOW + ) + else: + # for files/urls, mac and linux use the default backend + self.video_capture = cv2.VideoCapture(self.camera_info.id) + + if self.retry_count != self.retry_high_water_mark: + # at the high water mark this is a reconnect + self.retry_count = 0 + + if not self.video_capture.isOpened(): + logger.warn( + "Error: unable to open camera. Check if the camera is connected." + ) + self.update_error.emit("Error: Unable to play video stream") + logger.info("Camera thread stopped") + return False + + # attempt to set the highest resolution + # check if camera index is a OpenCV camera index + if self.camera_info.type == CameraInfo.CameraType.OPENCV: + # make sure to open the camera at the highest resolution + set_camera_highest_resolution(self.video_capture) + + return True + + def run(self): + description_ascii = ( + self.camera_info.description.encode("ascii", errors="ignore").decode() + if type(self.camera_info.description) == str + else str(self.camera_info.description) + ) + logger.info("Starting camera thread for: '%s'", description_ascii) + + if not self.connect_video_capture(): + self.should_stop = True + return + + self.last_update_timestamp = datetime.now() + self.last_frame_timestamp = datetime.now() + self.last_emit_time = datetime.now() + + while not self.should_stop: + if self.video_capture is None: + logger.warn("Error: video capture is None") + break + + if self.retry_count == self.retry_high_water_mark: + logger.warn("Error: retry high water mark exceeded") + # reconnect the video cap + if self.video_capture is not None: + self.video_capture.release() + self.video_capture = None + if not self.connect_video_capture(): + self.should_stop = True + break + self.sleep_fps_target() + self.retry_count += 1 + continue + if self.retry_count > self.retry_count_max: + logger.warn("Error: retry count exceeded") + self.should_stop = True + self.update_error.emit("Error: Unable to play video stream") + break + + # Read the frame from the camera + ret, frame_rgb = None, None + try: + ret, frame_rgb = self.video_capture.read() + except Exception as e: + self.retry_count += 1 + logger.exception( + "Error: unable to read frame from camera (retry count: %d), exception %s", + self.retry_count, + e, + ) + self.sleep_fps_target() + continue + + if not ret: + self.retry_count += 1 + if self.camera_info.type == CameraInfo.CameraType.FILE: + logger.debug("Restarting video file") + self.video_capture.set(cv2.CAP_PROP_POS_FRAMES, 0) + self.sleep_fps_target() + continue + logger.warn( + "Error: unable to read frame from camera, return value False (retry count: %d)", + self.retry_count, + ) + self.sleep_fps_target() + continue + + self.retry_count = 0 # good frame, reset the retry count + current_time = datetime.now() + + # calculate the frame rate + time_diff_ms = ( + current_time - self.last_frame_timestamp + ).microseconds / 1000 + if time_diff_ms > 0: + self.fps = ( + self.fps_alpha * (1000 / time_diff_ms) + + (1.0 - self.fps_alpha) * self.fps + ) + self.last_frame_timestamp = current_time + + # check that enough time has passed since last update + time_diff_ms = ( + current_time - self.last_update_timestamp + ).microseconds / 1000 + if time_diff_ms < self.update_frame_interval: + # dump this frame since not enough time has passed + self.sleep_fps_target() + continue + # process this frame + self.last_update_timestamp = current_time + self.ups = ( + self.fps_alpha * (1000 / time_diff_ms) + + (1.0 - self.fps_alpha) * self.ups + ) + + # Stabilize the frame + if self.stabilizationEnabled: + frame_rgb = self.framestabilizer.stabilize_frame(frame_rgb) + + # Apply the homography to the frame + if self.homography is not None: + frame_rgb = cv2.warpPerspective( + frame_rgb, self.homography, (frame_rgb.shape[1], frame_rgb.shape[0]) + ) + + gray = cv2.cvtColor(frame_rgb, cv2.COLOR_BGR2GRAY) + _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU) + + # Detect text in the target + if not self.detectionTargetsStorage.is_empty(): + detectionTargets = self.detectionTargetsStorage.get_data() + texts = self.textDetector.detect_multi_text( + binary, gray, detectionTargets, multi_crop=True + ) + if len(texts) > 0 and len(detectionTargets) == len(texts): + # augment the text detection targets with the results + results = [] + for i, result in enumerate(texts): + results.append( + TextDetectionTargetWithResult( + detectionTargets[i], + result.text, + result.state, + result.rect, + result.extra, + ) + ) + + # emit the results + self.ocr_result_signal.emit(results) + + # Emit the signal to update the pixmap once per second + time_diff_prev = (current_time - self.last_emit_time).total_seconds() * 1000 + if time_diff_prev >= self.preview_frame_interval: + if self.show_binary: + self.update_signal.emit(binary) + else: + self.update_signal.emit(frame_rgb) + self.last_emit_time = current_time + self.pps = ( + self.fps_alpha * (1000 / time_diff_prev) + + (1.0 - self.fps_alpha) * self.pps + ) + + self.sleep_fps_target() + + if self.video_capture is not None: + self.video_capture.release() + self.video_capture = None + + logger.info("Camera thread stopped") + + def sleep_fps_target(self): + time_diff_ms = (datetime.now() - self.last_frame_timestamp).microseconds / 1000 + if time_diff_ms < self.frame_interval: + time.sleep((self.frame_interval - time_diff_ms) / 1000.0) + + # on destroy, stop the timer + def __del__(self): + logger.info("Stopping camera") + self.should_stop = True + self.wait() + + def toggleStabilization(self, state): + self.stabilizationEnabled = state + if not state: + self.framestabilizer.reset() + + +class CameraView(QGraphicsView): + first_frame_received_signal = pyqtSignal() + + def __init__(self, camera_index, detectionTargetsStorage=None): + super().__init__() + self.scene = QGraphicsScene(self) + self.setScene(self.scene) + self.setRenderHint(QPainter.RenderHint.Antialiasing) + self.timerThread = TimerThread(camera_index, detectionTargetsStorage) + self.timerThread.update_signal.connect(self.update_pixmap) + self.timerThread.update_error.connect(self.error_event) + self.timerThread.start() + self.scenePixmapItem = None + self.detectionTargetsStorage = detectionTargetsStorage + self.firstFrameReceived = False + self.fps_text = None + self.error_text = None + self.showOSD = True + + def toggleOSD(self, state): + self.showOSD = state + if self.fps_text is not None: + self.fps_text.setVisible(state) + + def update_pixmap(self, frame): + if self.timerThread is None: + return + + # Create a QImage from the frame data + image = QImage( + frame.data, + frame.shape[1], + frame.shape[0], + frame.strides[0], + ( + QImage.Format.Format_Grayscale8 + if frame.ndim == 2 + else ( + QImage.Format.Format_BGR888 + if frame.shape[2] == 3 + else QImage.Format.Format_RGBA8888 + ) + ), + ) + + # Create a QPixmap from the QImage + pixmap = QPixmap.fromImage(image) + + if self.scenePixmapItem is None: + self.scenePixmapItem = QGraphicsPixmapItem(pixmap) + self.scene.addItem(self.scenePixmapItem) + self.scenePixmapItem.setZValue(0) + self.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) + else: + self.scenePixmapItem.setPixmap(pixmap) + + if not self.firstFrameReceived: + self.firstFrameReceived = True + self.first_frame_received_signal.emit() + + # update the fps text + fps_text = f"Frames/s: {self.timerThread.fps:.2f}\nUpdates/s: {self.timerThread.ups:.2f}\nPreviews/s: {self.timerThread.pps:.2f}" + if self.fps_text is None: + self.fps_text = self.scene.addText(fps_text) + self.fps_text.setPos(0, 0) + self.fps_text.setZValue(2) + self.fps_text.setDefaultTextColor(Qt.GlobalColor.white) + # scale the text according to the view size so its always the same size + self.fps_text.setScale(0.004 * self.width()) + else: + self.fps_text.setPlainText(fps_text) + + def error_event(self, error): + if self.error_text is not None: + self.scene.removeItem(self.error_text) + if error is not None: + logger.error("Error: %s", error) + # add the error to the scene + self.error_text = self.scene.addText(f"⚠️ {error}") + self.error_text.setDefaultTextColor(Qt.GlobalColor.red) + self.error_text.setScale(0.004 * self.width()) + # diplay error on the bottom of the video view + self.error_text.setPos( + 0, self.height() - self.error_text.boundingRect().height() - 10 + ) + + def setFourCornersForHomography(self, corners: list[tuple[int]]): + if corners is None or len(corners) != 4: + if self.timerThread is not None: + self.timerThread.homography = None + return + corners_as_array = np.array(corners, dtype=np.float32) + # Calculate bounding rectangle + x, y, w, h = cv2.boundingRect(corners_as_array) + # Destination points (corners of the bounding rectangle) + dst_points = np.array( + [[x, y], [x + w, y], [x + w, y + h], [x, y + h]], dtype=np.float32 + ) + # calculate the homography from the corners to the rect + self.timerThread.homography, _ = cv2.findHomography( + corners_as_array, dst_points + ) + + def closeEvent(self, event): + logger.debug("Close") + if self.timerThread is not None: + # Stop the timer thread + self.timerThread.should_stop = True + self.timerThread.wait() + self.timerThread = None + + # Call the base class closeEvent method + super().closeEvent(event) + + # on destroy, stop the timer + def __del__(self): + if self.timerThread is not None: + self.timerThread.should_stop = True + self.timerThread.wait() + self.timerThread = None diff --git a/connect_obs.ui b/connect_obs.ui new file mode 100644 index 0000000..572b6ea --- /dev/null +++ b/connect_obs.ui @@ -0,0 +1,98 @@ + + + Dialog + + + + 0 + 0 + 400 + 300 + + + + Connect OBS + + + + + + Websocket Server + + + + + + + + localhost + + + + + + + IP + + + + + + + Port + + + + + + + 4455 + + + + + + + Password + + + + + + + + + + + + + 0 + 0 + + + + Connect + + + + + + + + + + + 0 + 0 + + + + + + + + + + + + diff --git a/defaults.py b/defaults.py new file mode 100644 index 0000000..c1361f0 --- /dev/null +++ b/defaults.py @@ -0,0 +1,200 @@ +class FieldType: + # Enum for the type of the field + NUMBER = 0 + TIME = 1 + TEXT = 2 + + +# 0 - Time MM:ss and ss.m +# 1 - Time MM:ss +# 2 - Time ss.m +# 3 - Time 00-59 +# 4 - Shotclock 00-39 +# 5 - Score 1dd +# 6 - Score ddd +# 7 - Period 1-4 +# 8 - Period d +# 9 - Alphanumeric +# 10 - Any text +# 11 - Any number +# 12 - Custom +format_prefixes = [ + "^(?:(?:[0-5]?\\d:[0-5]\\d)|(?:[0-5]?\\d\\.\\d))$", # Time MM:ss and ss.m + "^[0-5]?\\d:[0-5]\\d$", # Time MM:ss + "^[0-5]?\\d\\.\\d$", # Time ss.m + "^[0-5]\\d$", # Time 00-59 + "^[0-3]\\d$", # Shotclock 00-39 + "^1?\\d{1,2}$", # Score 1dd + "^\\d{1,3}$", # Score ddd + "^[1-4]{1}$", # Period 1-4 + "^\\d{1}$", # Period d + "^[A-Za-z0-9]*$", # Alphanumeric + "^.*$", # Any text + "^\\d*$", # Any number + "^.*$", # Custom +] + + +# Default values for the scoreboard +default_boxes = [ + { + "name": "Home Score", + "type": FieldType.NUMBER, + "x": 0, + "y": 0, + "width": 120, + "height": 100, + "obs_source_name": "Home score", + "format_regex": format_prefixes[5], + "ordinal_indicator": False, + }, + { + "name": "Away Score", + "type": FieldType.NUMBER, + "x": 0, + "y": 0, + "width": 120, + "height": 100, + "obs_source_name": "Away score", + "format_regex": format_prefixes[5], + "ordinal_indicator": False, + }, + { + "name": "Time", + "type": FieldType.TIME, + "x": 0, + "y": 0, + "width": 170, + "height": 100, + "obs_source_name": "Clock", + "format_regex": format_prefixes[0], + "ordinal_indicator": False, + }, + { + "name": "Period", + "type": FieldType.NUMBER, + "x": 0, + "y": 0, + "width": 50, + "height": 80, + "obs_source_name": "Period", + "format_regex": format_prefixes[7], + "ordinal_indicator": True, + }, + { + "name": "Home Fouls", + "type": FieldType.NUMBER, + "x": 0, + "y": 0, + "width": 80, + "height": 80, + "obs_source_name": "#Home Fouls", + "format_regex": format_prefixes[11], + "ordinal_indicator": False, + }, + { + "name": "Away Fouls", + "type": FieldType.NUMBER, + "x": 0, + "y": 0, + "width": 80, + "height": 80, + "obs_source_name": "#Away Fouls", + "format_regex": format_prefixes[11], + "ordinal_indicator": False, + }, + { + "name": "Shot Clock", + "type": FieldType.NUMBER, + "x": 0, + "y": 0, + "width": 150, + "height": 100, + "obs_source_name": "shotclock", + "format_regex": format_prefixes[4], + "ordinal_indicator": False, + }, +] +default_custom_box_info = { + "type": FieldType.NUMBER, + "x": 0, + "y": 0, + "width": 150, + "height": 100, + "obs_source_name": "", + "format_regex": format_prefixes[11], + "ordinal_indicator": False, +} + + +def info_for_box_name(name): + # Get the info for a box name + for box in default_boxes: + if box["name"] == name: + return box + return default_custom_box_info + + +def normalize_settings_dict(settings, box_info): + # Normalize the settings dict with default values if they are not present + if not settings: + settings = {} + if not box_info: + box_info = { + "obs_source_name": "", + "format_regex": format_prefixes[11], + "type": FieldType.NUMBER, + "ordinal_indicator": False, + } + return { + "obs_source_name": ( + settings["obs_source_name"] + if "obs_source_name" in settings + else box_info["obs_source_name"] + ), + "format_regex": ( + settings["format_regex"] + if "format_regex" in settings + else box_info["format_regex"] + ), + "type": (settings["type"] if "type" in settings else box_info["type"]), + "smoothing": (settings["smoothing"] if "smoothing" in settings else False), + "skip_empty": (settings["skip_empty"] if "skip_empty" in settings else True), + "conf_thresh": (settings["conf_thresh"] if "conf_thresh" in settings else 0.5), + "cleanup_thresh": ( + settings["cleanup_thresh"] if "cleanup_thresh" in settings else 0 + ), + "dilate": (settings["dilate"] if "dilate" in settings else 1), + "skew": (settings["skew"] if "skew" in settings else 0), + "vscale": (settings["vscale"] if "vscale" in settings else 10), + "autocrop": (settings["autocrop"] if "autocrop" in settings else False), + "skip_similar_image": ( + settings["skip_similar_image"] + if "skip_similar_image" in settings + else False + ), + "remove_leading_zeros": ( + settings["remove_leading_zeros"] + if "remove_leading_zeros" in settings + else False + ), + "rescale_patch": ( + settings["rescale_patch"] if "rescale_patch" in settings else True + ), + "normalize_wh_ratio": ( + settings["normalize_wh_ratio"] + if "normalize_wh_ratio" in settings + else False + ), + "invert_patch": ( + settings["invert_patch"] if "invert_patch" in settings else False + ), + "binarization_method": ( + settings["binarization_method"] if "binarization_method" in settings else 0 + ), + "ordinal_indicator": ( + settings["ordinal_indicator"] + if "ordinal_indicator" in settings + else box_info["ordinal_indicator"] + ), + } diff --git a/entitlements.plist b/entitlements.plist new file mode 100644 index 0000000..df08067 --- /dev/null +++ b/entitlements.plist @@ -0,0 +1,16 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + com.apple.security.device.camera + + + diff --git a/file_output.py b/file_output.py new file mode 100644 index 0000000..8e7903d --- /dev/null +++ b/file_output.py @@ -0,0 +1,100 @@ +import datetime +from os import path + +from text_detection_target import TextDetectionTargetWithResult +from sc_logging import logger + + +def save_text_files(results, out_folder, append_method_index): + for targetWithResult in results: + if targetWithResult.result is None: + continue + if ( + "skip_empty" in targetWithResult.settings + and targetWithResult.settings["skip_empty"] + and len(targetWithResult.result) == 0 + ): + continue + if ( + targetWithResult.result_state + != TextDetectionTargetWithResult.ResultState.Success + ): + continue + + output_file_path = path.abspath( + path.join( + out_folder, + f"{targetWithResult.name}.txt", + ) + ) + append_method = "w" + if append_method_index == 1 or append_method_index == 2: + append_method = "a" + + # check if the file exists, if it does, append the result to the file + try: + with open(output_file_path, append_method) as f: + f.write(f"{targetWithResult.result}") + if append_method == "a": + f.write("\n") + except Exception as e: + logger.error(f"Error writing to file: {e}") + + +def save_csv(results, out_folder, append_method_index, first_csv_append): + if not out_folder: + return + + # add timestamp as first column + result_concat_for_aggreagate = f"{datetime.datetime.now().isoformat()}," + header = "Timestamp," + for targetWithResult in results: + header += f"{targetWithResult.name}," + if ( + targetWithResult.result_state + != TextDetectionTargetWithResult.ResultState.Success + ): + result_concat_for_aggreagate += "," + else: + result_concat_for_aggreagate += ( + f"{targetWithResult.result}," if targetWithResult.result else "," + ) + + aggregate_output_file_path = path.abspath(path.join(out_folder, "results.csv")) + append_method = "w" + if append_method_index == 0 or append_method_index == 2: + append_method = "a" + + try: + with open(aggregate_output_file_path, append_method) as f: + # if this is the first-time-appending or truncating, add the header + if first_csv_append or append_method == "w": + f.write(header + "\n") + first_csv_append = False + f.write(result_concat_for_aggreagate + "\n") + except Exception as e: + logger.error(f"Error writing to aggregate file: {e}") + + +def save_xml(results, out_folder): + if not out_folder: + return + + aggregate_output_file_path = path.abspath(path.join(out_folder, "results.xml")) + + try: + with open(aggregate_output_file_path, "w") as f: + # add xml preamble + f.write('\n') + + f.write("") + for targetWithResult in results: + # serialize dictionary to xml + dictionary = targetWithResult.to_dict() + f.write("") + for key in dictionary: + f.write(f"<{key}>{dictionary[key]}") + f.write("") + f.write("") + except Exception as e: + logger.error(f"Error writing to xml file: {e}") diff --git a/get_camera_info.py b/get_camera_info.py new file mode 100644 index 0000000..cc753fe --- /dev/null +++ b/get_camera_info.py @@ -0,0 +1,72 @@ +import platform +from camera_info import CameraInfo +from ndi import NDICapture +from sc_logging import logger + +# This file contains the code to get the camera information for the current OS + + +def get_camera_info_windows(): + from win32DeviceEnum import enum_devices_dshow + + device_info = [] + cameras = enum_devices_dshow.enumerate_video_devices_dshow() + for camera in cameras: + device_info.append( + CameraInfo( + camera[1], str(camera[0]), camera[0], CameraInfo.CameraType.OPENCV + ) + ) + + return device_info + + +def get_camera_info_mac(): + import AVFoundation as AV + + device_info = [] + devices = AV.AVCaptureDevice.devicesWithMediaType_(AV.AVMediaTypeVideo) + + for i, device in enumerate(devices): + device_info.append( + CameraInfo( + device.localizedName(), + device.uniqueID(), + i, + CameraInfo.CameraType.OPENCV, + ) + ) + + # sort by the ID, since opencv sorts by ID + device_info.sort(key=lambda x: x.uuid) + # update the ID + for i, camera in enumerate(device_info): + camera.id = i + + return device_info + + +def get_camera_info_linux(): + # Basic method using /dev/video* enumeration + device_info = [ + CameraInfo(f"Camera {i}", f"/dev/{dev}", i, CameraInfo.CameraType.OPENCV) + for i, dev in enumerate(device_info) + ] + return device_info + + +def get_camera_info() -> list[CameraInfo]: + logger.info("Getting cameras info") + os_name = platform.system() + cameras = [] + if os_name == "Windows": + cameras += get_camera_info_windows() + elif os_name == "Darwin": + cameras += get_camera_info_mac() + elif os_name == "Linux": + cameras += get_camera_info_linux() + + # Add NDI cameras + cameras += NDICapture.get_camera_info_ndi() + + return cameras diff --git a/http_server.py b/http_server.py new file mode 100644 index 0000000..53162f5 --- /dev/null +++ b/http_server.py @@ -0,0 +1,200 @@ +import asyncio +import http.server +import http.client +import os +import signal +import threading +from fastapi import FastAPI, Query +from fastapi.responses import HTMLResponse, JSONResponse, Response +from fastapi.middleware.cors import CORSMiddleware +import csv +import xml.etree.ElementTree as ET +from io import StringIO + +import uvicorn + +from text_detection_target import TextDetectionTargetWithResult +from sc_logging import logger + +PORT = 18099 +http_results = [] +loop: asyncio.AbstractEventLoop = None + +app = FastAPI() + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Allows all origins + allow_credentials=True, + allow_methods=["*"], # Allows all methods + allow_headers=["*"], # Allows all headers +) + +HTML_PAGE = """ + + + + + +Scoreboard + + + + + +
+
+
+
Home Team
+
0
+
Fouls: 0
+
+
+
00:00
+
+
+
Away Team
+
0
+
Fouls: 0
+
+
+
+ + + +""" + + +@app.get("/scoresight", response_class=HTMLResponse) +async def get_html(): + return HTMLResponse(content=HTML_PAGE) + + +# Example JSON response +@app.get("/json") +async def get_json(pivot=Query(None)): + # check querystring for "?pivot" to pivot the data + data = {} + if pivot is not None: + for result in http_results: + if result.result_state == TextDetectionTargetWithResult.ResultState.Success: + data[result.name] = result.result + else: + data = [result.to_dict() for result in http_results] + + return JSONResponse(content=data) + + +@app.get("/xml") +async def get_xml(pivot=Query(None)): + root = ET.Element("data") + if pivot is not None: + data = {} + for result in http_results: + if result.result_state == TextDetectionTargetWithResult.ResultState.Success: + data[result.name] = result.result + for key in data: + # transform key to camelCase + key_xml = "".join([word.title() for word in key.split(" ")]) + ET.SubElement(root, key_xml).text = data[key] + else: + for targetWithResult in http_results: + resultEl = ET.SubElement(root, "result") + resultEl.set("name", targetWithResult.name) + resultEl.set("result", targetWithResult.result) + resultEl.set("result_state", targetWithResult.result_state.name) + resultEl.set("x", str(targetWithResult.x())) + resultEl.set("y", str(targetWithResult.y())) + resultEl.set("width", str(targetWithResult.width())) + resultEl.set("height", str(targetWithResult.height())) + + return Response(content=ET.tostring(root), media_type="text/xml") + + +@app.get("/csv") +async def get_csv(): + output = StringIO() + csv_writer = csv.writer(output) + csv_writer.writerow(["Name", "Text", "X", "Y", "Width", "Height"]) + for result in http_results: + csv_writer.writerow( + [ + result.name, + result.result, + result.x, + result.y, + result.width, + result.height, + ] + ) + return Response(content=output.getvalue(), media_type="text/csv") + + +def start_http_server(): + def run_uvicorn(): + config = uvicorn.Config(app=app, host="0.0.0.0", port=PORT, loop="asyncio") + server = uvicorn.Server(config) + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + logger.info(f"Server starting at port {PORT}") + loop.run_until_complete(server.serve()) + loop.close() + logger.info("Server thread stopped") + + # Start Uvicorn server in a separate thread + server_thread = threading.Thread(target=run_uvicorn) + server_thread.start() + + +@app.get("/shutdown") +async def shutdown(): + os.kill(os.getpid(), signal.SIGINT) + return {"message": "Initiating shutdown..."} + + +def stop_http_server(): + logger.info("Stopping server...") + conn = http.client.HTTPConnection("localhost", PORT) + conn.request("GET", "/shutdown") + conn.close() + logger.info("Server stopped") + + +def update_http_server(results: list[TextDetectionTargetWithResult]): + global http_results + http_results = results diff --git a/icons/MacOS_icon.png b/icons/MacOS_icon.png new file mode 100644 index 0000000..b6dcdf5 Binary files /dev/null and b/icons/MacOS_icon.png differ diff --git a/icons/MacOS_icon_Pro.png b/icons/MacOS_icon_Pro.png new file mode 100644 index 0000000..068ac44 Binary files /dev/null and b/icons/MacOS_icon_Pro.png differ diff --git a/icons/Windows-icon-open.ico b/icons/Windows-icon-open.ico new file mode 100644 index 0000000..fb7f12d Binary files /dev/null and b/icons/Windows-icon-open.ico differ diff --git a/icons/Windows-icon.ico b/icons/Windows-icon.ico new file mode 100644 index 0000000..d76c788 Binary files /dev/null and b/icons/Windows-icon.ico differ diff --git a/icons/circle-check.svg b/icons/circle-check.svg new file mode 100644 index 0000000..3dffd1d --- /dev/null +++ b/icons/circle-check.svg @@ -0,0 +1 @@ + diff --git a/icons/circle-x.svg b/icons/circle-x.svg new file mode 100644 index 0000000..5314d19 --- /dev/null +++ b/icons/circle-x.svg @@ -0,0 +1 @@ + diff --git a/icons/plus.svg b/icons/plus.svg new file mode 100644 index 0000000..8951274 --- /dev/null +++ b/icons/plus.svg @@ -0,0 +1 @@ + diff --git a/icons/splash.png b/icons/splash.png new file mode 100644 index 0000000..046d85d Binary files /dev/null and b/icons/splash.png differ diff --git a/icons/trash.svg b/icons/trash.svg new file mode 100644 index 0000000..a673761 --- /dev/null +++ b/icons/trash.svg @@ -0,0 +1 @@ + diff --git a/log_view.py b/log_view.py new file mode 100644 index 0000000..5e67421 --- /dev/null +++ b/log_view.py @@ -0,0 +1,53 @@ +from os import path +import platform +from PyQt6.QtWidgets import QDialog +from PyQt6.QtCore import QTimer +from PyQt6.uic import loadUi +from sc_logging import log_file_path + + +class LogViewerDialog(QDialog): + def __init__(self): + super().__init__() + loadUi(path.abspath(path.join(path.dirname(__file__), "log_view.ui")), self) + self.timer = QTimer() + self.timer.timeout.connect(self.update_ui) + self.timer.start(1000) # Update UI every 1 second + self.current_log_data = "" + self.pushButton_openlogfolder.clicked.connect(self.open_log_folder) + + def open_log_folder(self): + # Open the folder containing the log file + # check if this is windows, mac or linux + if path.exists(log_file_path): + os_name = platform.system() + + if os_name == "Windows": + from os import startfile + + startfile(path.dirname(log_file_path)) + elif os_name == "Linux": + import subprocess + + subprocess.Popen(["xdg-open", path.dirname(log_file_path)]) + elif os_name == "Darwin": + import subprocess + + subprocess.Popen(["open", path.dirname(log_file_path)]) + + def update_ui(self): + with open(log_file_path, "r") as log_file: + lines = log_file.readlines() + last_1000_lines = lines[-1000:] + log_data = "".join(last_1000_lines) + if log_data == self.current_log_data: + return + self.current_log_data = log_data + # Update the UI with the log data + self.textEdit_log.setPlainText(log_data) + if self.checkBox_autoScroll.isChecked(): + # scroll to the bottom + self.textEdit_log.verticalScrollBar().setValue( + self.textEdit_log.verticalScrollBar().maximum() + ) + self.scrollArea.ensureWidgetVisible(self.textEdit_log) diff --git a/log_view.ui b/log_view.ui new file mode 100644 index 0000000..018387e --- /dev/null +++ b/log_view.ui @@ -0,0 +1,125 @@ + + + Dialog + + + + 0 + 0 + 553 + 300 + + + + Dialog + + + + + + 0 + + + 0 + + + + + Auto Scroll + + + true + + + + + + + Open Log Folder + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Close + + + + + + + + + true + + + + + 0 + 0 + 533 + 250 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/main.py b/main.py new file mode 100644 index 0000000..cd2f6a9 --- /dev/null +++ b/main.py @@ -0,0 +1,1220 @@ +from functools import partial +import os +import platform +import sys +import datetime +from PyQt6.QtWidgets import ( + QApplication, + QMainWindow, + QFileDialog, + QLabel, + QDialog, + QInputDialog, + QTableWidgetItem, +) +from PyQt6.uic import loadUi +from PyQt6.QtGui import QIcon, QStandardItemModel, QStandardItem +from PyQt6.QtCore import Qt, pyqtSignal, pyqtSlot +from dotenv import load_dotenv +from os import path + +from camera_info import CameraInfo +from get_camera_info import get_camera_info +from http_server import start_http_server, update_http_server, stop_http_server +from screen_capture_source import ScreenCapture +from source_view import ImageViewer +from defaults import ( + default_boxes, + info_for_box_name, + normalize_settings_dict, + format_prefixes, +) + +from storage import ( + TextDetectionTargetMemoryStorage, + fetch_data, + remove_data, + store_data, + store_custom_box_name, + rename_custom_box_name_in_storage, + remove_custom_box_name_in_storage, + fetch_custom_box_names, +) +from obs_websocket import ( + create_obs_scene_from_export, + open_obs_websocket, + update_text_source, +) + +from text_detection_target import TextDetectionTarget, TextDetectionTargetWithResult +from sc_logging import logger +from update_check import check_for_updates +from log_view import LogViewerDialog +import file_output +from vmix_output import VMixAPI + + +def clear_layout(layout): + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + widget = None + else: + clear_layout(item.layout()) + + +class MainWindow(QMainWindow): + # add a signal to update sources + update_sources = pyqtSignal(list) + get_sources = pyqtSignal() + + def __init__(self): + super().__init__() + path_to_dat = path.abspath(path.join(path.dirname(__file__), "mainwindow.ui")) + loadUi(path_to_dat, self) + # load env variables + load_dotenv() + self.setWindowTitle( + f"{fetch_data('scoresight.json', 'product_name')} - v{os.getenv('LOCAL_RELEASE_TAG')} - Registered to: {fetch_data('scoresight.json', 'customer_name')}" + ) + if platform.system() == "Windows": + # set the icon + self.setWindowIcon( + QIcon( + path.abspath( + path.join(path.dirname(__file__), "icons/Windows-icon-open.ico") + ) + ) + ) + + menubar = self.menuBar() + file_menu = menubar.addMenu("File") + + # check for updates + check_for_updates(False) + file_menu.addAction("Check for Updates", lambda: check_for_updates(True)) + file_menu.addAction("About", self.openAboutDialog) + file_menu.addAction("View Current Log", self.openLogsDialog) + + file_menu.addAction("Import Configuration", self.importConfiguration) + file_menu.addAction("Export Configuration", self.exportConfiguration) + + self.pushButton_connectObs.clicked.connect(self.openOBSConnectModal) + self.statusbar.showMessage("OBS: Not Connected") + + self.vmixUiSetup() + + start_http_server() + + self.pushButton_stabilize.setEnabled(True) + self.pushButton_stabilize.clicked.connect(self.toggleStabilize) + + self.widget_detectionCadence.setVisible(True) + self.horizontalSlider_detectionCadence.setValue( + fetch_data("scoresight.json", "detection_cadence", 5) + ) + self.horizontalSlider_detectionCadence.valueChanged.connect( + self.detectionCadenceChanged + ) + self.toolButton_addBox.clicked.connect(self.addBox) + self.toolButton_removeBox.clicked.connect(self.removeCustomBox) + + self.obs_websocket_client = None + + ocr_models = [ + "Daktronics", + "General Scoreboard", + "General Fonts (English)", + "General Scoreboard Large", + ] + self.comboBox_ocrModel.addItems(ocr_models) + self.comboBox_ocrModel.setCurrentIndex( + fetch_data("scoresight.json", "ocr_model", 1) + ) # default to General Scoreboard + + self.frame_source_view.setEnabled(False) + self.groupBox_target_settings.setEnabled(False) + self.pushButton_makeBox.clicked.connect(self.makeBox) + self.pushButton_removeBox.clicked.connect(self.removeBox) + self.tableWidget_boxes.itemClicked.connect(self.listItemClicked) + # connect the edit triggers + self.tableWidget_boxes.itemDoubleClicked.connect(self.editBoxName) + self.pushButton_refresh_sources.clicked.connect(lambda: self.get_sources.emit()) + self.detectionTargetsStorage = TextDetectionTargetMemoryStorage() + self.detectionTargetsStorage.data_changed.connect(self.detectionTargetsChanged) + self.pushButton_createOBSScene.clicked.connect(self.createOBSScene) + self.pushButton_selectFolder.clicked.connect(self.selectOutputFolder) + self.toolButton_trashFolder.clicked.connect(self.clearOutputFolder) + self.pushButton_stopUpdates.toggled.connect(self.toggleStopUpdates) + self.comboBox_ocrModel.currentIndexChanged.connect(self.ocrModelChanged) + self.pushButton_restoreDefaults.clicked.connect(self.restoreDefaults) + self.toolButton_zoomReset.clicked.connect(self.resetZoom) + self.toolButton_osd.toggled.connect(self.toggleOSD) + self.toolButton_showOCRrects.toggled.connect(self.toggleOCRRects) + self.checkBox_smoothing.toggled.connect( + partial(self.genericSettingsChanged, "smoothing") + ) + self.checkBox_skip_empty.toggled.connect( + partial(self.genericSettingsChanged, "skip_empty") + ) + self.horizontalSlider_conf_thresh.valueChanged.connect(self.confThreshChanged) + self.lineEdit_format.textChanged.connect( + partial(self.genericSettingsChanged, "format_regex") + ) + self.comboBox_fieldType.currentIndexChanged.connect( + partial(self.genericSettingsChanged, "type") + ) + self.checkBox_skip_similar_image.toggled.connect( + partial(self.genericSettingsChanged, "skip_similar_image") + ) + self.checkBox_autocrop.toggled.connect( + partial(self.genericSettingsChanged, "autocrop") + ) + self.horizontalSlider_cleanup.valueChanged.connect(self.cleanupThreshChanged) + self.horizontalSlider_dilate.valueChanged.connect( + partial(self.genericSettingsChanged, "dilate") + ) + self.horizontalSlider_skew.valueChanged.connect( + partial(self.genericSettingsChanged, "skew") + ) + self.horizontalSlider_vscale.valueChanged.connect( + partial(self.genericSettingsChanged, "vscale") + ) + self.checkBox_removeLeadingZeros.toggled.connect( + partial(self.genericSettingsChanged, "remove_leading_zeros") + ) + self.checkBox_rescalePatch.toggled.connect( + partial(self.genericSettingsChanged, "rescale_patch") + ) + self.checkBox_normWHRatio.toggled.connect( + partial(self.genericSettingsChanged, "normalize_wh_ratio") + ) + self.checkBox_invertPatch.toggled.connect( + partial(self.genericSettingsChanged, "invert_patch") + ) + self.checkBox_ordinalIndicator.toggled.connect( + partial(self.genericSettingsChanged, "ordinal_indicator") + ) + self.comboBox_binarizationMethod.currentIndexChanged.connect( + partial(self.genericSettingsChanged, "binarization_method") + ) + self.comboBox_formatPrefix.currentIndexChanged.connect(self.formatPrefixChanged) + + # populate the tableWidget_boxes with the default and custom boxes + custom_boxes_names = fetch_custom_box_names() + + for box_name in [box["name"] for box in default_boxes] + custom_boxes_names: + item = QTableWidgetItem( + QIcon( + path.abspath( + path.join(path.dirname(__file__), "icons/circle-x.svg") + ) + ), + box_name, + ) + item.setData(Qt.ItemDataRole.UserRole, "unchecked") + self.tableWidget_boxes.insertRow(self.tableWidget_boxes.rowCount()) + self.tableWidget_boxes.setItem( + self.tableWidget_boxes.rowCount() - 1, 0, item + ) + disabledItem = QTableWidgetItem() + disabledItem.setFlags(Qt.ItemFlag.NoItemFlags) + self.tableWidget_boxes.setItem( + self.tableWidget_boxes.rowCount() - 1, 1, disabledItem + ) + + self.image_viewer = None + self.obs_connect_modal = None + self.source_name = None + self.updateOCRResults = True + self.log_dialog = None + + if fetch_data("scoresight.json", "obs"): + self.connectObs() + + self.out_folder = fetch_data("scoresight.json", "output_folder") + if self.out_folder: + if not path.exists(self.out_folder): + self.out_folder = None + remove_data("scoresight.json", "output_folder") + else: + self.lineEdit_folder.setText(self.out_folder) + + self.first_csv_append = True + self.last_aggregate_save = datetime.datetime.now() + self.checkBox_saveCsv.toggled.connect(self.save_csv_toggled) + self.checkBox_saveCsv.setChecked( + fetch_data("scoresight.json", "save_csv", False) + ) + self.checkBox_saveXML.toggled.connect(self.save_xml_toggled) + self.checkBox_saveXML.setChecked( + fetch_data("scoresight.json", "save_xml", False) + ) + self.comboBox_appendMethod.currentIndexChanged.connect(self.appendMethodChanged) + self.horizontalSlider_aggsPerSecond.valueChanged.connect( + self.aggsPerSecondChanged + ) + self.comboBox_appendMethod.setCurrentIndex( + fetch_data("scoresight.json", "append_method", 3) + ) + self.horizontalSlider_aggsPerSecond.setValue( + fetch_data("scoresight.json", "aggs_per_second", 5) + ) + + self.update_sources.connect(self.updateSources) + self.get_sources.connect(self.getSources) + self.get_sources.emit() + + def formatPrefixChanged(self, index): + if index == 12: + return # do nothing if "Select Preset" is selected + # based on the selected index, set the format prefix + # change lineEdit_format to the selected format prefix + self.lineEdit_format.setText(format_prefixes[index]) + + def importConfiguration(self): + # open a file dialog to select a configuration file + file, _ = QFileDialog.getOpenFileName( + self, "Open Configuration File", "", "Configuration Files (*.json)" + ) + if not file: + return + # load the configuration from the file + if not self.detectionTargetsStorage.loadBoxesFromFile(file): + # show an error message + self.statusbar.showMessage("Error loading configuration file") + + def exportConfiguration(self): + # open a file dialog to select the output file + file, _ = QFileDialog.getSaveFileName( + self, "Save Configuration File", "", "Configuration Files (*.json)" + ) + if not file: + return + # save the configuration to the file + self.detectionTargetsStorage.saveBoxesToFile(file) + + def toggleOSD(self, value): + if self.image_viewer: + self.image_viewer.toggleOSD(value) + + def toggleOCRRects(self, value): + if self.image_viewer: + self.image_viewer.toggleOCRRects(value) + + def save_csv_toggled(self, value): + store_data("scoresight.json", "save_csv", value) + + def save_xml_toggled(self, value): + store_data("scoresight.json", "save_xml", value) + + def appendMethodChanged(self, index): + store_data("scoresight.json", "append_method", index) + + def aggsPerSecondChanged(self, value): + store_data("scoresight.json", "aggs_per_second", value) + + def resetZoom(self): + if self.image_viewer: + self.image_viewer.resetZoom() + + def detectionCadenceChanged(self, detections_per_second): + store_data("scoresight.json", "detection_cadence", detections_per_second) + if self.image_viewer and self.image_viewer.timerThread: + # convert the detections_per_second to milliseconds + self.image_viewer.timerThread.update_frame_interval = ( + 1000 / detections_per_second + ) + + def ocrModelChanged(self, index): + store_data("scoresight.json", "ocr_model", index) + # update the ocr model in the text detector + if ( + self.image_viewer + and self.image_viewer.timerThread + and self.image_viewer.timerThread.textDetector + ): + self.image_viewer.timerThread.textDetector.setOcrModel(index) + + def openLogsDialog(self): + if self.log_dialog is None: + # open the logs dialog + self.log_dialog = LogViewerDialog() + self.log_dialog.setWindowTitle("Logs") + + # show the dialog, non modal + self.log_dialog.show() + + def openAboutDialog(self): + # open the about dialog + about_dialog = QDialog() + loadUi( + path.abspath(path.join(path.dirname(__file__), "about.ui")), + about_dialog, + ) + about_dialog.setWindowTitle("About ScoreSight") + about_dialog.exec() + + def toggleStabilize(self): + if not self.image_viewer: + return + # start or stop the stabilization + self.image_viewer.toggleStabilization(self.pushButton_stabilize.isChecked()) + + # def toggleHttpServer(self): + # if not self.pushButton_starthttpserver.isChecked(): + # # stop the http server + # stop_http_server() + # # change the button text to "start the http server" + # self.pushButton_starthttpserver.setText("▶️ Start the server") + # return + # else: + # # start the http server + # start_http_server() + # # change the button text to "stop the http server" + # self.pushButton_starthttpserver.setText("🛑 Stop the server") + + def toggleStopUpdates(self, value): + self.statusbar.showMessage("Stopped updates" if value else "Resumed updates") + self.updateOCRResults = not value + # change the text on the button + self.pushButton_stopUpdates.setText( + "▶️ Resume updates" if value else "🛑 Stop updates" + ) + + def selectOutputFolder(self): + # open a Qt dialog to select the output folder + folder = QFileDialog.getExistingDirectory( + self, + "Select Output Folder", + fetch_data("scoresight.json", "output_folder"), + options=QFileDialog.Option.ShowDirsOnly, + ) + if folder and len(folder) > 0: + self.lineEdit_folder.setText(folder) + self.out_folder = folder + store_data("scoresight.json", "output_folder", folder) + + def clearOutputFolder(self): + # clear the output folder + self.lineEdit_folder.setText("") + self.out_folder = None + remove_data("scoresight.json", "output_folder") + + def editSettings(self, settingsMutatorCallback): + # update the selected item's settings in the detectionTargetsStorage + item = self.tableWidget_boxes.currentItem() + if not item: + logger.info("no item selected") + return + item_name = item.text() + item_obj = self.detectionTargetsStorage.find_item_by_name(item_name) + if not item_obj: + logger.info("item not found: %s", item_name) + return + item_obj = settingsMutatorCallback(item_obj) + self.detectionTargetsStorage.edit_item(item_name, item_obj) + + def restoreDefaults(self): + # restore the default settings for the selected item + def restoreDefaultsSettings(item_obj): + info = info_for_box_name(item_obj.name) + item_obj.settings = normalize_settings_dict({}, info) + return item_obj + + self.editSettings(restoreDefaultsSettings) + self.populateSettings(self.tableWidget_boxes.currentItem().text()) + + def confThreshChanged(self): + def editConfThreshSettings(item_obj): + item_obj.settings["conf_thresh"] = ( + float(self.horizontalSlider_conf_thresh.value()) / 100.0 + ) + return item_obj + + self.editSettings(editConfThreshSettings) + + def cleanupThreshChanged(self): + def editCleanupThreshSettings(item_obj): + item_obj.settings["cleanup_thresh"] = ( + float(self.horizontalSlider_cleanup.value()) / 100.0 + ) + return item_obj + + self.editSettings(editCleanupThreshSettings) + + def genericSettingsChanged(self, settingName, value): + def editGenericSettings(item_obj): + item_obj.settings[settingName] = value + return item_obj + + self.editSettings(editGenericSettings) + + def vmixConnectionChanged(self): + self.vmixUpdater = VMixAPI( + self.lineEdit_vmixHost.text(), + self.lineEdit_vmixPort.text(), + self.inputLineEdit_vmix.text(), + {}, + ) + store_data("scoresight.json", "vmix_host", self.lineEdit_vmixHost.text()) + store_data("scoresight.json", "vmix_port", self.lineEdit_vmixPort.text()) + store_data("scoresight.json", "vmix_input", self.inputLineEdit_vmix.text()) + + def vmixMappingChanged(self, _): + # store entire mapping data in scoresight.json + mapping = {} + for i in range(self.tableView_vmixMapping.model().rowCount()): + item = self.tableView_vmixMapping.model().item(i, 0) + value = self.tableView_vmixMapping.model().item(i, 1) + if item and value: + mapping[item.text()] = value.text() + store_data("scoresight.json", "vmix_mapping", mapping) + self.vmixUpdater.set_field_mapping(mapping) + + def vmixUiSetup(self): + # populate the vmix connection from storage + self.lineEdit_vmixHost.setText( + fetch_data("scoresight.json", "vmix_host", "localhost") + ) + self.lineEdit_vmixPort.setText( + fetch_data("scoresight.json", "vmix_port", "8099") + ) + self.inputLineEdit_vmix.setText( + fetch_data("scoresight.json", "vmix_input", "1") + ) + # connect the lineEdits to vmixConnectionChanged + self.lineEdit_vmixHost.textChanged.connect(self.vmixConnectionChanged) + self.lineEdit_vmixPort.textChanged.connect(self.vmixConnectionChanged) + self.inputLineEdit_vmix.textChanged.connect(self.vmixConnectionChanged) + + # create the vmixUpdater + self.vmixUpdater = VMixAPI( + self.lineEdit_vmixHost.text(), + self.lineEdit_vmixPort.text(), + self.inputLineEdit_vmix.text(), + {}, + ) + # add standard item model to the tableView_vmixMapping + self.tableView_vmixMapping.setModel(QStandardItemModel()) + mapping = fetch_data("scoresight.json", "vmix_mapping", {}) + if mapping: + self.vmixUpdater.set_field_mapping(mapping) + + self.tableView_vmixMapping.model().itemChanged.connect(self.vmixMappingChanged) + + self.pushButton_startvmix.toggled.connect(self.togglevMix) + + def togglevMix(self, value): + if not self.vmixUpdater: + return + if value: + self.pushButton_startvmix.setText("🛑 Stop vMix") + self.vmixUpdater.running = True + else: + self.pushButton_startvmix.setText("▶️ Start vMix") + self.vmixUpdater.running = False + + def updatevMixTable(self, detectionTargets): + mapping_storage = fetch_data("scoresight.json", f"vmix_mapping") + model = QStandardItemModel() + model.blockSignals(True) + + for box in detectionTargets: + # add the detection to the vmix output mapping: tableView_vmixMapping + # check if the table already has the detectionTarget + items = model.findItems(box.name, Qt.MatchFlag.MatchExactly) + if len(items) == 0: + # add the item to the list + row = model.rowCount() + model.insertRow(row) + model.setItem(row, 0, QStandardItem(box.name)) + # the first item shouldn't be editable + model.item(row, 0).setFlags(Qt.ItemFlag.NoItemFlags) + if mapping_storage and box.name in mapping_storage: + model.setItem(row, 1, QStandardItem(mapping_storage[box.name])) + else: + model.setItem(row, 1, QStandardItem(box.name)) + else: + # update the item in the list + item = items[0] + row = item.row() + # get value from storage + if mapping_storage and box.name in mapping_storage: + model.setItem(row, 1, QStandardItem(mapping_storage[box.name])) + else: + model.setItem(row, 1, QStandardItem(box.name)) + # remove the items that are not in the detectionTargets + for i in range(model.rowCount()): + item = model.item(i, 0) + if not any([box.name == item.text() for box in detectionTargets]): + model.removeRow(i) + + model.blockSignals(False) + self.tableView_vmixMapping.setModel(model) + + def detectionTargetsChanged(self, detectionTargets): + for box in detectionTargets: + # change the list icon to green checkmark + items = self.tableWidget_boxes.findItems( + box.name, Qt.MatchFlag.MatchExactly + ) + if len(items) == 0: + # add the item to the list + item = QTableWidgetItem(box.name) + self.tableWidget_boxes.setItem( + self.tableWidget_boxes.rowCount(), 0, item + ) + else: + item = items[0] + item.setIcon( + QIcon( + path.abspath( + path.join(path.dirname(__file__), "icons/circle-check.svg") + ) + ) + ) + item.setData(Qt.ItemDataRole.UserRole, "checked") + + self.updatevMixTable(detectionTargets) + + # if save_csv is enabled, truncate the aggregate file + if self.checkBox_saveCsv.isChecked() and self.out_folder: + csv_output_file_path = path.abspath( + path.join(self.out_folder, "results.csv") + ) + try: + with open(csv_output_file_path, "w") as f: + f.write("") + self.first_csv_append = True + self.last_aggregate_save = datetime.datetime.now() + except Exception as e: + logger.error(f"Error truncating aggregate file: {e}") + + def populateSettings(self, name): + self.lineEdit_format.blockSignals(True) + self.comboBox_fieldType.blockSignals(True) + self.checkBox_smoothing.blockSignals(True) + self.checkBox_skip_empty.blockSignals(True) + self.horizontalSlider_conf_thresh.blockSignals(True) + self.checkBox_autocrop.blockSignals(True) + self.checkBox_skip_similar_image.blockSignals(True) + self.horizontalSlider_cleanup.blockSignals(True) + self.horizontalSlider_dilate.blockSignals(True) + self.horizontalSlider_skew.blockSignals(True) + self.horizontalSlider_vscale.blockSignals(True) + self.checkBox_removeLeadingZeros.blockSignals(True) + self.checkBox_rescalePatch.blockSignals(True) + self.checkBox_normWHRatio.blockSignals(True) + self.checkBox_invertPatch.blockSignals(True) + self.checkBox_ordinalIndicator.blockSignals(True) + self.comboBox_binarizationMethod.blockSignals(True) + self.comboBox_formatPrefix.blockSignals(True) + + # populate the settings from the detectionTargetsStorage + item_obj = self.detectionTargetsStorage.find_item_by_name(name) + if not item_obj: + self.lineEdit_format.setText("") + self.comboBox_fieldType.setCurrentIndex(0) + self.checkBox_smoothing.setChecked(True) + self.checkBox_skip_empty.setChecked(True) + self.horizontalSlider_conf_thresh.setValue(50) + self.checkBox_autocrop.setChecked(False) + self.checkBox_skip_similar_image.setChecked(False) + self.horizontalSlider_cleanup.setValue(0) + self.horizontalSlider_dilate.setValue(1) + self.horizontalSlider_skew.setValue(0) + self.horizontalSlider_vscale.setValue(10) + self.label_selectedInfo.setText("") + self.checkBox_removeLeadingZeros.setChecked(False) + self.checkBox_rescalePatch.setChecked(False) + self.checkBox_normWHRatio.setChecked(False) + self.checkBox_invertPatch.setChecked(False) + self.checkBox_ordinalIndicator.setChecked(False) + self.comboBox_binarizationMethod.setCurrentIndex(0) + else: + item_obj.settings = normalize_settings_dict( + item_obj.settings, info_for_box_name(item_obj.name) + ) + self.label_selectedInfo.setText(f"{item_obj.name}") + self.lineEdit_format.setText(item_obj.settings["format_regex"]) + self.comboBox_fieldType.setCurrentIndex(item_obj.settings["type"]) + self.checkBox_smoothing.setChecked(item_obj.settings["smoothing"]) + self.checkBox_skip_empty.setChecked(item_obj.settings["skip_empty"]) + self.horizontalSlider_conf_thresh.setValue( + int(item_obj.settings["conf_thresh"] * 100) + ) + self.checkBox_autocrop.setChecked(item_obj.settings["autocrop"]) + self.checkBox_skip_similar_image.setChecked( + item_obj.settings["skip_similar_image"] + ) + self.horizontalSlider_cleanup.setValue( + int(item_obj.settings["cleanup_thresh"] * 100) + ) + self.horizontalSlider_dilate.setValue(item_obj.settings["dilate"]) + self.horizontalSlider_skew.setValue(item_obj.settings["skew"]) + self.horizontalSlider_vscale.setValue(item_obj.settings["vscale"]) + self.checkBox_removeLeadingZeros.setChecked( + item_obj.settings["remove_leading_zeros"] + ) + self.checkBox_rescalePatch.setChecked(item_obj.settings["rescale_patch"]) + self.checkBox_normWHRatio.setChecked( + item_obj.settings["normalize_wh_ratio"] + ) + self.checkBox_invertPatch.setChecked(item_obj.settings["invert_patch"]) + self.checkBox_ordinalIndicator.setChecked( + item_obj.settings["ordinal_indicator"] + ) + self.comboBox_binarizationMethod.setCurrentIndex( + item_obj.settings["binarization_method"] + ) + + self.comboBox_formatPrefix.setCurrentIndex(12) + + self.lineEdit_format.blockSignals(False) + self.comboBox_fieldType.blockSignals(False) + self.checkBox_smoothing.blockSignals(False) + self.checkBox_skip_empty.blockSignals(False) + self.horizontalSlider_conf_thresh.blockSignals(False) + self.checkBox_autocrop.blockSignals(False) + self.checkBox_skip_similar_image.blockSignals(False) + self.horizontalSlider_cleanup.blockSignals(False) + self.horizontalSlider_dilate.blockSignals(False) + self.horizontalSlider_skew.blockSignals(False) + self.horizontalSlider_vscale.blockSignals(False) + self.checkBox_removeLeadingZeros.blockSignals(False) + self.checkBox_rescalePatch.blockSignals(False) + self.checkBox_normWHRatio.blockSignals(False) + self.checkBox_invertPatch.blockSignals(False) + self.checkBox_ordinalIndicator.blockSignals(False) + self.comboBox_binarizationMethod.blockSignals(False) + self.comboBox_formatPrefix.blockSignals(False) + + def listItemClicked(self, item): + if item.data(Qt.ItemDataRole.UserRole) == "checked": + # enable the remove box button and disable the make box button + self.pushButton_removeBox.setEnabled(True) + self.pushButton_makeBox.setEnabled(False) + self.groupBox_target_settings.setEnabled(True) + self.populateSettings(item.text()) + else: + # enable the make box button and disable the remove box button + self.pushButton_removeBox.setEnabled(False) + self.pushButton_makeBox.setEnabled(True) + self.groupBox_target_settings.setEnabled(False) + self.populateSettings("") + + def openOBSConnectModal(self): + # disable OBS options + self.lineEdit_sceneName.setEnabled(False) + self.checkBox_recreate.setEnabled(False) + self.pushButton_createOBSScene.setEnabled(False) + + # load the ui from "connect_obs.ui" + path_to_dat = path.abspath(path.join(path.dirname(__file__), "connect_obs.ui")) + self.obs_connect_modal = loadUi(path_to_dat) + # connect the "connect" button to a function + self.obs_connect_modal.pushButton_connect.clicked.connect(self.connectObs) + # load the saved data from scoresight.json + obs_data = fetch_data("scoresight.json", "obs") + if obs_data: + self.obs_connect_modal.lineEdit_ip.setText(obs_data["ip"]) + self.obs_connect_modal.lineEdit_port.setText(obs_data["port"]) + self.obs_connect_modal.lineEdit_password.setText(obs_data["password"]) + # show the modal + self.obs_connect_modal.show() + # focus the connect button + self.obs_connect_modal.pushButton_connect.setFocus() + + def connectObs(self): + # open a websocket connection to OBS using obs_websocket.py + # enable the save button in the modal if the connection is successful + if self.obs_connect_modal is not None: + self.obs_websocket_client = open_obs_websocket( + { + "ip": self.obs_connect_modal.lineEdit_ip.text(), + "port": self.obs_connect_modal.lineEdit_port.text(), + "password": self.obs_connect_modal.lineEdit_password.text(), + } + ) + else: + self.obs_websocket_client = open_obs_websocket( + fetch_data("scoresight.json", "obs") + ) + if not self.obs_websocket_client: + # show error in label_error + if self.obs_connect_modal: + self.obs_connect_modal.label_error.setText("Cannot connect to OBS") + return + + # connection was successful + if self.obs_connect_modal: + store_data( + "scoresight.json", + "obs", + { + "ip": self.obs_connect_modal.lineEdit_ip.text(), + "port": self.obs_connect_modal.lineEdit_port.text(), + "password": self.obs_connect_modal.lineEdit_password.text(), + }, + ) + self.obs_connect_modal.close() + + self.lineEdit_sceneName.setEnabled(True) + self.checkBox_recreate.setEnabled(True) + self.pushButton_createOBSScene.setEnabled(True) + + # set OBS status to connected in the status bar + self.statusbar.showMessage("OBS: Connected") + + @pyqtSlot() + def getSources(self): + # enumerate all the cameras + camera_sources = get_camera_info() + self.update_sources.emit(camera_sources) + + @pyqtSlot(list) + def updateSources(self, camera_sources: list[CameraInfo]): + # populate the combobox with the sources + self.comboBox_camera_source.clear() + self.comboBox_camera_source.addItem("Select a source") + for source in camera_sources: + self.comboBox_camera_source.addItem(source.description, source) + + # add an option to use a file as input + self.comboBox_camera_source.addItem("Open a Video File", "file") + self.comboBox_camera_source.addItem("URL Source (HTTP, RTSP)", "url") + self.comboBox_camera_source.addItem("Screen Capture", "screen_capture") + self.comboBox_camera_source.setEnabled(True) + self.comboBox_camera_source.currentIndexChanged.connect(self.sourceChanged) + + # enable the source view frame + self.frame_source_view.setEnabled(True) + + selected_source_from_storage = fetch_data("scoresight.json", "source_selected") + if type(selected_source_from_storage) == str: + logger.info( + "Source selected from storage: %s", selected_source_from_storage + ) + # check if the source is a file path + if path.exists(selected_source_from_storage): + self.comboBox_camera_source.blockSignals(True) + self.comboBox_camera_source.setCurrentText("Open a Video File") + self.comboBox_camera_source.blockSignals(False) + self.source_name = selected_source_from_storage + self.sourceSelectionSucessful() + else: + # select the last selected source + self.comboBox_camera_source.setCurrentText(selected_source_from_storage) + + def sourceChanged(self, index): + # get the source name from the combobox + self.source_name = self.comboBox_camera_source.currentText() + self.groupBox_sb_info.setEnabled(False) + self.tableWidget_boxes.setEnabled(False) + self.pushButton_fourCorner.setEnabled(False) + self.pushButton_binary.setEnabled(False) + if self.source_name == "Select a source": + if self.image_viewer: + # remove the image viewer from the layout frame_for_source_view_label + self.frame_for_source_view_label.layout().removeWidget( + self.image_viewer + ) + self.image_viewer.close() + self.image_viewer = None + # add a label with mardown text + label_select_source = QLabel("### Open a Camera or Load a File") + label_select_source.setTextFormat(Qt.TextFormat.MarkdownText) + label_select_source.setEnabled(False) + label_select_source.setAlignment(Qt.AlignmentFlag.AlignCenter) + clear_layout(self.frame_for_source_view_label.layout()) + self.frame_for_source_view_label.layout().addWidget(label_select_source) + return + if self.source_name == "Open a Video File": + # open a file dialog to select a video file + file, _ = QFileDialog.getOpenFileName( + self, "Open Video File", "", "Video Files (*.mp4 *.avi *.mov)" + ) + if not file: + return + self.source_name = file + if self.source_name == "URL Source (HTTP, RTSP)": + # open a dialog to enter the url + url_dialog = QDialog() + loadUi( + path.abspath(path.join(path.dirname(__file__), "url_source.ui")), + url_dialog, + ) + url_dialog.setWindowTitle("URL Source") + # focus on url input + url_dialog.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: + return + self.source_name = url_dialog.lineEdit_url.text() + if self.source_name == "": + return + if self.source_name == "Screen Capture": + # open a dialog to select the screen + screen_dialog = QDialog() + loadUi( + path.abspath(path.join(path.dirname(__file__), "screen_capture.ui")), + screen_dialog, + ) + screen_dialog.setWindowTitle("Screen Capture Selection") + # populate comboBox_window with the available windows + screen_dialog.comboBox_window.clear() + screen_dialog.comboBox_window.addItem("Capture the entire screen", -1) + for window in ScreenCapture.list_windows(): + screen_dialog.comboBox_window.addItem(window[0], window[1]) + screen_dialog.exec() + # check if the dialog was accepted + if screen_dialog.result() != QDialog.DialogCode.Accepted: + return + # get the window ID from the comboBox_window + window_id = screen_dialog.comboBox_window.currentData() + self.source_name = window_id + + # store the source selection in scoresight.json + store_data("scoresight.json", "source_selected", self.source_name) + self.sourceSelectionSucessful() + + def itemSelected(self, item_name): + # select the item in the tableWidget_boxes + items = self.tableWidget_boxes.findItems(item_name, Qt.MatchFlag.MatchExactly) + if len(items) == 0: + return + item = items[0] + item.setSelected(True) + self.tableWidget_boxes.setCurrentItem(item) + self.listItemClicked(item) + + def fourCornersApplied(self, corners): + # check the button + self.pushButton_fourCorner.setChecked(True) + + def sourceSelectionSucessful(self): + if self.comboBox_camera_source.currentData() is None: + return + if self.comboBox_camera_source.currentText() == "Select a source": + return + + self.frame_source_view.setEnabled(False) + + if self.comboBox_camera_source.currentData() == "file": + camera_info = CameraInfo( + self.source_name, + self.source_name, + self.source_name, + CameraInfo.CameraType.FILE, + ) + elif self.comboBox_camera_source.currentData() == "url": + camera_info = CameraInfo( + self.source_name, + self.source_name, + self.source_name, + CameraInfo.CameraType.URL, + ) + elif self.comboBox_camera_source.currentData() == "screen_capture": + camera_info = CameraInfo( + self.source_name, + self.source_name, + self.source_name, + CameraInfo.CameraType.SCREEN_CAPTURE, + ) + else: + camera_info = self.comboBox_camera_source.currentData() + + if self.image_viewer: + # remove the image viewer from the layout frame_for_source_view_label + self.frame_for_source_view_label.layout().removeWidget(self.image_viewer) + self.image_viewer.close() + self.image_viewer = None + + # clear self.frame_for_source_view_label + clear_layout(self.frame_for_source_view_label.layout()) + + # set the pixmap to the image viewer + self.image_viewer = ImageViewer( + camera_info, + self.fourCornersApplied, + self.detectionTargetsStorage, + self.itemSelected, + ) + self.pushButton_fourCorner.setEnabled(True) + self.pushButton_binary.setEnabled(True) + self.pushButton_fourCorner.toggled.connect(self.image_viewer.toggleFourCorner) + self.pushButton_binary.clicked.connect(self.image_viewer.toggleBinary) + if self.image_viewer.timerThread: + self.image_viewer.timerThread.ocr_result_signal.connect(self.ocrResult) + self.image_viewer.timerThread.update_error.connect(self.updateError) + self.image_viewer.first_frame_received_signal.connect( + self.cameraConnectedEnableUI + ) + self.ocrModelChanged(fetch_data("scoresight.json", "ocr_model", 1)) + + # set the image viewer to the layout frame_for_source_view_label + self.frame_for_source_view_label.layout().addWidget(self.image_viewer) + + def cameraConnectedEnableUI(self): + # enable groupBox_sb_info + self.groupBox_sb_info.setEnabled(True) + self.tableWidget_boxes.setEnabled(True) + self.frame_source_view.setEnabled(True) + self.widget_viewTools.setEnabled(True) + + # load the boxes from scoresight.json + self.detectionTargetsStorage.loadBoxesFromStorage() + self.updateError(None) + + def updateError(self, error): + if not error: + self.statusbar.clearMessage() + return + # show the error in the status bar + self.statusbar.showMessage(error) + self.frame_source_view.setEnabled(True) + self.widget_viewTools.setEnabled(False) + + def ocrResult(self, results: list[TextDetectionTargetWithResult]): + if not self.updateOCRResults: + # don't update the results, the user has disabled updates + return + + update_http_server(results) + + # update vmix + self.vmixUpdater.update_vmix(results) + + # update the table widget value items + for targetWithResult in results: + if ( + targetWithResult.result_state + == TextDetectionTargetWithResult.ResultState.Success + ): + items = self.tableWidget_boxes.findItems( + targetWithResult.name, Qt.MatchFlag.MatchExactly + ) + if len(items) == 0: + continue + item = items[0] + # get the value (1 column) of the item + item = self.tableWidget_boxes.item(item.row(), 1) + item.setText(targetWithResult.result) + + if self.out_folder is None: + return + + if not path.exists(path.abspath(self.out_folder)): + self.out_folder = None + remove_data("scoresight.json", "output_folder") + logger.warning("Output folder does not exist") + return + + # check if enough time has passed since last file save according to aggs per second + if ( + datetime.datetime.now() - self.last_aggregate_save + ).total_seconds() < 1.0 / self.horizontalSlider_aggsPerSecond.value(): + return + + self.last_aggregate_save = datetime.datetime.now() + + # update the obs scene sources with the results, use update_text_source + for targetWithResult in results: + if targetWithResult.result is None: + continue + if ( + "skip_empty" in targetWithResult.settings + and targetWithResult.settings["skip_empty"] + and len(targetWithResult.result) == 0 + ): + continue + if ( + targetWithResult.result_state + != TextDetectionTargetWithResult.ResultState.Success + ): + continue + + if self.obs_websocket_client is not None: + # find the source name for the target from the default boxes + update_text_source( + self.obs_websocket_client, + targetWithResult.settings["obs_source_name"], + targetWithResult.result, + ) + + # save the results to text files + file_output.save_text_files( + results, self.out_folder, self.comboBox_appendMethod.currentIndex() + ) + + # save the results to a csv file + if self.checkBox_saveCsv.isChecked(): + file_output.save_csv( + results, + self.out_folder, + self.comboBox_appendMethod.currentIndex(), + self.first_csv_append, + ) + + # save the results to an xml file + if self.checkBox_saveXML.isChecked(): + file_output.save_xml( + results, + self.out_folder, + ) + + def addBox(self): + # add a new box to the tableWidget_boxes + # find the number of custom boxes + custom_boxes = [] + for i in range(self.tableWidget_boxes.rowCount()): + item = self.tableWidget_boxes.item(i, 0) + if item.text() not in [o["name"] for o in default_boxes]: + custom_boxes.append(item.text()) + + store_custom_box_name("Custom") + item = QTableWidgetItem( + QIcon( + path.abspath(path.join(path.dirname(__file__), "icons/circle-x.svg")) + ), + "Custom", + ) + item.setData(Qt.ItemDataRole.UserRole, "unchecked") + self.tableWidget_boxes.insertRow(self.tableWidget_boxes.rowCount()) + self.tableWidget_boxes.setItem(self.tableWidget_boxes.rowCount() - 1, 0, item) + disabledItem = QTableWidgetItem() + disabledItem.setFlags(Qt.ItemFlag.NoItemFlags) + self.tableWidget_boxes.setItem( + self.tableWidget_boxes.rowCount() - 1, 1, disabledItem + ) + + def removeCustomBox(self): + item = self.tableWidget_boxes.currentItem() + if not item: + logger.info("No item selected") + return + if item.column() != 0: + item = self.tableWidget_boxes.item(item.row(), 0) + self.removeBox() + remove_custom_box_name_in_storage(item.text()) + # only allow removing custom boxes + if item.text() in [o["name"] for o in default_boxes]: + logger.info("Cannot remove default box") + return + # remove the selected item from the tableWidget_boxes + self.tableWidget_boxes.removeRow(item.row()) + + def editBoxName(self, item): + if item.text() in [o["name"] for o in default_boxes]: + return + new_name, ok = QInputDialog.getText( + self, "Edit Box Name", "New Name:", text=item.text() + ) + if ok and new_name != "" and new_name != item.text(): + # check if name doesn't exist already + for i in range(self.tableWidget_boxes.rowCount()): + if new_name == self.tableWidget_boxes.item(i, 0).text(): + logger.info("Name '%s' already exists", new_name) + return + # rename the item in the detectionTargetsStorage + if not self.detectionTargetsStorage.rename_item(item.text(), new_name): + logger.info("Error renaming item in application storage") + return + # rename the item in the tableWidget_boxes + item.setText(new_name) + rename_custom_box_name_in_storage(item.text(), new_name) + + def makeBox(self): + item = self.tableWidget_boxes.currentItem() + if not item: + return + # create a new box on self.image_viewer with the name of the selected item from the tableWidget_boxes + # change the list icon to green checkmark + item.setIcon( + QIcon( + path.abspath( + path.join(path.dirname(__file__), "icons/circle-check.svg") + ) + ) + ) + item.setData(Qt.ItemDataRole.UserRole, "checked") + self.listItemClicked(item) + + # get the size of the box from the name + info = info_for_box_name(item.text()) + + self.detectionTargetsStorage.add_item( + TextDetectionTarget( + info["x"], + info["y"], + info["width"], + info["height"], + item.text(), + normalize_settings_dict({}, info), + ) + ) + + def removeBox(self): + item = self.tableWidget_boxes.currentItem() + if not item: + return + # change the list icon to red x + item.setIcon( + QIcon(path.abspath(path.join(path.dirname(__file__), "icons/circle-x.svg"))) + ) + item.setData(Qt.ItemDataRole.UserRole, "unchecked") + self.listItemClicked(item) + self.detectionTargetsStorage.remove_item(item.text()) + + def createOBSScene(self): + self.statusBar().showMessage("Creating OBS scene") + # get the scene name from the lineEdit_sceneName + scene_name = self.lineEdit_sceneName.text() + # clear or create a new scene + create_obs_scene_from_export(self.obs_websocket_client, scene_name) + self.statusBar().showMessage("Finished creating scene") + + # on destroy, close the OBS connection + def closeEvent(self, event): + logger.info("Closing") + if self.image_viewer: + self.image_viewer.close() + self.image_viewer = None + + if self.log_dialog: + self.log_dialog.close() + self.log_dialog = None + + # store the boxes to scoresight.json + self.detectionTargetsStorage.saveBoxesToStorage() + if self.obs_websocket_client: + # destroy the client object + self.obs_websocket_client = None + + super().closeEvent(event) + + +if __name__ == "__main__": + # only attempt splash when not on Mac OSX + os_name = platform.system() + if os_name != "Darwin": + try: + import pyi_splash + + pyi_splash.close() + except ImportError: + pass + app = QApplication(sys.argv) + + # show the main window + mainWindow = MainWindow() + mainWindow.show() + + app.exec() + logger.info("Exiting...") + + stop_http_server() diff --git a/mainwindow.ui b/mainwindow.ui new file mode 100644 index 0000000..b212bd6 --- /dev/null +++ b/mainwindow.ui @@ -0,0 +1,1711 @@ + + + MainWindow + + + + 0 + 0 + 961 + 839 + + + + ScoreSight Open + + + + + 0 + + + + + + 0 + 0 + + + + QFrame::StyledPanel + + + QFrame::Plain + + + 0 + + + + 12 + + + + + false + + + Scoreboard Information + + + + 4 + + + 0 + + + + + + 1 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::ScrollBarAlwaysOff + + + QAbstractItemView::NoEditTriggers + + + false + + + false + + + QAbstractItemView::SingleSelection + + + false + + + true + + + false + + + + Field + + + + + Value + + + + + + + + + 0 + 0 + + + + + QLayout::SetMinimumSize + + + 3 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 27 + 0 + + + + + 14 + 75 + true + + + + + + + + + + + + + 27 + 0 + + + + + 14 + 75 + true + + + + - + + + + + + + + + + + + + + 3 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + false + + + Add to Scene -> + + + + + + + false + + + Remove Selected + + + + + + + + + + Target Information Settings + + + false + + + + QFormLayout::ExpandingFieldsGrow + + + 3 + + + 2 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Select an item above + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Defaults ↩️ + + + + + + + + + + Format + + + + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + 0 + + + + Time mm:ss.d + + + + + Time mm:ss + + + + + Time ss.d + + + + + Time 0-59 + + + + + Shotclock 0-39 + + + + + Score 1dd + + + + + Score ddd + + + + + Period 1-4 + + + + + Period d + + + + + Alphanumeric + + + + + Any text + + + + + Any number + + + + + Select Preset + + + + + + + + + + + Type + + + + + + + + Number 0-9 + + + + + Time 0-9 , . : + + + + + Text + + + + + + + + + 0 + 0 + + + + + 3 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Average Output + + + + + + + Ordinal (1st, 2nd, ..) + + + + + + + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Skip Empty Values + + + + + + + Skip Similar Image + + + + + + + + + + + 0 + 0 + + + + + 3 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Auto Crop + + + + + + + Invert Input + + + + + + + + + + Remove leading 0s + + + + + + + + 3 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Scale the image to 35 pixels height, a favorable size for OCR + + + Rescale Input + + + + + + + Scale to a favorable 1:2 width-to-height ratio + + + Normalize W-H Ratio + + + + + + + + + + Binarize + + + + + + + + 0 + 0 + + + + + Global + + + + + No Binarization + + + + + Local + + + + + Adaptive + + + + + + + + + 0 + 0 + + + + + 3 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Cleanup + + + + + + + Qt::Horizontal + + + + + + + V.Scale + + + + + + + 1 + + + 10 + + + 5 + + + 10 + + + Qt::Horizontal + + + + + + + + + + + 0 + 0 + + + + + 3 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Dilate + + + + + + + 5 + + + 1 + + + Qt::Horizontal + + + + + + + Skew + + + + + + + -10 + + + 10 + + + Qt::Horizontal + + + + + + + + + + Conf. Th + + + + + + + 50 + + + Qt::Horizontal + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + OCR Model + + + + + + + + + + + + + + + + 1 + + + Qt::Horizontal + + + + + + + + 0 + 0 + + + + 0 + + + + + 0 + 0 + + + + Text Files + + + + QFormLayout::ExpandingFieldsGrow + + + Qt::AlignRight|Qt::AlignTop|Qt::AlignTrailing + + + Qt::AlignHCenter|Qt::AlignTop + + + 6 + + + + + Folder + + + + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + true + + + + + + + 📂 + + + + + + + + + + + + + + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Save .csv file + + + + + + + Save .xml file + + + + + + + + + + Append + + + + + + + + 0 + 0 + + + + + Results in .csv file + + + + + Results in .txt files + + + + + Results in both + + + + + Don't append results + + + + + + + + How many times per second to save the results to files + + + Save / s + + + + + + + 1 + + + 10 + + + 1 + + + 5 + + + Qt::Horizontal + + + QSlider::TicksBelow + + + + + + + + Browser + + + + + + Server is running. + + + + + + + <html><head/><body><p>HTML Scoreboard: <a href="http://localhost:18099/scoresight"><span style=" text-decoration: underline; color:#0000ff;">http://localhost:18099/scoresight</span></a></p><p>JSON: <a href="http://localhost:18099/json"><span style=" text-decoration: underline; color:#0000ff;">http://localhost:18099/json</span></a> (optional: ?pivot)</p><p>XML: <a href="http://localhost:18099/xml"><span style=" text-decoration: underline; color:#0000ff;">http://localhost:18099/xml</span></a> (optional: ?pivot)</p><p>CSV: <a href="http://localhost:18099/csv"><span style=" text-decoration: underline; color:#0000ff;">http://localhost:18099/csv</span></a></p></body></html> + + + Qt::RichText + + + true + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + OBS + + + + + + false + + + ScoreSight Scene + + + + + + + false + + + Recreate if exists + + + true + + + + + + + false + + + Create OBS Scene + + + + + + + + 0 + 0 + + + + + 0 + 40 + + + + 🛜 Connect OBS + + + + .. + + + + + + + + VMix + + + + + + + 0 + 0 + + + + false + + + true + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Output Mapping + + + + + + + + 0 + 0 + + + + ▶️ Start + + + true + + + false + + + + + + + + + + 3 + + + 3 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Connection + + + + + + + + 3 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + localhost + + + + + + + : + + + + + + + + 0 + 0 + + + + + 50 + 16777215 + + + + 8099 + + + + + + + + + + Input + + + + + + + 1 + + + + + + + + + + + + + + 0 + 40 + + + + 🛑 Stop Updates + + + true + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Detections / s + + + + + + + 1 + + + 15 + + + 5 + + + 5 + + + Qt::Horizontal + + + false + + + false + + + 5 + + + + + + + + + + + + + true + + + + 0 + 0 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + 0 + + + 6 + + + + + + 6 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 3 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + Source + + + + + + + + 1 + 0 + + + + + + + + Refresh Sources + + + 🔄 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + false + + + + 3 + + + 0 + + + 0 + + + 0 + + + 3 + + + + + false + + + + 0 + 0 + + + + Binary View + + + true + + + + + + + false + + + + 0 + 0 + + + + 4-corner Correction + + + true + + + + + + + false + + + + 0 + 0 + + + + Stabilize + + + true + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Show Statistics + + + OSD + + + true + + + true + + + + + + + Show OCR Detection Boxes + + + OCR + + + true + + + true + + + + + + + 1:1 + + + + + + + false + + + Ctrl-scroll to zoom + + + + + + + + + + true + + + + 0 + 0 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + false + + + ### Open a Camera or Load a File + + + Qt::MarkdownText + + + Qt::AlignCenter + + + + + + + + + + + + + + + 0 + 0 + 961 + 21 + + + + + + + + diff --git a/ndi.py b/ndi.py new file mode 100644 index 0000000..224469f --- /dev/null +++ b/ndi.py @@ -0,0 +1,136 @@ +import time +import cv2 +from cyndilib.wrapper.ndi_recv import RecvColorFormat, RecvBandwidth +from cyndilib.finder import Finder +from cyndilib.receiver import Receiver, ReceiveFrameType +from cyndilib.video_frame import VideoRecvFrame +from cyndilib.metadata_frame import MetadataRecvFrame +from cyndilib.locks import * +from cyndilib.buffertypes import * +from cyndilib.send_frame_status import * +from cyndilib.callback import * +from cyndilib.wrapper.ndi_send import * +import numpy as np + +from camera_info import CameraInfo +from sc_logging import logger + + +def ReceiveFrameTypeToString(frame_type: ReceiveFrameType) -> str: + if frame_type == ReceiveFrameType.recv_audio: + return "recv_audio" + if frame_type == ReceiveFrameType.recv_metadata: + return "recv_metadata" + if frame_type == ReceiveFrameType.recv_video: + return "recv_video" + if frame_type == ReceiveFrameType.recv_error: + return "recv_error" + if frame_type == ReceiveFrameType.nothing: + return "nothing" + if frame_type == ReceiveFrameType.recv_status_change: + return "recv_status_change" + if frame_type == ReceiveFrameType.recv_buffers_full: + return "recv_buffers_full" + return "recv_unknown" + + +class NDICapture: + finder = Finder() + + def get_camera_info_ndi(): + # Get the NDI cameras + logger.info("Getting NDI sources...") + sources = [] + # Create a Finder to find NDI sources + if NDICapture.finder.wait_for_sources(1.0): + # create the camera info objects + sources = [ + CameraInfo(name, name, i, CameraInfo.CameraType.NDI) + for i, name in enumerate(NDICapture.finder.get_source_names()) + ] + logger.info(f"Found {len(sources)} NDI sources") + + return sources + + def __init__(self, id: str): + self.receiver = Receiver( + color_format=RecvColorFormat.BGRX_BGRA, + bandwidth=RecvBandwidth.highest, + ) + self.source = NDICapture.finder.get_source(id) + self.receiver.set_source(self.source) + self.video_frame = VideoRecvFrame() + self.metadata_frame = MetadataRecvFrame() + self.receiver.set_video_frame(self.video_frame) + self.receiver.set_metadata_frame(self.metadata_frame) + self.receiver.set_source_tally_program(True) + self.receiver.set_source_tally_preview(False) + + logger.info(f"NDI Capture created with id {self.receiver.source.name}") + + def __del__(self): + self.release() + + def isOpened(self): + return self.receiver is not None + + def release(self): + if self.receiver is not None: + self.receiver.disconnect() + self.receiver = None + self.source = None + self.video_frame = None + self.metadata_frame = None + + def read(self): + if self.receiver is not None and self.receiver.is_connected(): + video_grab_start_time = time.time() + while ( + self.receiver is not None + and self.receiver.is_connected() + and time.time() - video_grab_start_time < 0.03 + ): + try: + ret = self.receiver.receive(ReceiveFrameType.recv_all, 1000) + except Exception as e: + logger.error(f"Error receiving frame: {e}") + time.sleep(1) + self.receiver.reconnect() + break + if ret == ReceiveFrameType.recv_video: + if min(self.video_frame.xres, self.video_frame.yres) != 0: + # create a new uint8 numpy array of size self.video_frame.get_buffer_size() + frame = np.empty( + self.video_frame.get_buffer_size(), dtype=np.uint8 + ) + # copy the frame to the numpy array + self.video_frame.fill_p_data(frame) + # create a new numpy array with the frame data + frame = frame.reshape( + self.video_frame.yres, self.video_frame.xres, 4 + ) + frame = cv2.cvtColor(frame, cv2.COLOR_RGBA2RGB) + return True, frame + else: + logger.error("NDI Capture video frame is empty") + elif ret == ReceiveFrameType.recv_metadata: + # log the dictionary + logger.debug("Metadata: " + str(self.metadata_frame.attrs)) + # skip metadata + continue + elif ret == ReceiveFrameType.recv_audio: + # skip audio + continue + else: + logger.error( + "NDI Capture did not return video: " + + ReceiveFrameTypeToString(ret) + ) + if ret == ReceiveFrameType.recv_error: + self.receiver.reconnect() + else: + logger.error("NDI Capture is not connected") + time.sleep(1) + self.receiver.reconnect() + + return False, None diff --git a/obs_data/Scoreboard parts/Left Scoreboard.png b/obs_data/Scoreboard parts/Left Scoreboard.png new file mode 100644 index 0000000..9ff96f4 Binary files /dev/null and b/obs_data/Scoreboard parts/Left Scoreboard.png differ diff --git a/obs_data/Scoreboard parts/Left base Scoreboard.png b/obs_data/Scoreboard parts/Left base Scoreboard.png new file mode 100644 index 0000000..4f2846e Binary files /dev/null and b/obs_data/Scoreboard parts/Left base Scoreboard.png differ diff --git a/obs_data/Scoreboard parts/Middle Scoreboard.png b/obs_data/Scoreboard parts/Middle Scoreboard.png new file mode 100644 index 0000000..d508079 Binary files /dev/null and b/obs_data/Scoreboard parts/Middle Scoreboard.png differ diff --git a/obs_data/Scoreboard parts/Right Scoreboard.png b/obs_data/Scoreboard parts/Right Scoreboard.png new file mode 100644 index 0000000..50152b1 Binary files /dev/null and b/obs_data/Scoreboard parts/Right Scoreboard.png differ diff --git a/obs_data/Scoreboard parts/Right base Scoreboard.png b/obs_data/Scoreboard parts/Right base Scoreboard.png new file mode 100644 index 0000000..ebeeef2 Binary files /dev/null and b/obs_data/Scoreboard parts/Right base Scoreboard.png differ diff --git a/obs_data/Scoreboard parts/logo-placeholder-image.png b/obs_data/Scoreboard parts/logo-placeholder-image.png new file mode 100644 index 0000000..cb59239 Binary files /dev/null and b/obs_data/Scoreboard parts/logo-placeholder-image.png differ diff --git a/obs_data/Scoresight_OBS_scene_collection.json b/obs_data/Scoresight_OBS_scene_collection.json new file mode 100644 index 0000000..7d987ec --- /dev/null +++ b/obs_data/Scoresight_OBS_scene_collection.json @@ -0,0 +1,2918 @@ +{ + "current_scene": "Scoreboard", + "current_program_scene": "Scoreboard", + "scene_order": [ + { + "name": "Scoreboard" + } + ], + "name": "test", + "groups": [ + { + "prev_ver": 503316482, + "name": "Base Scoreboard", + "uuid": "35d63ff5-565c-42d6-8ba2-0566c2ea354a", + "id": "group", + "versioned_id": "group", + "settings": { + "custom_size": true, + "cx": 1920, + "cy": 1080, + "id_counter": 0, + "items": [ + { + "name": "Middle Scoreboard", + "source_uuid": "fca8e8a6-fe8e-4097-a180-a36b9ce03a29", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 13, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Left base Scoreboard", + "source_uuid": "364e7649-2c99-40d6-8ba9-977ed8ab084c", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 11, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Left Scoreboard", + "source_uuid": "163689d5-3a0f-4f6e-ad0a-53da995ae08c", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 12, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Right base Scoreboard", + "source_uuid": "d5cd5ece-99eb-47f3-9db5-2be71d664fa2", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 14, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Right Scoreboard", + "source_uuid": "3fd5eb40-e47f-4385-a7cf-7317f6b228f1", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 15, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + } + ] + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": { + "libobs.show_scene_item.13": [], + "libobs.hide_scene_item.13": [], + "libobs.show_scene_item.11": [], + "libobs.hide_scene_item.11": [], + "libobs.show_scene_item.12": [], + "libobs.hide_scene_item.12": [], + "libobs.show_scene_item.14": [], + "libobs.hide_scene_item.14": [], + "libobs.show_scene_item.15": [], + "libobs.hide_scene_item.15": [] + }, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 503316482, + "name": "Fouls", + "uuid": "b54a726e-372b-4ce2-8fa8-13b4d7ba0ab9", + "id": "group", + "versioned_id": "group", + "settings": { + "custom_size": true, + "cx": 1038, + "cy": 32, + "id_counter": 0, + "items": [ + { + "name": "Home Fouls", + "source_uuid": "e1c3cb5a-0a94-48be-aa59-1103d8d79b3f", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 4.0 + }, + "scale": { + "x": 0.10632184147834778, + "y": 0.10655737668275833 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 18, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Away Fouls", + "source_uuid": "4501f19e-0c82-4806-ba7a-d0d705e881bb", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 945.0, + "y": 4.0 + }, + "scale": { + "x": 0.10632184147834778, + "y": 0.10655737668275833 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 35, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "#Away Fouls", + "source_uuid": "d74d6cd3-42c5-4bef-9039-9692ba780f88", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 1020.0, + "y": 1.0 + }, + "scale": { + "x": 0.12676055729389191, + "y": 0.12552301585674286 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 16, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "#Home Fouls", + "source_uuid": "2359e37c-15d2-4c2e-ab79-57be082c8c72", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 74.0, + "y": 0.0 + }, + "scale": { + "x": 0.11971831321716309, + "y": 0.12295082211494446 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 17, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + } + ] + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": { + "libobs.show_scene_item.18": [], + "libobs.hide_scene_item.18": [], + "libobs.show_scene_item.35": [], + "libobs.hide_scene_item.35": [], + "libobs.show_scene_item.16": [], + "libobs.hide_scene_item.16": [], + "libobs.show_scene_item.17": [], + "libobs.hide_scene_item.17": [] + }, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 503316482, + "name": "Scoreboard text", + "uuid": "877a1475-8d92-41bf-b11c-84c1bebf04e6", + "id": "group", + "versioned_id": "group", + "settings": { + "custom_size": true, + "cx": 528, + "cy": 107, + "id_counter": 3, + "items": [ + { + "name": "Home score", + "source_uuid": "8fb77bc1-1460-4ca7-ae71-3bd32a5bd8c8", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 112.0, + "y": 50.0 + }, + "scale": { + "x": 0.39436620473861694, + "y": 0.39344269037246704 + }, + "align": 2, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 14, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Away score", + "source_uuid": "dec1061a-b1db-4f93-9201-3976530ebfad", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 414.0, + "y": 49.0 + }, + "scale": { + "x": 0.40140846371650696, + "y": 0.40163934230804443 + }, + "align": 1, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 15, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "shotclock", + "source_uuid": "df6c2501-3282-4f1f-bcf1-50de0fe2d91f", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 307.0, + "y": 59.0 + }, + "scale": { + "x": 0.19718310236930847, + "y": 0.19672131538391113 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 20, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Clock", + "source_uuid": "be6f57a5-cd94-4260-8329-aa4e86ec1a3f", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 261.94451904296875, + "y": 34.0 + }, + "scale": { + "x": 0.24569638073444366, + "y": 0.24590164422988892 + }, + "align": 0, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 23, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Period", + "source_uuid": "1f840e5a-f801-483a-aaee-fb393f9abec2", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 171.0, + "y": 65.0 + }, + "scale": { + "x": 0.17595307528972626, + "y": 0.17213115096092224 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 24, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "ot", + "source_uuid": "e96de96c-0106-4182-82b6-6cc1230cc4da", + "visible": false, + "locked": false, + "rot": 0.0, + "pos": { + "x": 162.0, + "y": 65.0 + }, + "scale": { + "x": 0.17302055656909943, + "y": 0.17001475393772125 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 19, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Final", + "source_uuid": "acfb7c7a-8736-4745-a907-fe3586e5963d", + "visible": false, + "locked": false, + "rot": 0.0, + "pos": { + "x": 196.14605712890625, + "y": 21.0 + }, + "scale": { + "x": 0.23773987591266632, + "y": 0.23770491778850555 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 2, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + } + ] + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": { + "libobs.show_scene_item.14": [], + "libobs.hide_scene_item.14": [], + "libobs.show_scene_item.15": [], + "libobs.hide_scene_item.15": [], + "libobs.show_scene_item.20": [], + "libobs.hide_scene_item.20": [], + "libobs.show_scene_item.23": [], + "libobs.hide_scene_item.23": [], + "libobs.show_scene_item.24": [], + "libobs.hide_scene_item.24": [], + "libobs.show_scene_item.19": [], + "libobs.hide_scene_item.19": [], + "libobs.show_scene_item.2": [], + "libobs.hide_scene_item.2": [] + }, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 503316482, + "name": "Logos", + "uuid": "9e8fbf9b-8f60-4ad9-b63f-c5c77ab9bf65", + "id": "group", + "versioned_id": "group", + "settings": { + "custom_size": true, + "cx": 1434, + "cy": 191, + "id_counter": 0, + "items": [ + { + "name": "Away Team Logo", + "source_uuid": "a4861d4a-50e6-4e7d-bb0b-7db13f0cd095", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 1243.0, + "y": 0.0 + }, + "scale": { + "x": 0.373046875, + "y": 0.373046875 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 24, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Home Team Logo", + "source_uuid": "c8b24bd4-5140-4618-9cb3-112270244e59", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 0.373046875, + "y": 0.373046875 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 18, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + } + ] + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": { + "libobs.show_scene_item.24": [], + "libobs.hide_scene_item.24": [], + "libobs.show_scene_item.18": [], + "libobs.hide_scene_item.18": [] + }, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 503316482, + "name": "Team Names", + "uuid": "eff50383-2102-4522-9e95-4419ac2d584b", + "id": "group", + "versioned_id": "group", + "settings": { + "custom_size": true, + "cx": 1080, + "cy": 70, + "id_counter": 0, + "items": [ + { + "name": "Home Team Name", + "source_uuid": "e2e7dd36-68a6-4bd2-9d05-87ff2fbaefc3", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 139.0, + "y": 34.0 + }, + "scale": { + "x": 0.28739002346992493, + "y": 0.28688523173332214 + }, + "align": 0, + "bounds_type": 2, + "bounds_align": 9, + "bounds": { + "x": 278.0, + "y": 69.655799865722656 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 21, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Away Team Name", + "source_uuid": "8b42977e-4daa-4929-889a-7ce2e45e115d", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 948.0, + "y": 34.00006103515625 + }, + "scale": { + "x": 0.28738996386528015, + "y": 0.28688523173332214 + }, + "align": 0, + "bounds_type": 2, + "bounds_align": 2, + "bounds": { + "x": 264.0, + "y": 69.655799865722656 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 27, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + } + ] + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": { + "libobs.show_scene_item.21": [], + "libobs.hide_scene_item.21": [], + "libobs.show_scene_item.27": [], + "libobs.hide_scene_item.27": [] + }, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + } + ], + "quick_transitions": [ + { + "name": "Cut", + "duration": 300, + "hotkeys": [], + "id": 7, + "fade_to_black": false + }, + { + "name": "Fade", + "duration": 300, + "hotkeys": [], + "id": 8, + "fade_to_black": false + }, + { + "name": "Fade", + "duration": 300, + "hotkeys": [], + "id": 9, + "fade_to_black": true + } + ], + "transitions": [], + "saved_projectors": [], + "current_transition": "Fade", + "transition_duration": 300, + "preview_locked": false, + "scaling_enabled": false, + "scaling_level": 0, + "scaling_off_x": 0.0, + "scaling_off_y": 0.0, + "virtual-camera": { + "type2": 3 + }, + "modules": { + "transition-table": { + "transitions": [], + "dialog_width": 1512, + "dialog_height": 922, + "enable_hotkey": [], + "disable_hotkey": [] + }, + "copyTransformHotkey": [], + "pasteTransformHotkey": [], + "advanced-scene-switcher": { + "sceneGroups": [], + "macroProperties": { + "highlightExecuted": false, + "highlightConditions": false, + "highlightActions": false, + "newMacroRegisterHotkey": false + }, + "macros": [], + "variables": [], + "switches": [], + "ignoreWindows": [], + "screenRegion": [], + "pauseEntries": [], + "sceneRoundTrip": [], + "sceneTransitions": [], + "defaultTransitions": [], + "defTransitionDelay": 0, + "ignoreIdleWindows": [], + "idleTargetType": 0, + "idleSceneName": "", + "idleTransitionName": "", + "idleEnable": false, + "idleTime": 60, + "executableSwitches": [], + "randomSwitches": [], + "fileSwitches": [], + "readEnabled": false, + "readPath": "", + "writeEnabled": false, + "writePath": "", + "mediaSwitches": [], + "timeSwitches": [], + "audioSwitches": [], + "audioFallbackTargetType": 0, + "audioFallbackScene": "", + "audioFallbackTransition": "", + "audioFallbackEnable": false, + "audioFallbackDuration": { + "value": { + "value": 0.0, + "type": 0 + }, + "unit": 0, + "version": 1 + }, + "videoSwitches": [], + "ServerEnabled": false, + "ServerPort": 55555, + "LockToIPv4": false, + "ClientEnabled": false, + "Address": "", + "ClientPort": 55555, + "SendSceneChange": true, + "SendSceneChangeAll": true, + "SendPreview": true, + "triggers": [], + "interval": 300, + "non_matching_scene": "", + "switch_if_not_matching": 0, + "noMatchDelay": { + "value": { + "value": 0.0, + "type": 0 + }, + "unit": 0, + "version": 1 + }, + "cooldown": { + "value": { + "value": 0.0, + "type": 0 + }, + "unit": 0, + "version": 1 + }, + "active": false, + "startup_behavior": 0, + "autoStartEvent": 0, + "verbose": false, + "showSystemTrayNotifications": false, + "disableHints": false, + "disableFilterComboboxFilter": false, + "warnPluginLoadFailure": true, + "hideLegacyTabs": true, + "priority0": 10, + "priority1": 0, + "priority2": 2, + "priority3": 8, + "priority4": 6, + "priority5": 9, + "priority6": 7, + "priority7": 4, + "priority8": 1, + "priority9": 5, + "priority10": 3, + "threadPriority": 3, + "transitionOverrideOverride": false, + "adjustActiveTransitionType": true, + "lastImportPath": "", + "startHotkey": [], + "stopHotkey": [], + "toggleHotkey": [], + "upMacroSegmentHotkey": [], + "downMacroSegmentHotkey": [], + "removeMacroSegmentHotkey": [], + "generalTabPos": 0, + "macroTabPos": 1, + "transitionTabPos": 15, + "pauseTabPos": 16, + "titleTabPos": 2, + "exeTabPos": 3, + "regionTabPos": 4, + "mediaTabPos": 5, + "fileTabPos": 6, + "randomTabPos": 7, + "timeTabPos": 8, + "idleTabPos": 9, + "sequenceTabPos": 10, + "audioTabPos": 11, + "videoTabPos": 12, + "networkTabPos": 13, + "sceneGroupTabPos": 14, + "triggerTabPos": 17, + "saveWindowGeo": false, + "windowPosX": 0, + "windowPosY": 0, + "windowWidth": 0, + "windowHeight": 0, + "macroListMacroEditSplitterPosition": [], + "version": "267855fded058e22e682bf2888c822fb419d8b55", + "twitchConnections": [], + "connections": [] + }, + "scripts-tool": [], + "output-timer": { + "streamTimerHours": 0, + "streamTimerMinutes": 0, + "streamTimerSeconds": 30, + "recordTimerHours": 0, + "recordTimerMinutes": 0, + "recordTimerSeconds": 30, + "autoStartStreamTimer": false, + "autoStartRecordTimer": false, + "pauseRecordTimer": true + }, + "auto-scene-switcher": { + "interval": 300, + "non_matching_scene": "", + "switch_if_not_matching": false, + "active": false, + "switches": [] + } + }, + "sources": [ + { + "prev_ver": 503316482, + "name": "#Away Fouls", + "uuid": "d74d6cd3-42c5-4bef-9039-9692ba780f88", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "from_file": false, + "text": "0", + "text_file": "/Users/piercegordon/Desktop/OBS/Scoreboard txt flies/away fouls.txt" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 503316482, + "name": "#Home Fouls", + "uuid": "2359e37c-15d2-4c2e-ab79-57be082c8c72", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "from_file": false, + "text": "0", + "text_file": "/Users/piercegordon/Desktop/OBS/Scoreboard txt flies/home fouls.txt" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 503316482, + "name": "Away Fouls", + "uuid": "4501f19e-0c82-4806-ba7a-d0d705e881bb", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "text": "Fouls:" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 503316482, + "name": "Away score", + "uuid": "dec1061a-b1db-4f93-9201-3976530ebfad", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "from_file": false, + "text": "18", + "text_file": "/Users/piercegordon/Desktop/OBS/Scoreboard txt flies/away score.txt" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 503316482, + "name": "Away Team Logo", + "uuid": "a4861d4a-50e6-4e7d-bb0b-7db13f0cd095", + "id": "image_source", + "versioned_id": "image_source", + "settings": { + "file": "/Users/piercegordon/Desktop/OBS/OBS overlays/Scoreboard/Scoreboard parts/logo-placeholder-image.png" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 503316482, + "name": "Away Team Name", + "uuid": "8b42977e-4daa-4929-889a-7ce2e45e115d", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "font": { + "face": "NCAA Michigan St Spartans", + "style": "Regular", + "size": 256, + "flags": 0 + }, + "undo_suuid": "8b42977e-4daa-4929-889a-7ce2e45e115d", + "text": "Guest" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 503316482, + "name": "Clock", + "uuid": "be6f57a5-cd94-4260-8329-aa4e86ec1a3f", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "from_file": false, + "text": "8:00", + "text_file": "/Users/piercegordon/Desktop/OBS/Scoreboard txt flies/Time.txt" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 503316482, + "name": "Final", + "uuid": "acfb7c7a-8736-4745-a907-fe3586e5963d", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "text": "Final" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 503316482, + "name": "Home Fouls", + "uuid": "e1c3cb5a-0a94-48be-aa59-1103d8d79b3f", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "text": "Fouls:" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 503316482, + "name": "Home score", + "uuid": "8fb77bc1-1460-4ca7-ae71-3bd32a5bd8c8", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "from_file": false, + "text": "24", + "text_file": "/Users/piercegordon/Desktop/OBS/Scoreboard txt flies/Home score.txt" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 503316482, + "name": "Home Team Logo", + "uuid": "c8b24bd4-5140-4618-9cb3-112270244e59", + "id": "image_source", + "versioned_id": "image_source", + "settings": { + "file": "/Users/piercegordon/Desktop/OBS/OBS overlays/Scoreboard/Scoreboard parts/logo-placeholder-image.png" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 503316482, + "name": "Home Team Name", + "uuid": "e2e7dd36-68a6-4bd2-9d05-87ff2fbaefc3", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "text": "Home", + "font": { + "face": "NCAA Michigan St Spartans", + "style": "Regular", + "size": 256, + "flags": 0 + } + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 503316482, + "name": "Left base Scoreboard", + "uuid": "364e7649-2c99-40d6-8ba9-977ed8ab084c", + "id": "color_source", + "versioned_id": "color_source_v3", + "settings": { + "color": 4294967295 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {}, + "filters": [ + { + "prev_ver": 503316482, + "name": "Image Mask/Blend", + "uuid": "3c431010-db7c-49ed-88d3-b46bb3f47716", + "id": "mask_filter", + "versioned_id": "mask_filter_v2", + "settings": { + "image_path": "/Users/piercegordon/Desktop/OBS/OBS overlays/Scoreboard/Scoreboard parts/Left base Scoreboard.png", + "stretch": false, + "type": "mask_alpha_filter.effect", + "color": 4294967295 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + } + ] + }, + { + "prev_ver": 503316482, + "name": "Left Scoreboard", + "uuid": "163689d5-3a0f-4f6e-ad0a-53da995ae08c", + "id": "color_source", + "versioned_id": "color_source_v3", + "settings": { + "undo_sname": "Left Scoreboard", + "color": 4294967295 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {}, + "filters": [ + { + "prev_ver": 503316482, + "name": "Image Mask/Blend", + "uuid": "34523512-7e7d-473d-b075-6688b79fde6f", + "id": "mask_filter", + "versioned_id": "mask_filter_v2", + "settings": { + "image_path": "/Users/piercegordon/Desktop/OBS/OBS overlays/Scoreboard/Scoreboard parts/Left Scoreboard.png", + "stretch": false, + "type": "mask_alpha_filter.effect", + "color": 4279600402 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + } + ] + }, + { + "prev_ver": 503316482, + "name": "Media Source", + "uuid": "3ca61148-3fee-486b-aed2-499ded45a18b", + "id": "ffmpeg_source", + "versioned_id": "ffmpeg_source", + "settings": { + "local_file": "/Users/piercegordon/Desktop/OBS/Scoreboard test/Scoreboard test with shotclock.mov", + "looping": true, + "clear_on_media_end": false, + "restart_on_activate": false + }, + "mixers": 255, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": { + "libobs.mute": [], + "libobs.unmute": [], + "libobs.push-to-mute": [], + "libobs.push-to-talk": [], + "MediaSource.Restart": [], + "MediaSource.Play": [], + "MediaSource.Pause": [], + "MediaSource.Stop": [] + }, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 503316482, + "name": "Middle Scoreboard", + "uuid": "fca8e8a6-fe8e-4097-a180-a36b9ce03a29", + "id": "color_source", + "versioned_id": "color_source_v3", + "settings": { + "color": 4294967295 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {}, + "filters": [ + { + "prev_ver": 503316482, + "name": "Image Mask/Blend", + "uuid": "13091513-e3d7-4f98-a76a-55ff54fc7148", + "id": "mask_filter", + "versioned_id": "mask_filter_v2", + "settings": { + "image_path": "/Users/piercegordon/Desktop/OBS/OBS overlays/Scoreboard/Scoreboard parts/Middle Scoreboard.png", + "stretch": false, + "type": "mask_alpha_filter.effect", + "color": 4278190080 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + } + ] + }, + { + "prev_ver": 503316482, + "name": "ot", + "uuid": "e96de96c-0106-4182-82b6-6cc1230cc4da", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "text": "OT" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 503316482, + "name": "Period", + "uuid": "1f840e5a-f801-483a-aaee-fb393f9abec2", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "from_file": false, + "text": "1st", + "text_file": "/Users/piercegordon/Desktop/OBS/Scoreboard txt flies/Period.txt", + "antialiasing": true + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 503316482, + "name": "Right base Scoreboard", + "uuid": "d5cd5ece-99eb-47f3-9db5-2be71d664fa2", + "id": "color_source", + "versioned_id": "color_source_v3", + "settings": { + "color": 4294967295 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {}, + "filters": [ + { + "prev_ver": 503316482, + "name": "Image Mask/Blend", + "uuid": "ce99ea5f-98d2-45ff-94c9-47e935a12a03", + "id": "mask_filter", + "versioned_id": "mask_filter_v2", + "settings": { + "image_path": "/Users/piercegordon/Desktop/OBS/OBS overlays/Scoreboard/Scoreboard parts/Right base Scoreboard.png", + "stretch": false, + "type": "mask_alpha_filter.effect", + "color": 4294966523 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + } + ] + }, + { + "prev_ver": 503316482, + "name": "Right Scoreboard", + "uuid": "3fd5eb40-e47f-4385-a7cf-7317f6b228f1", + "id": "color_source", + "versioned_id": "color_source_v3", + "settings": { + "color": 4294967295 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {}, + "filters": [ + { + "prev_ver": 503316482, + "name": "Image Mask/Blend", + "uuid": "a4c29f21-b857-4b71-afaa-2c59ee3f8e52", + "id": "mask_filter", + "versioned_id": "mask_filter_v2", + "settings": { + "image_path": "/Users/piercegordon/Desktop/OBS/OBS overlays/Scoreboard/Scoreboard parts/Right Scoreboard.png", + "stretch": false, + "type": "mask_alpha_filter.effect", + "color": 4278190318 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + } + ] + }, + { + "prev_ver": 503316482, + "name": "Scoreboard", + "uuid": "6656641a-06b4-41e5-8a06-0de8a25e38be", + "id": "scene", + "versioned_id": "scene", + "settings": { + "custom_size": false, + "id_counter": 28, + "items": [ + { + "name": "Middle Scoreboard", + "source_uuid": "fca8e8a6-fe8e-4097-a180-a36b9ce03a29", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 13, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Left base Scoreboard", + "source_uuid": "364e7649-2c99-40d6-8ba9-977ed8ab084c", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 11, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Left Scoreboard", + "source_uuid": "163689d5-3a0f-4f6e-ad0a-53da995ae08c", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 12, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Right base Scoreboard", + "source_uuid": "d5cd5ece-99eb-47f3-9db5-2be71d664fa2", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 14, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Right Scoreboard", + "source_uuid": "3fd5eb40-e47f-4385-a7cf-7317f6b228f1", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 15, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Base Scoreboard", + "source_uuid": "35d63ff5-565c-42d6-8ba2-0566c2ea354a", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 16, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": { + "collapsed": true + } + }, + { + "name": "Home score", + "source_uuid": "8fb77bc1-1460-4ca7-ae71-3bd32a5bd8c8", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 810.0, + "y": 970.0 + }, + "scale": { + "x": 0.39436620473861694, + "y": 0.39344269037246704 + }, + "align": 2, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 14, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Away score", + "source_uuid": "dec1061a-b1db-4f93-9201-3976530ebfad", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 1112.0, + "y": 969.0 + }, + "scale": { + "x": 0.40140846371650696, + "y": 0.40163934230804443 + }, + "align": 1, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 15, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "shotclock", + "source_uuid": "df6c2501-3282-4f1f-bcf1-50de0fe2d91f", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 1005.0, + "y": 979.0 + }, + "scale": { + "x": 0.19718310236930847, + "y": 0.19672131538391113 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 20, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Clock", + "source_uuid": "be6f57a5-cd94-4260-8329-aa4e86ec1a3f", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 959.94451904296875, + "y": 954.0 + }, + "scale": { + "x": 0.24569638073444366, + "y": 0.24590164422988892 + }, + "align": 0, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 23, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Period", + "source_uuid": "1f840e5a-f801-483a-aaee-fb393f9abec2", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 869.0, + "y": 985.0 + }, + "scale": { + "x": 0.17595307528972626, + "y": 0.17213115096092224 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 24, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "ot", + "source_uuid": "e96de96c-0106-4182-82b6-6cc1230cc4da", + "visible": false, + "locked": false, + "rot": 0.0, + "pos": { + "x": 860.0, + "y": 985.0 + }, + "scale": { + "x": 0.17302055656909943, + "y": 0.17001475393772125 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 19, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Final", + "source_uuid": "acfb7c7a-8736-4745-a907-fe3586e5963d", + "visible": false, + "locked": false, + "rot": 0.0, + "pos": { + "x": 894.14605712890625, + "y": 941.0 + }, + "scale": { + "x": 0.23773987591266632, + "y": 0.23770491778850555 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 2, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Scoreboard text", + "source_uuid": "877a1475-8d92-41bf-b11c-84c1bebf04e6", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 698.0, + "y": 920.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 20, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": { + "collapsed": true + } + }, + { + "name": "Away Team Logo", + "source_uuid": "a4861d4a-50e6-4e7d-bb0b-7db13f0cd095", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 1490.0, + "y": 889.0 + }, + "scale": { + "x": 0.373046875, + "y": 0.373046875 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 24, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Home Team Logo", + "source_uuid": "c8b24bd4-5140-4618-9cb3-112270244e59", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 247.0, + "y": 889.0 + }, + "scale": { + "x": 0.373046875, + "y": 0.373046875 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 18, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Logos", + "source_uuid": "9e8fbf9b-8f60-4ad9-b63f-c5c77ab9bf65", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 247.0, + "y": 889.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 25, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": { + "collapsed": true + } + }, + { + "name": "Home Fouls", + "source_uuid": "e1c3cb5a-0a94-48be-aa59-1103d8d79b3f", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 424.0, + "y": 1003.125 + }, + "scale": { + "x": 0.11021415889263153, + "y": 0.10988729447126389 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 18, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Away Fouls", + "source_uuid": "4501f19e-0c82-4806-ba7a-d0d705e881bb", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 1403.5953369140625, + "y": 1003.125 + }, + "scale": { + "x": 0.11021415889263153, + "y": 0.10988729447126389 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 35, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "#Away Fouls", + "source_uuid": "d74d6cd3-42c5-4bef-9039-9692ba780f88", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 1481.3409423828125, + "y": 1000.03125 + }, + "scale": { + "x": 0.13140110671520233, + "y": 0.12944561243057251 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 16, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "#Home Fouls", + "source_uuid": "2359e37c-15d2-4c2e-ab79-57be082c8c72", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 500.70904541015625, + "y": 999.0 + }, + "scale": { + "x": 0.12410105764865875, + "y": 0.12679304182529449 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 17, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Fouls", + "source_uuid": "b54a726e-372b-4ce2-8fa8-13b4d7ba0ab9", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 424.0, + "y": 999.0 + }, + "scale": { + "x": 1.0366088151931763, + "y": 1.03125 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 19, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": { + "collapsed": true + } + }, + { + "name": "Home Team Name", + "source_uuid": "e2e7dd36-68a6-4bd2-9d05-87ff2fbaefc3", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 559.0, + "y": 963.34417724609375 + }, + "scale": { + "x": 0.28739002346992493, + "y": 0.28688523173332214 + }, + "align": 0, + "bounds_type": 2, + "bounds_align": 9, + "bounds": { + "x": 278.0, + "y": 69.655799865722656 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 21, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Away Team Name", + "source_uuid": "8b42977e-4daa-4929-889a-7ce2e45e115d", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 1368.0, + "y": 963.34423828125 + }, + "scale": { + "x": 0.28738996386528015, + "y": 0.28688523173332214 + }, + "align": 0, + "bounds_type": 2, + "bounds_align": 2, + "bounds": { + "x": 264.0, + "y": 69.655799865722656 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 27, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Team Names", + "source_uuid": "eff50383-2102-4522-9e95-4419ac2d584b", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 420.0, + "y": 929.34417724609375 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 26, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": { + "collapsed": false + } + } + ] + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": { + "OBSBasic.SelectScene": [], + "libobs.show_scene_item.16": [], + "libobs.hide_scene_item.16": [], + "libobs.show_scene_item.20": [], + "libobs.hide_scene_item.20": [], + "libobs.show_scene_item.25": [], + "libobs.hide_scene_item.25": [], + "libobs.show_scene_item.19": [], + "libobs.hide_scene_item.19": [], + "libobs.show_scene_item.26": [], + "libobs.hide_scene_item.26": [] + }, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 503316482, + "name": "shotclock", + "uuid": "df6c2501-3282-4f1f-bcf1-50de0fe2d91f", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "from_file": false, + "text": "20", + "text_file": "/Users/piercegordon/Desktop/OBS/Scoreboard txt flies/shotclock.txt", + "color1": 4278190335, + "color2": 4278190335 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + } + ] +} \ No newline at end of file diff --git a/obs_data/test.json b/obs_data/test.json new file mode 100644 index 0000000..74a6d66 --- /dev/null +++ b/obs_data/test.json @@ -0,0 +1,2868 @@ +{ + "current_scene": "Scoreboard", + "current_program_scene": "Scoreboard", + "scene_order": [ + { + "name": "Scoreboard" + } + ], + "name": "test", + "groups": [ + { + "prev_ver": 486604803, + "name": "Base Scoreboard", + "uuid": "35d63ff5-565c-42d6-8ba2-0566c2ea354a", + "id": "group", + "versioned_id": "group", + "settings": { + "custom_size": true, + "cx": 1920, + "cy": 1080, + "id_counter": 0, + "items": [ + { + "name": "Middle Scoreboard", + "source_uuid": "fca8e8a6-fe8e-4097-a180-a36b9ce03a29", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 13, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Left base Scoreboard", + "source_uuid": "364e7649-2c99-40d6-8ba9-977ed8ab084c", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 11, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Left Scoreboard", + "source_uuid": "163689d5-3a0f-4f6e-ad0a-53da995ae08c", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 12, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Right base Scoreboard", + "source_uuid": "d5cd5ece-99eb-47f3-9db5-2be71d664fa2", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 14, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Right Scoreboard", + "source_uuid": "3fd5eb40-e47f-4385-a7cf-7317f6b228f1", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 15, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + } + ] + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": { + "libobs.show_scene_item.Middle Scoreboard": [], + "libobs.hide_scene_item.Middle Scoreboard": [], + "libobs.show_scene_item.Left base Scoreboard": [], + "libobs.hide_scene_item.Left base Scoreboard": [], + "libobs.show_scene_item.Left Scoreboard": [], + "libobs.hide_scene_item.Left Scoreboard": [], + "libobs.show_scene_item.Right base Scoreboard": [], + "libobs.hide_scene_item.Right base Scoreboard": [], + "libobs.show_scene_item.Right Scoreboard": [], + "libobs.hide_scene_item.Right Scoreboard": [] + }, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 486604803, + "name": "Fouls", + "uuid": "b54a726e-372b-4ce2-8fa8-13b4d7ba0ab9", + "id": "group", + "versioned_id": "group", + "settings": { + "custom_size": true, + "cx": 1038, + "cy": 32, + "id_counter": 0, + "items": [ + { + "name": "Home Fouls", + "source_uuid": "e1c3cb5a-0a94-48be-aa59-1103d8d79b3f", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 4.0 + }, + "scale": { + "x": 0.10632184147834778, + "y": 0.10655737668275833 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 18, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Away Fouls", + "source_uuid": "4501f19e-0c82-4806-ba7a-d0d705e881bb", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 945.0, + "y": 4.0 + }, + "scale": { + "x": 0.10632184147834778, + "y": 0.10655737668275833 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 35, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "#Away Fouls", + "source_uuid": "d74d6cd3-42c5-4bef-9039-9692ba780f88", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 1020.0, + "y": 1.0 + }, + "scale": { + "x": 0.12676055729389191, + "y": 0.12552301585674286 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 16, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "#Home Fouls", + "source_uuid": "2359e37c-15d2-4c2e-ab79-57be082c8c72", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 74.0, + "y": 0.0 + }, + "scale": { + "x": 0.11971831321716309, + "y": 0.12295082211494446 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 17, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + } + ] + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": { + "libobs.show_scene_item.Home Fouls": [], + "libobs.hide_scene_item.Home Fouls": [], + "libobs.show_scene_item.Away Fouls": [], + "libobs.hide_scene_item.Away Fouls": [], + "libobs.show_scene_item.#Away Fouls": [], + "libobs.hide_scene_item.#Away Fouls": [], + "libobs.show_scene_item.#Home Fouls": [], + "libobs.hide_scene_item.#Home Fouls": [] + }, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 486604803, + "name": "Scoreboard text", + "uuid": "877a1475-8d92-41bf-b11c-84c1bebf04e6", + "id": "group", + "versioned_id": "group", + "settings": { + "custom_size": true, + "cx": 528, + "cy": 107, + "id_counter": 3, + "items": [ + { + "name": "Home score", + "source_uuid": "8fb77bc1-1460-4ca7-ae71-3bd32a5bd8c8", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 112.0, + "y": 50.0 + }, + "scale": { + "x": 0.39436620473861694, + "y": 0.39344269037246704 + }, + "align": 2, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 14, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Away score", + "source_uuid": "dec1061a-b1db-4f93-9201-3976530ebfad", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 414.0, + "y": 49.0 + }, + "scale": { + "x": 0.40140846371650696, + "y": 0.40163934230804443 + }, + "align": 1, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 15, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "shotclock", + "source_uuid": "df6c2501-3282-4f1f-bcf1-50de0fe2d91f", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 307.0, + "y": 59.0 + }, + "scale": { + "x": 0.19718310236930847, + "y": 0.19672131538391113 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 20, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Clock", + "source_uuid": "be6f57a5-cd94-4260-8329-aa4e86ec1a3f", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 261.94451904296875, + "y": 34.0 + }, + "scale": { + "x": 0.24569638073444366, + "y": 0.24590164422988892 + }, + "align": 0, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 23, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Period", + "source_uuid": "1f840e5a-f801-483a-aaee-fb393f9abec2", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 171.0, + "y": 65.0 + }, + "scale": { + "x": 0.17595307528972626, + "y": 0.17213115096092224 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 24, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "ot", + "source_uuid": "e96de96c-0106-4182-82b6-6cc1230cc4da", + "visible": false, + "locked": false, + "rot": 0.0, + "pos": { + "x": 162.0, + "y": 65.0 + }, + "scale": { + "x": 0.17302055656909943, + "y": 0.17001475393772125 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 19, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Final", + "source_uuid": "acfb7c7a-8736-4745-a907-fe3586e5963d", + "visible": false, + "locked": false, + "rot": 0.0, + "pos": { + "x": 196.14605712890625, + "y": 21.0 + }, + "scale": { + "x": 0.23773987591266632, + "y": 0.23770491778850555 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 2, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + } + ] + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": { + "libobs.show_scene_item.Home score": [], + "libobs.hide_scene_item.Home score": [], + "libobs.show_scene_item.Away score": [], + "libobs.hide_scene_item.Away score": [], + "libobs.show_scene_item.shotclock": [], + "libobs.hide_scene_item.shotclock": [], + "libobs.show_scene_item.Clock": [], + "libobs.hide_scene_item.Clock": [], + "libobs.show_scene_item.Period": [], + "libobs.hide_scene_item.Period": [], + "libobs.show_scene_item.ot": [], + "libobs.hide_scene_item.ot": [], + "libobs.show_scene_item.Final": [], + "libobs.hide_scene_item.Final": [] + }, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 486604803, + "name": "Logos", + "uuid": "9e8fbf9b-8f60-4ad9-b63f-c5c77ab9bf65", + "id": "group", + "versioned_id": "group", + "settings": { + "custom_size": true, + "cx": 1434, + "cy": 191, + "id_counter": 0, + "items": [ + { + "name": "Away Team Logo", + "source_uuid": "a4861d4a-50e6-4e7d-bb0b-7db13f0cd095", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 1243.0, + "y": 0.0 + }, + "scale": { + "x": 0.373046875, + "y": 0.373046875 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 24, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Home Team Logo", + "source_uuid": "c8b24bd4-5140-4618-9cb3-112270244e59", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 0.373046875, + "y": 0.373046875 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 18, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + } + ] + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": { + "libobs.show_scene_item.Away Team Logo": [], + "libobs.hide_scene_item.Away Team Logo": [], + "libobs.show_scene_item.Home Team Logo": [], + "libobs.hide_scene_item.Home Team Logo": [] + }, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 486604803, + "name": "Team Names", + "uuid": "eff50383-2102-4522-9e95-4419ac2d584b", + "id": "group", + "versioned_id": "group", + "settings": { + "custom_size": true, + "cx": 1080, + "cy": 70, + "id_counter": 0, + "items": [ + { + "name": "Home Team Name", + "source_uuid": "e2e7dd36-68a6-4bd2-9d05-87ff2fbaefc3", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 139.0, + "y": 34.0 + }, + "scale": { + "x": 0.28739002346992493, + "y": 0.28688523173332214 + }, + "align": 0, + "bounds_type": 2, + "bounds_align": 9, + "bounds": { + "x": 278.0, + "y": 69.655799865722656 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 21, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Away Team Name", + "source_uuid": "8b42977e-4daa-4929-889a-7ce2e45e115d", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 948.0, + "y": 34.00006103515625 + }, + "scale": { + "x": 0.28738996386528015, + "y": 0.28688523173332214 + }, + "align": 0, + "bounds_type": 2, + "bounds_align": 2, + "bounds": { + "x": 264.0, + "y": 69.655799865722656 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 27, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + } + ] + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": { + "libobs.show_scene_item.Home Team Name": [], + "libobs.hide_scene_item.Home Team Name": [] + }, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + } + ], + "quick_transitions": [ + { + "name": "Cut", + "duration": 300, + "hotkeys": [], + "id": 7, + "fade_to_black": false + }, + { + "name": "Fade", + "duration": 300, + "hotkeys": [], + "id": 8, + "fade_to_black": false + }, + { + "name": "Fade", + "duration": 300, + "hotkeys": [], + "id": 9, + "fade_to_black": true + } + ], + "transitions": [], + "saved_projectors": [], + "current_transition": "Fade", + "transition_duration": 300, + "preview_locked": false, + "scaling_enabled": false, + "scaling_level": 0, + "scaling_off_x": 0.0, + "scaling_off_y": 0.0, + "virtual-camera": { + "type": 0, + "internal": 0 + }, + "modules": { + "transition-table": { + "transitions": [], + "dialog_width": 1512, + "dialog_height": 922, + "enable_hotkey": [], + "disable_hotkey": [] + }, + "copyTransformHotkey": [], + "pasteTransformHotkey": [], + "advanced-scene-switcher": { + "sceneGroups": [], + "macroProperties": { + "highlightExecuted": false, + "highlightConditions": false, + "highlightActions": false, + "newMacroRegisterHotkey": false + }, + "macros": [], + "variables": [], + "switches": [], + "ignoreWindows": [], + "screenRegion": [], + "pauseEntries": [], + "sceneRoundTrip": [], + "sceneTransitions": [], + "defaultTransitions": [], + "defTransitionDelay": 0, + "ignoreIdleWindows": [], + "idleTargetType": 0, + "idleSceneName": "", + "idleTransitionName": "", + "idleEnable": false, + "idleTime": 60, + "executableSwitches": [], + "randomSwitches": [], + "fileSwitches": [], + "readEnabled": false, + "readPath": "", + "writeEnabled": false, + "writePath": "", + "mediaSwitches": [], + "timeSwitches": [], + "audioSwitches": [], + "audioFallbackTargetType": 0, + "audioFallbackScene": "", + "audioFallbackTransition": "", + "audioFallbackEnable": false, + "audioFallbackDuration": { + "value": { + "value": 0.0, + "type": 0 + }, + "unit": 0, + "version": 1 + }, + "videoSwitches": [], + "ServerEnabled": false, + "ServerPort": 55555, + "LockToIPv4": false, + "ClientEnabled": false, + "Address": "", + "ClientPort": 55555, + "SendSceneChange": true, + "SendSceneChangeAll": true, + "SendPreview": true, + "triggers": [], + "interval": 300, + "non_matching_scene": "", + "switch_if_not_matching": 0, + "noMatchDelay": { + "value": { + "value": 0.0, + "type": 0 + }, + "unit": 0, + "version": 1 + }, + "cooldown": { + "value": { + "value": 0.0, + "type": 0 + }, + "unit": 0, + "version": 1 + }, + "active": false, + "startup_behavior": 0, + "autoStartEvent": 0, + "verbose": false, + "showSystemTrayNotifications": false, + "disableHints": false, + "disableFilterComboboxFilter": false, + "warnPluginLoadFailure": true, + "hideLegacyTabs": true, + "priority0": 10, + "priority1": 0, + "priority2": 2, + "priority3": 8, + "priority4": 6, + "priority5": 9, + "priority6": 7, + "priority7": 4, + "priority8": 1, + "priority9": 5, + "priority10": 3, + "threadPriority": 3, + "transitionOverrideOverride": false, + "adjustActiveTransitionType": true, + "lastImportPath": "", + "startHotkey": [], + "stopHotkey": [], + "toggleHotkey": [], + "upMacroSegmentHotkey": [], + "downMacroSegmentHotkey": [], + "removeMacroSegmentHotkey": [], + "generalTabPos": 0, + "macroTabPos": 1, + "transitionTabPos": 15, + "pauseTabPos": 16, + "titleTabPos": 2, + "exeTabPos": 3, + "regionTabPos": 4, + "mediaTabPos": 5, + "fileTabPos": 6, + "randomTabPos": 7, + "timeTabPos": 8, + "idleTabPos": 9, + "sequenceTabPos": 10, + "audioTabPos": 11, + "videoTabPos": 12, + "networkTabPos": 13, + "sceneGroupTabPos": 14, + "triggerTabPos": 17, + "saveWindowGeo": false, + "windowPosX": 0, + "windowPosY": 0, + "windowWidth": 0, + "windowHeight": 0, + "macroListMacroEditSplitterPosition": [], + "version": "267855fded058e22e682bf2888c822fb419d8b55", + "twitchConnections": [], + "connections": [] + }, + "scripts-tool": [], + "output-timer": { + "streamTimerHours": 0, + "streamTimerMinutes": 0, + "streamTimerSeconds": 30, + "recordTimerHours": 0, + "recordTimerMinutes": 0, + "recordTimerSeconds": 30, + "autoStartStreamTimer": false, + "autoStartRecordTimer": false, + "pauseRecordTimer": true + }, + "auto-scene-switcher": { + "interval": 300, + "non_matching_scene": "", + "switch_if_not_matching": false, + "active": false, + "switches": [] + } + }, + "sources": [ + { + "prev_ver": 486604803, + "name": "#Away Fouls", + "uuid": "d74d6cd3-42c5-4bef-9039-9692ba780f88", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "from_file": false, + "text": "0", + "text_file": "/Users/piercegordon/Desktop/OBS/Scoreboard txt flies/away fouls.txt" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 486604803, + "name": "#Home Fouls", + "uuid": "2359e37c-15d2-4c2e-ab79-57be082c8c72", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "from_file": false, + "text": "0", + "text_file": "/Users/piercegordon/Desktop/OBS/Scoreboard txt flies/home fouls.txt" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 486604803, + "name": "Away Fouls", + "uuid": "4501f19e-0c82-4806-ba7a-d0d705e881bb", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "text": "Fouls:" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 486604803, + "name": "Away score", + "uuid": "dec1061a-b1db-4f93-9201-3976530ebfad", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "from_file": false, + "text": "18", + "text_file": "/Users/piercegordon/Desktop/OBS/Scoreboard txt flies/away score.txt" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 486604803, + "name": "Away Team Logo", + "uuid": "a4861d4a-50e6-4e7d-bb0b-7db13f0cd095", + "id": "image_source", + "versioned_id": "image_source", + "settings": { + "file": "/Users/piercegordon/Desktop/OBS/OBS overlays/Scoreboard/Scoreboard parts/logo-placeholder-image.png" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 486604803, + "name": "Away Team Name", + "uuid": "8b42977e-4daa-4929-889a-7ce2e45e115d", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "text": "Guest" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 486604803, + "name": "Clock", + "uuid": "be6f57a5-cd94-4260-8329-aa4e86ec1a3f", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "from_file": false, + "text": "8:00", + "text_file": "/Users/piercegordon/Desktop/OBS/Scoreboard txt flies/Time.txt" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 486604803, + "name": "Final", + "uuid": "acfb7c7a-8736-4745-a907-fe3586e5963d", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "text": "Final" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 486604803, + "name": "Home Fouls", + "uuid": "e1c3cb5a-0a94-48be-aa59-1103d8d79b3f", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "text": "Fouls:" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 486604803, + "name": "Home score", + "uuid": "8fb77bc1-1460-4ca7-ae71-3bd32a5bd8c8", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "from_file": false, + "text": "24", + "text_file": "/Users/piercegordon/Desktop/OBS/Scoreboard txt flies/Home score.txt" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 486604803, + "name": "Home Team Logo", + "uuid": "c8b24bd4-5140-4618-9cb3-112270244e59", + "id": "image_source", + "versioned_id": "image_source", + "settings": { + "file": "/Users/piercegordon/Desktop/OBS/OBS overlays/Scoreboard/Scoreboard parts/logo-placeholder-image.png" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 486604803, + "name": "Home Team Name", + "uuid": "e2e7dd36-68a6-4bd2-9d05-87ff2fbaefc3", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "text": "Home" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 486604803, + "name": "Left base Scoreboard", + "uuid": "364e7649-2c99-40d6-8ba9-977ed8ab084c", + "id": "color_source", + "versioned_id": "color_source_v3", + "settings": { + "color": 4294967295 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {}, + "filters": [ + { + "prev_ver": 486604803, + "name": "Image Mask/Blend", + "uuid": "3c431010-db7c-49ed-88d3-b46bb3f47716", + "id": "mask_filter", + "versioned_id": "mask_filter_v2", + "settings": { + "image_path": "/Users/piercegordon/Desktop/OBS/OBS overlays/Scoreboard/Scoreboard parts/Left base Scoreboard.png", + "stretch": false, + "type": "mask_alpha_filter.effect", + "color": 4294967295 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + } + ] + }, + { + "prev_ver": 486604803, + "name": "Left Scoreboard", + "uuid": "163689d5-3a0f-4f6e-ad0a-53da995ae08c", + "id": "color_source", + "versioned_id": "color_source_v3", + "settings": { + "undo_sname": "Left Scoreboard", + "color": 4294967295 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {}, + "filters": [ + { + "prev_ver": 486604803, + "name": "Image Mask/Blend", + "uuid": "34523512-7e7d-473d-b075-6688b79fde6f", + "id": "mask_filter", + "versioned_id": "mask_filter_v2", + "settings": { + "image_path": "/Users/piercegordon/Desktop/OBS/OBS overlays/Scoreboard/Scoreboard parts/Left Scoreboard.png", + "stretch": false, + "type": "mask_alpha_filter.effect", + "color": 4279600402 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + } + ] + }, + { + "prev_ver": 486604803, + "name": "Middle Scoreboard", + "uuid": "fca8e8a6-fe8e-4097-a180-a36b9ce03a29", + "id": "color_source", + "versioned_id": "color_source_v3", + "settings": { + "color": 4294967295 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {}, + "filters": [ + { + "prev_ver": 486604803, + "name": "Image Mask/Blend", + "uuid": "13091513-e3d7-4f98-a76a-55ff54fc7148", + "id": "mask_filter", + "versioned_id": "mask_filter_v2", + "settings": { + "image_path": "/Users/piercegordon/Desktop/OBS/OBS overlays/Scoreboard/Scoreboard parts/Middle Scoreboard.png", + "stretch": false, + "type": "mask_alpha_filter.effect", + "color": 4278190080 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + } + ] + }, + { + "prev_ver": 486604803, + "name": "ot", + "uuid": "e96de96c-0106-4182-82b6-6cc1230cc4da", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "text": "OT" + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 486604803, + "name": "Period", + "uuid": "1f840e5a-f801-483a-aaee-fb393f9abec2", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "from_file": false, + "text": "1st", + "text_file": "/Users/piercegordon/Desktop/OBS/Scoreboard txt flies/Period.txt", + "antialiasing": true + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 486604803, + "name": "Right base Scoreboard", + "uuid": "d5cd5ece-99eb-47f3-9db5-2be71d664fa2", + "id": "color_source", + "versioned_id": "color_source_v3", + "settings": { + "color": 4294967295 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {}, + "filters": [ + { + "prev_ver": 486604803, + "name": "Image Mask/Blend", + "uuid": "ce99ea5f-98d2-45ff-94c9-47e935a12a03", + "id": "mask_filter", + "versioned_id": "mask_filter_v2", + "settings": { + "image_path": "/Users/piercegordon/Desktop/OBS/OBS overlays/Scoreboard/Scoreboard parts/Right base Scoreboard.png", + "stretch": false, + "type": "mask_alpha_filter.effect", + "color": 4294966523 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + } + ] + }, + { + "prev_ver": 486604803, + "name": "Right Scoreboard", + "uuid": "3fd5eb40-e47f-4385-a7cf-7317f6b228f1", + "id": "color_source", + "versioned_id": "color_source_v3", + "settings": { + "color": 4294967295 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {}, + "filters": [ + { + "prev_ver": 486604803, + "name": "Image Mask/Blend", + "uuid": "a4c29f21-b857-4b71-afaa-2c59ee3f8e52", + "id": "mask_filter", + "versioned_id": "mask_filter_v2", + "settings": { + "image_path": "/Users/piercegordon/Desktop/OBS/OBS overlays/Scoreboard/Scoreboard parts/Right Scoreboard.png", + "stretch": false, + "type": "mask_alpha_filter.effect", + "color": 4278190318 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + } + ] + }, + { + "prev_ver": 486604803, + "name": "Scoreboard", + "uuid": "6656641a-06b4-41e5-8a06-0de8a25e38be", + "id": "scene", + "versioned_id": "scene", + "settings": { + "custom_size": false, + "id_counter": 27, + "items": [ + { + "name": "Middle Scoreboard", + "source_uuid": "fca8e8a6-fe8e-4097-a180-a36b9ce03a29", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 13, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Left base Scoreboard", + "source_uuid": "364e7649-2c99-40d6-8ba9-977ed8ab084c", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 11, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Left Scoreboard", + "source_uuid": "163689d5-3a0f-4f6e-ad0a-53da995ae08c", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 12, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Right base Scoreboard", + "source_uuid": "d5cd5ece-99eb-47f3-9db5-2be71d664fa2", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 14, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Right Scoreboard", + "source_uuid": "3fd5eb40-e47f-4385-a7cf-7317f6b228f1", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 15, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Base Scoreboard", + "source_uuid": "35d63ff5-565c-42d6-8ba2-0566c2ea354a", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 0.0, + "y": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 16, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": { + "collapsed": true + } + }, + { + "name": "Home score", + "source_uuid": "8fb77bc1-1460-4ca7-ae71-3bd32a5bd8c8", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 810.0, + "y": 970.0 + }, + "scale": { + "x": 0.39436620473861694, + "y": 0.39344269037246704 + }, + "align": 2, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 14, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Away score", + "source_uuid": "dec1061a-b1db-4f93-9201-3976530ebfad", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 1112.0, + "y": 969.0 + }, + "scale": { + "x": 0.40140846371650696, + "y": 0.40163934230804443 + }, + "align": 1, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 15, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "shotclock", + "source_uuid": "df6c2501-3282-4f1f-bcf1-50de0fe2d91f", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 1005.0, + "y": 979.0 + }, + "scale": { + "x": 0.19718310236930847, + "y": 0.19672131538391113 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 20, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Clock", + "source_uuid": "be6f57a5-cd94-4260-8329-aa4e86ec1a3f", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 959.94451904296875, + "y": 954.0 + }, + "scale": { + "x": 0.24569638073444366, + "y": 0.24590164422988892 + }, + "align": 0, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 23, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Period", + "source_uuid": "1f840e5a-f801-483a-aaee-fb393f9abec2", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 869.0, + "y": 985.0 + }, + "scale": { + "x": 0.17595307528972626, + "y": 0.17213115096092224 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 24, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "ot", + "source_uuid": "e96de96c-0106-4182-82b6-6cc1230cc4da", + "visible": false, + "locked": false, + "rot": 0.0, + "pos": { + "x": 860.0, + "y": 985.0 + }, + "scale": { + "x": 0.17302055656909943, + "y": 0.17001475393772125 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 19, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Final", + "source_uuid": "acfb7c7a-8736-4745-a907-fe3586e5963d", + "visible": false, + "locked": false, + "rot": 0.0, + "pos": { + "x": 894.14605712890625, + "y": 941.0 + }, + "scale": { + "x": 0.23773987591266632, + "y": 0.23770491778850555 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 2, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Scoreboard text", + "source_uuid": "877a1475-8d92-41bf-b11c-84c1bebf04e6", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 698.0, + "y": 920.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 20, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": { + "collapsed": true + } + }, + { + "name": "Away Team Logo", + "source_uuid": "a4861d4a-50e6-4e7d-bb0b-7db13f0cd095", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 1490.0, + "y": 889.0 + }, + "scale": { + "x": 0.373046875, + "y": 0.373046875 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 24, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Home Team Logo", + "source_uuid": "c8b24bd4-5140-4618-9cb3-112270244e59", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 247.0, + "y": 889.0 + }, + "scale": { + "x": 0.373046875, + "y": 0.373046875 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 18, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Logos", + "source_uuid": "9e8fbf9b-8f60-4ad9-b63f-c5c77ab9bf65", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 247.0, + "y": 889.0 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 25, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": { + "collapsed": true + } + }, + { + "name": "Home Fouls", + "source_uuid": "e1c3cb5a-0a94-48be-aa59-1103d8d79b3f", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 424.0, + "y": 1003.125 + }, + "scale": { + "x": 0.11021415889263153, + "y": 0.10988729447126389 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 18, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Away Fouls", + "source_uuid": "4501f19e-0c82-4806-ba7a-d0d705e881bb", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 1403.5953369140625, + "y": 1003.125 + }, + "scale": { + "x": 0.11021415889263153, + "y": 0.10988729447126389 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 35, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "#Away Fouls", + "source_uuid": "d74d6cd3-42c5-4bef-9039-9692ba780f88", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 1481.3409423828125, + "y": 1000.03125 + }, + "scale": { + "x": 0.13140110671520233, + "y": 0.12944561243057251 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 16, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "#Home Fouls", + "source_uuid": "2359e37c-15d2-4c2e-ab79-57be082c8c72", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 500.70904541015625, + "y": 999.0 + }, + "scale": { + "x": 0.12410105764865875, + "y": 0.12679304182529449 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 1.0, + "y": 1.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 17, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Fouls", + "source_uuid": "b54a726e-372b-4ce2-8fa8-13b4d7ba0ab9", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 424.0, + "y": 999.0 + }, + "scale": { + "x": 1.0366088151931763, + "y": 1.03125 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 19, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": { + "collapsed": true + } + }, + { + "name": "Home Team Name", + "source_uuid": "e2e7dd36-68a6-4bd2-9d05-87ff2fbaefc3", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 559.0, + "y": 963.34417724609375 + }, + "scale": { + "x": 0.28739002346992493, + "y": 0.28688523173332214 + }, + "align": 0, + "bounds_type": 2, + "bounds_align": 9, + "bounds": { + "x": 278.0, + "y": 69.655799865722656 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 21, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Away Team Name", + "source_uuid": "8b42977e-4daa-4929-889a-7ce2e45e115d", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 1368.0, + "y": 963.34423828125 + }, + "scale": { + "x": 0.28738996386528015, + "y": 0.28688523173332214 + }, + "align": 0, + "bounds_type": 2, + "bounds_align": 2, + "bounds": { + "x": 264.0, + "y": 69.655799865722656 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 27, + "group_item_backup": true, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": {} + }, + { + "name": "Team Names", + "source_uuid": "eff50383-2102-4522-9e95-4419ac2d584b", + "visible": true, + "locked": false, + "rot": 0.0, + "pos": { + "x": 420.0, + "y": 929.34417724609375 + }, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "align": 5, + "bounds_type": 0, + "bounds_align": 0, + "bounds": { + "x": 0.0, + "y": 0.0 + }, + "crop_left": 0, + "crop_top": 0, + "crop_right": 0, + "crop_bottom": 0, + "id": 26, + "group_item_backup": false, + "scale_filter": "disable", + "blend_method": "default", + "blend_type": "normal", + "show_transition": { + "duration": 0 + }, + "hide_transition": { + "duration": 0 + }, + "private_settings": { + "collapsed": true + } + } + ] + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": { + "OBSBasic.SelectScene": [], + "libobs.show_scene_item.Base Scoreboard": [], + "libobs.hide_scene_item.Base Scoreboard": [], + "libobs.show_scene_item.Fouls": [], + "libobs.hide_scene_item.Fouls": [], + "libobs.show_scene_item.Scoreboard text": [], + "libobs.hide_scene_item.Scoreboard text": [], + "libobs.show_scene_item.Logos": [], + "libobs.hide_scene_item.Logos": [], + "libobs.show_scene_item.Team Names": [], + "libobs.hide_scene_item.Team Names": [], + "libobs.show_scene_item.Away Team Name": [], + "libobs.hide_scene_item.Away Team Name": [] + }, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + }, + { + "prev_ver": 486604803, + "name": "shotclock", + "uuid": "df6c2501-3282-4f1f-bcf1-50de0fe2d91f", + "id": "text_ft2_source", + "versioned_id": "text_ft2_source_v2", + "settings": { + "from_file": false, + "text": "20", + "text_file": "/Users/piercegordon/Desktop/OBS/Scoreboard txt flies/shotclock.txt", + "color1": 4278190335, + "color2": 4278190335 + }, + "mixers": 0, + "sync": 0, + "flags": 0, + "volume": 1.0, + "balance": 0.5, + "enabled": true, + "muted": false, + "push-to-mute": false, + "push-to-mute-delay": 0, + "push-to-talk": false, + "push-to-talk-delay": 0, + "hotkeys": {}, + "deinterlace_mode": 0, + "deinterlace_field_order": 0, + "monitoring_type": 0, + "private_settings": {} + } + ] +} \ No newline at end of file diff --git a/obs_websocket.py b/obs_websocket.py new file mode 100644 index 0000000..e340aef --- /dev/null +++ b/obs_websocket.py @@ -0,0 +1,256 @@ +import json +from os import path +import obsws_python as obs +from sc_logging import logger + + +def open_obs_websocket(server_info): + # Open a websocket connection to OBS + try: + cl = obs.ReqClient( + host=server_info["ip"], + port=server_info["port"], + password=server_info["password"], + timeout=10, + ) + resp = cl.get_version() + logger.info(f"OBS Version: {resp.obs_version}") + return cl + except Exception as e: + logger.warn(f"Error: {e}") + return None + + +def get_all_sources(obs_client: obs.ReqClient): + # Get all the sources from OBS + try: + # get all scenes + resp = obs_client.get_scene_list() + scenes = resp.scenes + # get all sources from all scenes + sources = [] + for scene in scenes: + resp = obs_client.get_scene_item_list(scene["sceneName"]) + # add the sources with their scene name + for source in resp.scene_items: + source["sceneName"] = scene["sceneName"] + sources.append(source) + return sources + except Exception as e: + logger.exception("Error: unable to get all sources") + return None + + +def get_source_by_name(obs_client: obs.ReqClient, source_name): + # Get a source from OBS by name + try: + # get all scenes + resp = obs_client.get_scene_list() + scenes = resp.scenes + # get all sources from all scenes + sources = [] + for scene in scenes: + resp = obs_client.get_scene_item_list(scene["sceneName"]) + # add the sources with their scene name + for source in resp.scene_items: + source["sceneName"] = scene["sceneName"] + sources.append(source) + # find the source by name + for source in sources: + if source["sourceName"] == source_name: + return source + return None + except Exception as e: + logger.exception("Error: unable to get source by name") + return None + + +def get_source_screenshot(obs_client: obs.ReqClient, source_name, width, height): + # Get a screenshot of a source from OBS + try: + resp = obs_client.get_source_screenshot( + source_name, img_format="png", width=width, height=height, quality=100 + ) + return resp.image_data + except Exception as e: + logger.exception("Error: unable to get source screenshot") + return None + + +def update_text_source(obs_client: obs.ReqClient, source_name, text) -> None: + # Update a text source in OBS using SetInputSettings + try: + # get the source + obs_client.set_input_settings(source_name, {"text": text}, True) + except Exception as e: + logger.exception("Error: unable to update text source") + + +def clear_or_create_new_scene(obs_client: obs.ReqClient, scene_name) -> bool: + # Create a new scene in OBS or clear an existing one + try: + # check if the scene exists + resp = obs_client.get_scene_list() + except Exception as e: + logger.exception("Error: unable to get scene list") + return False + scenes = resp.scenes + scene_exists = False + for scene in scenes: + if scene["sceneName"] == scene_name: + scene_exists = True + break + # if the scene doesnt exist, create it + if not scene_exists: + # create a new scene + try: + obs_client.create_scene(scene_name) + return True + except Exception as e: + logger.exception(f"Cannot create scene. Error: {e}") + return False + + try: + # if the scene is currently selected in OBS, switch to another scene + resp = obs_client.get_current_program_scene() + if resp.current_program_scene_name == scene_name: + # get the first scene that is not the current scene + for scene in scenes: + if scene["sceneName"] != scene_name: + obs_client.set_current_program_scene(scene["sceneName"]) + break + except Exception as e: + logger.exception("Cannot change OBS program scene") + + try: + resp = obs_client.get_current_preview_scene() + if resp.current_preview_scene_name == scene_name: + # get the first scene that is not the current scene + for scene in scenes: + if scene["sceneName"] != scene_name: + obs_client.set_current_preview_scene(scene["sceneName"]) + break + except Exception as e: + logger.exception("Cannot change OBS preview scene") + + # remove all scene items + try: + resp = obs_client.get_scene_item_list(scene_name) + for scene_item in resp.scene_items: + obs_client.remove_scene_item(scene_name, scene_item["sceneItemId"]) + obs_client.remove_input(scene_item["sourceName"]) + except Exception as e: + logger.exception("Error: unable to remove scene item") + + return True + + +def create_obs_scene_from_export(obs_client, scene_name): + logger.info("creating OBS scene") + # create a new scene in OBS + clear_or_create_new_scene(obs_client, scene_name) + + # load the scene from obs_data/test.json + obs_data_path = path.abspath( + path.join( + path.dirname(__file__), "obs_data/Scoresight_OBS_scene_collection.json" + ) + ) + logger.debug(f"loading scene from '{obs_data_path}'") + with open(obs_data_path, "r") as f: + scene = json.load(f) + + # find scene source with items + scene_source = [ + source + for source in scene["sources"] + if source["uuid"] == "6656641a-06b4-41e5-8a06-0de8a25e38be" + ][0] + sources_settings = scene["sources"] + + for source in scene_source["settings"]["items"]: + # if the source is a scene, skip it + logger.debug(f"creating source {source['name']}") + # find source settings in sources_settings by the uuid + source_settings = [ + settings + for settings in sources_settings + if settings["uuid"] == source["source_uuid"] + ] + if len(source_settings) == 0: + logger.debug(f"source {source['name']} has no settings, possibly a group") + continue + source_settings = source_settings[0] + # merge settings with source['settings'] + source_settings_to_set = {**source_settings["settings"], **source} + if "file" in source_settings_to_set: + # get the base name of the image path + base_name = path.basename(source_settings_to_set["file"]) + # get the absolute path to the image + abs_path = path.abspath( + path.join( + path.dirname(__file__), + f"obs_data/Scoreboard parts/{base_name}", + ) + ) + source_settings_to_set["file"] = abs_path + + try: + # create a new source in OBS + obs_client.create_input( + sceneName=scene_name, + inputName=source["name"], + inputKind=source_settings["versioned_id"], + inputSettings=source_settings_to_set, + sceneItemEnabled=source_settings_to_set["visible"], + ) + # set the SetSceneItemTransform + if "pos" in source_settings_to_set: + # get the scene item id with GetSceneItemId + scene_item_id = obs_client.get_scene_item_id( + scene_name, source["name"] + ).scene_item_id + + transform_to_set = { + "alignment": source_settings_to_set["align"], + "cropBottom": source_settings_to_set["crop_bottom"], + "cropLeft": source_settings_to_set["crop_left"], + "cropRight": source_settings_to_set["crop_right"], + "cropTop": source_settings_to_set["crop_top"], + "positionX": source_settings_to_set["pos"]["x"], + "positionY": source_settings_to_set["pos"]["y"], + "scaleX": source_settings_to_set["scale"]["x"], + "scaleY": source_settings_to_set["scale"]["y"], + } + + obs_client.set_scene_item_transform( + scene_name, scene_item_id, transform_to_set + ) + # set the filters + if "filters" in source_settings: + for filter in source_settings["filters"]: + logger.debug( + f"creating filter {filter['name']} for source {source_settings['name']}" + ) + # if the filter has "image_path" in the settings, adjust the path to the obs_data folder + if "image_path" in filter["settings"]: + # get the base name of the image path + base_name = path.basename(filter["settings"]["image_path"]) + # get the absolute path to the image + abs_path = path.abspath( + path.join( + path.dirname(__file__), + f"obs_data/Scoreboard parts/{base_name}", + ) + ) + filter["settings"]["image_path"] = abs_path + obs_client.create_source_filter( + source["name"], + filter["name"], + filter["versioned_id"], + filter["settings"], + ) + except Exception as e: + logger.exception(f"Unable to create input and filters. Error: {e}") + + logger.info("finished creating sources") diff --git a/requirements-mac.txt b/requirements-mac.txt new file mode 100644 index 0000000..4a3f253 --- /dev/null +++ b/requirements-mac.txt @@ -0,0 +1,3 @@ +pyobjc-core +pyobjc-framework-AVFoundation +pyobjc-framework-Quartz diff --git a/requirements-win.txt b/requirements-win.txt new file mode 100644 index 0000000..868f9ca --- /dev/null +++ b/requirements-win.txt @@ -0,0 +1,3 @@ +pygetwindow +pybind11 +pywin32 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a075e86 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +cyndilib +fastapi +obsws-python +opencv-python +pillow +platformdirs +pyinstaller +pyqt6 +python-dotenv +requests +tesserocr +uvicorn diff --git a/sc_logging.py b/sc_logging.py new file mode 100644 index 0000000..f7c94ed --- /dev/null +++ b/sc_logging.py @@ -0,0 +1,59 @@ +import logging +import os +from platformdirs import user_log_dir +from datetime import datetime +from dotenv import load_dotenv + +# Load the environment variables from the .env file +load_dotenv(os.path.abspath(os.path.join(os.path.dirname(__file__), ".env"))) + +# Create a logger +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + +# get the user data directory +data_dir = user_log_dir("scoresight") +if not os.path.exists(data_dir): + os.makedirs(data_dir) + +current_time = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + +# basic config - send all logs to a file +logging.basicConfig( + filename=os.path.join(data_dir, f"scoresight_std_{current_time}.log"), + level=logging.INFO, +) + +# prepend the user data directory +log_file_path = os.path.join(data_dir, f"scoresight_{current_time}.log") + +# check to see if there are more log files, and only keep the most recent 10 +log_files = [ + f + for f in os.listdir(data_dir) + if f.startswith("scoresight_") and f.endswith(".log") +] +# sort log files by date +log_files.sort() +if len(log_files) > 10: + for f in log_files[:-10]: + os.remove(os.path.join(data_dir, f)) + +# Create a file handler +file_handler = logging.FileHandler(log_file_path) +file_handler.setLevel(logging.DEBUG) + +# Create a formatter +formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(module)s - %(message)s") +file_handler.setFormatter(formatter) + +# Add the file handler to the logger +logger.addHandler(file_handler) + +# if the .env file has a debug flag, set the logger to output to console +if os.getenv("SCORESIGHT_DEBUG"): + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.DEBUG) + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + logger.debug("Debug mode enabled") diff --git a/scoresight.iss b/scoresight.iss new file mode 100644 index 0000000..1e477e4 --- /dev/null +++ b/scoresight.iss @@ -0,0 +1,19 @@ +[Setup] +AppName=ScoreSight +AppVersion=@SCORESIGHT_VERSION@ +DefaultDirName={pf}\ScoreSight +DefaultGroupName=ScoreSight +OutputDir=.\dist +OutputBaseFilename=scoresight-setup +Compression=lzma +SolidCompression=yes +ArchitecturesInstallIn64BitMode=x64 + +[Files] +Source: "dist\scoresight\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{group}\ScoreSight"; Filename: "{app}\scoresight.exe" + +[Run] +Filename: "{app}\scoresight.exe"; Description: "Launch ScoreSight"; Flags: nowait postinstall skipifsilent diff --git a/scoresight.spec b/scoresight.spec new file mode 100644 index 0000000..009b070 --- /dev/null +++ b/scoresight.spec @@ -0,0 +1,171 @@ +# -*- mode: python ; coding: utf-8 -*- +import os + +# parse command line arguments +import argparse + +parser = argparse.ArgumentParser() +parser.add_argument('--mac_osx', action='store_true') +parser.add_argument('--win', action='store_true') +parser.add_argument('--debug', action='store_true') + +args = parser.parse_args() + +datas = [ + ('about.ui', '.'), + ('connect_obs.ui', '.'), + ('log_view.ui', '.'), + ('mainwindow.ui', '.'), + ('update_available.ui', '.'), + ('screen_capture.ui', '.'), + ('url_source.ui', '.'), + ('.env', '.'), + ('icons/circle-check.svg', './icons'), + ('icons/circle-x.svg', './icons'), + ('icons/MacOS_icon.png', './icons'), + ('icons/plus.svg', './icons'), + ('icons/splash.png', './icons'), + ('icons/trash.svg', './icons'), + ('icons/Windows-icon-open.ico', './icons'), + ('tesseract/tessdata/daktronics.traineddata', './tesseract/tessdata'), + ('tesseract/tessdata/scoreboard_general.traineddata', './tesseract/tessdata'), + ('tesseract/tessdata/scoreboard_general_large.traineddata', './tesseract/tessdata'), + ('tesseract/tessdata/eng.traineddata', './tesseract/tessdata'), + ('obs_data/Scoresight_OBS_scene_collection.json', './obs_data'), + ('obs_data/Scoreboard parts/Left Scoreboard.png', './obs_data/Scoreboard parts'), + ('obs_data/Scoreboard parts/Left base Scoreboard.png', './obs_data/Scoreboard parts'), + ('obs_data/Scoreboard parts/Middle Scoreboard.png', './obs_data/Scoreboard parts'), + ('obs_data/Scoreboard parts/Right Scoreboard.png', './obs_data/Scoreboard parts'), + ('obs_data/Scoreboard parts/Right base Scoreboard.png', './obs_data/Scoreboard parts'), + ('obs_data/Scoreboard parts/logo-placeholder-image.png', './obs_data/Scoreboard parts'), +] + +if args.win: + datas += [('win32DeviceEnum/win32DeviceEnumBind.cp311-win_amd64.pyd', './win32DeviceEnum')] + +a = Analysis( + [ + 'camera_info.py', + 'camera_view.py', + 'defaults.py', + 'file_output.py', + 'get_camera_info.py', + 'http_server.py', + 'log_view.py', + 'main.py', + 'ndi.py', + 'obs_websocket.py', + 'sc_logging.py', + 'screen_capture_source_mac.py', + 'screen_capture_source_windows.py', + 'screen_capture_source.py', + 'source_view.py', + 'storage.py', + 'tesseract.py', + 'text_detection_target.py', + 'update_check.py', + 'win32DeviceEnum/enum_devices_dshow.py', + 'vmix_output.py', + ], + pathex=[], + binaries=[], + datas=datas, + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, +) +pyz = PYZ(a.pure) + +if args.win: + splash = Splash('icons/splash.png', + binaries=a.binaries, + datas=a.datas, + text_pos=(10, 20), + text_size=10, + text_color='black') + exe = EXE( + pyz, + a.scripts, + splash, + name='scoresight', + icon='icons/Windows-icon-open.ico', + debug=args.debug is not None and args.debug, + exclude_binaries=True, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + console=args.debug is not None and args.debug, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + ) + coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + splash.binaries, + strip=False, + upx=True, + upx_exclude=[], + name='scoresight' + ) +elif args.mac_osx: + exe = EXE( + pyz, + a.binaries, + a.datas, + a.scripts, + name='scoresight', + debug=args.debug is not None and args.debug, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=os.environ.get('APPLE_APP_DEVELOPER_ID', ''), + entitlements_file='./entitlements.plist', + ) + app = BUNDLE( + exe, + name='scoresight.app', + icon='icons/MacOS_icon.png', + bundle_identifier='com.royshilkrot.scoresight', + version='0.0.1', + info_plist={ + 'NSPrincipalClass': 'NSApplication', + 'NSAppleScriptEnabled': False, + 'NSCameraUsageDescription': 'Getting images from the camera to perform OCR' + } + ) +else: + splash = Splash('icons/splash.png', + binaries=a.binaries, + datas=a.datas, + text_pos=(10, 20), + text_size=10, + text_color='black') + exe = EXE( + pyz, + a.binaries, + a.datas, + a.scripts, + splash, + splash.binaries, + name='scoresight', + icon='icons/Windows-icon-open.ico', + debug=args.debug is not None and args.debug, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + ) diff --git a/scoresight.vmix b/scoresight.vmix new file mode 100644 index 0000000..691b647 --- /dev/null +++ b/scoresight.vmix @@ -0,0 +1,207 @@ + + 9 + + C:\Program Files (x86)\vMix\titles\GT ScoreBoard\Scoreboard 1- VBA.gtzip + + + + + 1920x1080 + 333667 + 0 + + + 1920x1080 + 333667 + 0 + + + + + + + + + + + + + + + + + 0 + 0.1 + 5 + 0 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + 3 + 0 + 0 + 0 + 0 + + + + + 1 + true + 1 + 0 + 0 + 0 + + + false + + 0 + 0 + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + + 0 + None + None + None + + + + + + 333667 + 0 + 333667 + 0 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 0 + 0 + 0 + 1920x1080 + 1280x720 + + + + + 0 + 1 + 0 + 0 + 0 + 0 + 1 + 1 + + + 333667 + 0 + 333667 + 0 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 0 + 0 + 0 + + + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 0 + 0 + 0 + + + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 0 + 0 + 0 + + + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 0 + 0 + + + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 0 + 0 + + + \ No newline at end of file diff --git a/screen_capture.ui b/screen_capture.ui new file mode 100644 index 0000000..1c5b7aa --- /dev/null +++ b/screen_capture.ui @@ -0,0 +1,78 @@ + + + Dialog + + + + 0 + 0 + 400 + 92 + + + + Dialog + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + + + Window to Capture + + + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/screen_capture_source.py b/screen_capture_source.py new file mode 100644 index 0000000..f6c7142 --- /dev/null +++ b/screen_capture_source.py @@ -0,0 +1,29 @@ +import platform + + +class ScreenCaptureNotImplemented: + @staticmethod + def list_windows(): + return [] + + def __init__(self, window_name): + pass + + def isOpened(self): + return False + + def release(self): + pass + + def read(self): + return False, None + + +# This is a simple example of how to use the screen capture source in the +# platform-independent part of the code. +if platform.system() == "Darwin": + from screen_capture_source_mac import ScreenCaptureMacOS as ScreenCapture +elif platform.system() == "Windows": + from screen_capture_source_windows import ScreenCaptureWindows as ScreenCapture +else: + ScreenCapture = ScreenCaptureNotImplemented diff --git a/screen_capture_source_mac.py b/screen_capture_source_mac.py new file mode 100644 index 0000000..75eb7c4 --- /dev/null +++ b/screen_capture_source_mac.py @@ -0,0 +1,96 @@ +import Quartz +import objc +from Quartz.CoreGraphics import ( + CGImageGetWidth, + CGImageGetHeight, + CGImageGetDataProvider, + CGDataProviderCopyData, + CGWindowListCreateImage, + CGRectInfinite, + CGRectNull, +) +import numpy as np +from sc_logging import logger +import cv2 + + +class ScreenCaptureMacOS: + @staticmethod + def list_windows(): + window_list = Quartz.CGWindowListCopyWindowInfo( + Quartz.kCGWindowListOptionOnScreenOnly, Quartz.kCGNullWindowID + ) + windows = [] + for window in window_list: + # check if the window is an application window that has a layer of 0 + if window.get(Quartz.kCGWindowLayer) != 0: + continue + # Check if the window has a size and is not a menubar item + if ( + window.get(Quartz.kCGWindowBounds).get("Width") > 0 + and window.get(Quartz.kCGWindowBounds).get("Height") > 0 + ): + windows.append( + ( + window.get(Quartz.kCGWindowOwnerName, ""), + window[Quartz.kCGWindowNumber], + ) + ) + return windows + + def __init__(self, windowId=None): + self.windowId = windowId + self.window = None + if self.windowId is not None and self.windowId >= 0: + # Capture a specific window by ID + window_list = Quartz.CGWindowListCopyWindowInfo( + Quartz.kCGWindowListOptionOnScreenOnly, Quartz.kCGNullWindowID + ) + capture_window = [ + w for w in window_list if w[Quartz.kCGWindowNumber] == self.windowId + ] + if not capture_window: + raise ValueError(f"No window with ID {self.windowId} found") + self.window = capture_window[0] + + def isOpened(self): + return self.window is not None + + def read(self): + if self.windowId is None or self.windowId < 0: + # Capture the entire screen + cgimage = CGWindowListCreateImage( + CGRectInfinite, + Quartz.kCGWindowListOptionOnScreenOnly, + Quartz.kCGNullWindowID, + Quartz.kCGWindowImageDefault, + ) + else: + cgimage = CGWindowListCreateImage( + CGRectNull, + Quartz.kCGWindowListOptionIncludingWindow, + self.windowId, + Quartz.kCGWindowImageBoundsIgnoreFraming, + ) + + if cgimage is None: + logger.warn(f"Failed to create image from window {self.windowId}") + return False, None + + width = CGImageGetWidth(cgimage) + height = CGImageGetHeight(cgimage) + if width == 0 or height == 0: + logger.warn(f"Invalid image size: {width}x{height}") + return False, None + + data_provider = CGImageGetDataProvider(cgimage) + data = CGDataProviderCopyData(data_provider) + np_data = np.frombuffer(data, dtype=np.uint8) + # calculate the width from the buffer size + width = len(np_data) // height // 4 + image = np_data.reshape((height, width, 4)) # Assuming 4 channels (RGBA) + image = cv2.cvtColor(image, cv2.COLOR_RGBA2RGB) + return True, image # Convert to RGB + + def release(self): + pass diff --git a/screen_capture_source_windows.py b/screen_capture_source_windows.py new file mode 100644 index 0000000..a43fe47 --- /dev/null +++ b/screen_capture_source_windows.py @@ -0,0 +1,118 @@ +import cv2 +import numpy as np +import pygetwindow as gw +import numpy as np +import win32gui +import win32ui +import win32con +import win32api +from sc_logging import logger +from ctypes import windll + + +class ScreenCaptureWindows: + @staticmethod + def list_windows(): + # list the windows on the screen + windows = gw.getAllTitles() + return [(w, w) for w in windows if w != ""] + + def __init__(self, window_name): + self.capture_whole_screen = ( + window_name is None + or window_name == "" + or (type(window_name) == int and window_name < 0) + ) + + self.hwnd = 0 + if self.capture_whole_screen: + # Get the device context (DC) for the entire screen + self.hwnd = win32gui.GetDesktopWindow() + else: + # Find the window + self.hwnd = win32gui.FindWindow(None, window_name) + + if self.hwnd == 0: + logger.error("No window found") + return + + # Get the window device context (DC) + self.hwndDC = win32gui.GetWindowDC(self.hwnd) + if self.hwndDC == 0: + logger.error("Error getting window device context for window") + return + # Create a memory device context + self.mfcDC = win32ui.CreateDCFromHandle(self.hwndDC) + if self.mfcDC is None: + logger.error("Error creating memory device context for window") + return + self.saveDC = self.mfcDC.CreateCompatibleDC() + if self.saveDC is None: + logger.error(f"Error creating compatible memory device context for window") + return + + def isOpened(self): + return self.hwnd != 0 + + def release(self): + try: + self.saveDC.DeleteDC() + self.mfcDC.DeleteDC() + win32gui.ReleaseDC(self.hwnd, self.hwndDC) + except: + logger.exception("Error releasing screen capture resources") + + def read(self): + if self.hwnd == 0: + return False, None + + # check window handle is valid + if not win32gui.IsWindow(self.hwnd): + logger.error(f"Window handle {self.hwnd} is invalid") + return False, None + + if not self.capture_whole_screen: + # Get window dimensions + left, top, right, bot = win32gui.GetWindowRect(self.hwnd) + width = right - left + height = bot - top + else: + # Get the device context (DC) for the entire screen + width = win32api.GetSystemMetrics(win32con.SM_CXVIRTUALSCREEN) + height = win32api.GetSystemMetrics(win32con.SM_CYVIRTUALSCREEN) + left = win32api.GetSystemMetrics(win32con.SM_XVIRTUALSCREEN) + top = win32api.GetSystemMetrics(win32con.SM_YVIRTUALSCREEN) + + # Create a bitmap object + saveBitMap = win32ui.CreateBitmap() + if saveBitMap is None: + logger.error(f"Error creating bitmap for window") + return False, None + + saveBitMap.CreateCompatibleBitmap(self.mfcDC, width, height) + self.saveDC.SelectObject(saveBitMap) + + if self.capture_whole_screen: + # use bitblt to copy the window image to the bitmap + self.saveDC.BitBlt( + (0, 0), (width, height), self.mfcDC, (left, top), win32con.SRCCOPY + ) + else: + # use print window to copy the window image to the bitmap + result = windll.user32.PrintWindow(self.hwnd, self.saveDC.GetSafeHdc(), 3) + if not result: + logger.error(f"Unable to acquire screenshot! Result: {result}") + return False, None + + bmpinfo = saveBitMap.GetInfo() + bmpstr = saveBitMap.GetBitmapBits(True) + + img = np.frombuffer(bmpstr, dtype=np.uint8).reshape( + (bmpinfo["bmHeight"], bmpinfo["bmWidth"], 4) + ) + + win32gui.DeleteObject(saveBitMap.GetHandle()) + + image = np.ascontiguousarray(img) # Ensure array is contiguous + image = cv2.cvtColor(image, cv2.COLOR_RGBA2RGB) + return True, image diff --git a/scripts/imageannotator.py b/scripts/imageannotator.py new file mode 100644 index 0000000..f579784 --- /dev/null +++ b/scripts/imageannotator.py @@ -0,0 +1,91 @@ +import os +import sys +import tkinter as tk +from PIL import Image, ImageTk +from tesserocr import PyTessBaseAPI + + +class TextExtractor: + def __init__(self, master, folder_path): + self.master = master + self.master.title("Image Text Extractor") + + self.label = tk.Label(master, text="Enter the text from the image:") + self.label.pack() + + self.text_entry = tk.Entry(master, width=50) + self.text_entry.pack() + self.text_entry.bind("", self.save_text) + + self.canvas = tk.Canvas(master, width=500, height=500) + self.canvas.pack() + + self.images = [] + self.current_image = None + self.current_image_index = 0 + + self.folder_path = folder_path + + # Bind Escape key to close the program + self.master.bind("", lambda event: self.master.quit()) + + # self.api = PyTessBaseAPI( + # path="/Users/roy_shilkrot/Downloads/scoresight/tesseract/tessdata", + # lang="daktronics", + # ) + # # single word PSM + # self.api.SetPageSegMode(8) + + master.after(100, self.load_images) + + def display_image(self, image_path): + img = Image.open(image_path) + img.thumbnail((500, 500), Image.LANCZOS) + self.tk_image = ImageTk.PhotoImage(img) + self.canvas.create_image(250, 250, image=self.tk_image) + self.master.title( + f"Image Text Extractor - Image {self.current_image_index + 1}/{len(self.images)}" + ) + + def save_text(self, event=None): + text = self.text_entry.get() + if text and self.current_image: + base_name = os.path.splitext(self.current_image)[0] + with open(base_name + ".gt.txt", "w") as file: + file.write(text) + self.next_image() + + def next_image(self): + self.current_image_index += 1 + if self.current_image_index < len(self.images): + self.current_image = self.images[self.current_image_index] + self.display_image(self.current_image) + else: + self.master.quit() + + def load_images(self): + for file in os.listdir(self.folder_path): + if file.lower().endswith((".png", ".jpg", ".jpeg")): + # check there isn't a ".gt.txt" file for this image + base_name = os.path.splitext(file)[0] + if not os.path.exists( + os.path.join(self.folder_path, base_name + ".gt.txt") + ): + self.images.append(os.path.join(self.folder_path, file)) + # sort the images by creation time + self.images.sort(key=lambda x: os.path.getctime(x)) + if self.images: + self.current_image = self.images[0] + self.display_image(self.images[0]) + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Please provide the folder path as a command line argument.") + sys.exit(1) + + folder_path = sys.argv[1] + + root = tk.Tk() + app = TextExtractor(root, folder_path) + root.mainloop() diff --git a/scripts/imagecropper.py b/scripts/imagecropper.py new file mode 100644 index 0000000..622631a --- /dev/null +++ b/scripts/imagecropper.py @@ -0,0 +1,154 @@ +import os +import tkinter as tk +from PIL import Image, ImageTk +import sys +import uuid +import sys + + +class ImageCropper: + def __init__(self, master): + self.master = master + self.master.focus_force() # Add this line to give focus to the window + self.canvas = tk.Canvas(master, cursor="cross") + self.canvas.pack(fill="both", expand=True) + + self.images = [] + self.current_image = None + self.start_x = None + self.start_y = None + self.rect = None + master.after(100, self.load_images, sys.argv[1]) + self.current_image_index = 0 + + self.canvas.bind("", self.on_button_press) + self.canvas.bind("", self.on_move_press) + self.canvas.bind("", self.on_button_release) + master.bind("", self.next_image) + master.bind("n", self.next_image) + master.bind("", self.previous_image) + master.bind("p", self.previous_image) + master.bind("", self.on_window_resize) + + # Set the default window width to 640 + master.geometry("640x480") + master.bind("q", self.close_program) + + master.bind("", self.cancel_rect) + + def cancel_rect(self, event): + if self.rect: + self.canvas.delete(self.rect) + self.rect = None + + def close_program(self, event): + self.master.destroy() + + def on_window_resize(self, event=None): + if self.current_image_index is not None: + self.display_image(self.current_image_index) + + def load_images(self, folder_path): + if not os.path.exists(folder_path): + raise ValueError("The specified folder does not exist.") + + for file in os.listdir(folder_path): + if file.endswith(".png") or file.endswith(".jpg") or file.endswith(".jpeg"): + self.images.append(Image.open(os.path.join(folder_path, file))) + + if self.images: + self.display_image(0) + + def display_image(self, index): + if 0 <= index < len(self.images): + self.current_image_index = index + self.current_image = self.images[index] + original_image = self.images[index] + + window_width = self.master.winfo_width() + window_height = self.master.winfo_height() + if window_width > 1 and window_height > 1: + img_width, img_height = original_image.size + scale_width = window_width / img_width + scale_height = window_height / img_height + scale = min(scale_width, scale_height) + new_size = (int(img_width * scale), int(img_height * scale)) + + resized_image = original_image.resize(new_size, Image.LANCZOS) + self.tk_image = ImageTk.PhotoImage(resized_image) + self.canvas.create_image(0, 0, image=self.tk_image, anchor="nw") + self.canvas.config(scrollregion=self.canvas.bbox("all")) + + def on_button_press(self, event): + self.start_x = self.canvas.canvasx(event.x) + self.start_y = self.canvas.canvasy(event.y) + if not self.rect: + self.rect = self.canvas.create_rectangle( + self.start_x, self.start_y, 1, 1, outline="red" + ) + + def on_move_press(self, event): + curX, curY = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y) + self.canvas.coords(self.rect, self.start_x, self.start_y, curX, curY) + + def on_button_release(self, event): + end_x, end_y = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y) + self.save_crop(self.start_x, self.start_y, end_x, end_y) + self.start_x = None + self.start_y = None + self.rect = None + + def canvas_to_image_coords(self, x, y): + img_width, img_height = self.current_image.size + canvas_width = self.canvas.winfo_width() + canvas_height = self.canvas.winfo_height() + + # Calculate the scaling factor + scale = max(img_width / canvas_width, img_height / canvas_height) + + # Convert the coordinates from canvas to image + image_x = int(x * scale) + image_y = int(y * scale) + + return image_x, image_y + + def save_crop(self, x1, y1, x2, y2): + if self.current_image is not None: + # Normalize the coordinates to ensure they are within the image bounds + img_width, img_height = self.current_image.size + # transform to image coordinates + x1, y1 = self.canvas_to_image_coords(x1, y1) + x2, y2 = self.canvas_to_image_coords(x2, y2) + # make sure x1,y1 is top left and x2,y2 is bottom right + x1, x2 = sorted([x1, x2]) + y1, y2 = sorted([y1, y2]) + # make sure the coordinates are within the image bounds + x1, y1 = max(0, x1), max(0, y1) + x2, y2 = min(img_width, x2), min(img_height, y2) + + # Ensure valid crop area + if x2 > x1 and y2 > y1: + cropped = self.current_image.crop((x1, y1, x2, y2)) + unique_id = str(uuid.uuid4())[:8] # Generate a unique ID + folder_path = os.path.dirname(self.current_image.filename) + out_folder_path = os.path.join(folder_path, "out") + os.makedirs(out_folder_path, exist_ok=True) + save_path = os.path.join(out_folder_path, f"cropped_{unique_id}.png") + cropped.save(save_path) + + def next_image(self, event): + if self.current_image_index < len(self.images) - 1: + self.display_image(self.current_image_index + 1) + + def previous_image(self, event): + if self.current_image_index > 0: + self.display_image(self.current_image_index - 1) + + +if len(sys.argv) < 2: + print("Please provide the input folder as the first argument.") + sys.exit(1) + +root = tk.Tk() +app = ImageCropper(root) +root.mainloop() diff --git a/scripts/images_pespective_cropper.py b/scripts/images_pespective_cropper.py new file mode 100644 index 0000000..9bb22fa --- /dev/null +++ b/scripts/images_pespective_cropper.py @@ -0,0 +1,85 @@ +import os +import uuid +import cv2 +import numpy as np +import glob +import sys + +current_image = None +points = [] +output_folder = None + + +def mouse_callback(event, x, y, flags, param): + global points + + if event == cv2.EVENT_LBUTTONDOWN: + if len(points) < 4: + points.append((x, y)) + current_image_copy = current_image.copy() + for pt in points: + cv2.circle(current_image_copy, pt, 5, (0, 255, 0), -1) + # draw lines between the points with polylines + if len(points) > 1: + cv2.polylines( + current_image_copy, + [np.array(points, dtype=np.int32)], + True, + (0, 255, 0), + 2, + ) + cv2.imshow("Image", current_image_copy) + if len(points) == 4: + # find the bounding box of the points + x, y, w, h = cv2.boundingRect(np.array(points)) + homography, _ = cv2.findHomography( + np.array(points), np.array([(0, 0), (0, h), (w, h), (w, 0)]) + ) + cropped = cv2.warpPerspective(current_image, homography, (w, h)) + cropped = cv2.cvtColor(cropped, cv2.COLOR_BGR2GRAY) + _, thresholded = cv2.threshold( + cropped, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU + ) + cv2.imshow("Cropped Image", thresholded) + # save the cropped image into a folder called "cropped_images" in the same directory as the script + output_path = output_folder + "/cropped_" + str(uuid.uuid4()) + ".jpg" + print("Saving cropped image to", output_path) + cv2.imwrite(output_path, thresholded) + points = [] + + +def main(): + global points, current_image, output_folder + + if len(sys.argv) < 2: + print("Please provide the path to the image folder as a command-line argument.") + return + + image_folder_path = sys.argv[1] + output_folder = image_folder_path + "/cropped_images" + if not os.path.exists(output_folder): + os.makedirs(output_folder) + images = glob.glob(image_folder_path + "/*") + + cv2.namedWindow("Image") + cv2.setMouseCallback("Image", mouse_callback) + + for image_path in images: + points = [] + current_image = cv2.imread(image_path) + + cv2.imshow("Image", current_image) + while True: + key = cv2.waitKey(50) & 0xFF + + if key == ord(" "): + break + if key == ord("q"): + cv2.destroyAllWindows() + return + + cv2.destroyAllWindows() + + +if __name__ == "__main__": + main() diff --git a/scripts/video_cropper.py b/scripts/video_cropper.py new file mode 100644 index 0000000..3ba0578 --- /dev/null +++ b/scripts/video_cropper.py @@ -0,0 +1,147 @@ +import cv2 +import tkinter as tk +from tkinter import filedialog +import os +import sys +import os +import uuid +import numpy as np +from tqdm import tqdm + +points = [] + + +def draw_rectangle(event, x, y, flags, param): + global drawing, points + if event == cv2.EVENT_LBUTTONDOWN: + frame_copy = frame.copy() + if len(points) < 4: + points.append((x, y)) + if len(points) > 1: + for i in range(1, len(points)): + cv2.line(frame_copy, points[i - 1], points[i], (0, 255, 0), 2) + for pt in points: + cv2.circle(frame_copy, pt, 5, (0, 255, 0), -1) + cv2.imshow("First Frame", frame_copy) + if len(points) == 4: + cv2.destroyWindow("First Frame") + + +if len(sys.argv) < 2: + print("Please provide a video file path") + sys.exit(1) + +video_path = sys.argv[1] +if not os.path.exists(video_path): + print("Error: video file does not exist") + sys.exit(1) + +cap = cv2.VideoCapture(video_path) +ret, frame = cap.read() + +if not ret: + print("Error: unable to read the video file") + sys.exit(1) + +drawing = False +top_left_pt, bottom_right_pt = (-1, -1), (-1, -1) + +cv2.namedWindow("First Frame") +cv2.setMouseCallback("First Frame", draw_rectangle) + +cv2.imshow("First Frame", frame) +while True: + key = cv2.waitKey(1) & 0xFF + if key == ord("q"): + break + if len(points) == 4: + break + +cv2.destroyAllWindows() + +if len(points) == 4: + video_folder = os.path.dirname(video_path) + # get the video base name + video_base_name = os.path.splitext(os.path.basename(video_path))[0] + # use the base name without the extension as the out folder name + out_folder = os.path.join(video_folder, video_base_name) + os.makedirs(out_folder, exist_ok=True) + + cap.set(cv2.CAP_PROP_POS_FRAMES, 0) + frame_count = 0 + cropped_frame = None + written_frames = 0 + + print("Cropping and saving frames...") + + total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + progress_bar = tqdm(total=total_frames) + + while True: + ret, frame = cap.read() + if not ret: + break + + if frame_count % 75 == 0: + # calculate the new cropped frame using the homography + # the new shape should be the bounding box of the 4 points + pts = np.array(points, dtype=np.float32) + pts = pts.reshape(-1, 1, 2) + new_shape = cv2.boundingRect(pts) + # calculate the width by the ration in the 4 corner points + distance_pt1_pt2 = np.linalg.norm(np.array(points[0]) - np.array(points[1])) + distance_pt2_pt3 = np.linalg.norm(np.array(points[1]) - np.array(points[2])) + new_shape = ( + new_shape[0], + new_shape[1], + int(distance_pt1_pt2), + int(distance_pt2_pt3), + ) + # calculate the homography from the points to the new shape + h, _ = cv2.findHomography( + pts, + np.array( + [ + [0, 0], + [new_shape[2], 0], + [new_shape[2], new_shape[3]], + [0, new_shape[3]], + ], + dtype=np.float32, + ), + ) + # warp the frame + new_cropped_frame = cv2.warpPerspective( + frame, h, (new_shape[2], new_shape[3]) + ) + # otsu thresholding + new_cropped_frame = cv2.cvtColor(new_cropped_frame, cv2.COLOR_BGR2GRAY) + _, new_cropped_frame = cv2.threshold( + new_cropped_frame, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU + ) + # check if the frame is the first one + if cropped_frame is None: + cropped_frame = new_cropped_frame + else: + # check if the frame is the same as the previous one with a threshold + diff = cv2.absdiff(cropped_frame, new_cropped_frame) + # _, diff = cv2.threshold(diff, 100, 255, cv2.THRESH_BINARY) + if cv2.countNonZero(diff) > 400: + cropped_frame = new_cropped_frame + else: + frame_count += 1 + progress_bar.update(1) + continue + + frame_id = str(uuid.uuid4()) + cv2.imwrite(f"{out_folder}/frame_{frame_id}.png", cropped_frame) + written_frames += 1 + + # skip 25 frames + frame_count += 1 + progress_bar.update(1) + + cap.release() + progress_bar.close() + +print(f"{written_frames} frames cropped and saved successfully!") diff --git a/source_view.py b/source_view.py new file mode 100644 index 0000000..e4b246c --- /dev/null +++ b/source_view.py @@ -0,0 +1,521 @@ +from PyQt6.QtCore import QPointF, QRectF, Qt, QTimer +from PyQt6.QtGui import QBrush, QColor, QFont, QMouseEvent, QPen, QPolygonF +from PyQt6.QtWidgets import ( + QGraphicsItem, + QGraphicsPolygonItem, + QGraphicsRectItem, + QGraphicsSimpleTextItem, + QGraphicsView, +) + +from camera_view import CameraView +from storage import fetch_data, remove_data, store_data +from text_detection_target import TextDetectionTarget, TextDetectionTargetWithResult +from sc_logging import logger + + +class ResizableRect(QGraphicsRectItem): + selected_edge = None + + def __init__(self, x, y, width, height, onCenter=False): + if onCenter: + super().__init__(-width / 2, -height / 2, width, height) + else: + super().__init__(0, 0, width, height) + self.setPos(x, y) + self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsMovable) + self.setAcceptHoverEvents(True) + self.setPen(QPen(QBrush(Qt.GlobalColor.red), 3)) + + def getOriginalRect(self): + # get the original rect adjusted by the pen width + rect = self.rect() + border = 0 # self.pen().width() / 2 + return QRectF( + rect.x() + border, + rect.y() + border, + rect.width() - border * 2, + rect.height() - border * 2, + ) + + def getEdges(self, pos): + rect = self.rect() + border = self.pen().width() / 2 + + edge = None + if pos.x() < rect.x() + border: + edge = edge | Qt.Edge.LeftEdge if edge else Qt.Edge.LeftEdge + elif pos.x() > rect.right() - border: + edge = edge | Qt.Edge.RightEdge if edge else Qt.Edge.RightEdge + if pos.y() < rect.y() + border: + edge = edge | Qt.Edge.TopEdge if edge else Qt.Edge.TopEdge + elif pos.y() > rect.bottom() - border: + edge = edge | Qt.Edge.BottomEdge if edge else Qt.Edge.BottomEdge + + return edge + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.selected_edge = self.getEdges(event.pos()) + self.offset = QPointF() + else: + self.selected_edge = None + super().mousePressEvent(event) + + def mouseMoveEvent(self, event): + if self.selected_edge: + mouse_delta = event.pos() - event.buttonDownPos(Qt.MouseButton.LeftButton) + rect = self.rect() + pos_delta = QPointF() + border = self.pen().width() + + if self.selected_edge & Qt.Edge.LeftEdge: + # ensure that the width is *always* positive, otherwise limit + # both the delta position and width, based on the border size + diff = min(mouse_delta.x() - self.offset.x(), rect.width() - border) + if rect.x() < 0: + offset = diff / 2 + self.offset.setX(self.offset.x() + offset) + pos_delta.setX(offset) + rect.adjust(offset, 0, -offset, 0) + else: + pos_delta.setX(diff) + rect.setWidth(rect.width() - diff) + elif self.selected_edge & Qt.Edge.RightEdge: + if rect.x() < 0: + diff = max(mouse_delta.x() - self.offset.x(), border - rect.width()) + offset = diff / 2 + self.offset.setX(self.offset.x() + offset) + pos_delta.setX(offset) + rect.adjust(-offset, 0, offset, 0) + else: + rect.setWidth(max(border, event.pos().x() - rect.x())) + + if self.selected_edge & Qt.Edge.TopEdge: + # similarly to what done for LeftEdge, but for the height + diff = min(mouse_delta.y() - self.offset.y(), rect.height() - border) + if rect.y() < 0: + offset = diff / 2 + self.offset.setY(self.offset.y() + offset) + pos_delta.setY(offset) + rect.adjust(0, offset, 0, -offset) + else: + pos_delta.setY(diff) + rect.setHeight(rect.height() - diff) + elif self.selected_edge & Qt.Edge.BottomEdge: + if rect.y() < 0: + diff = max( + mouse_delta.y() - self.offset.y(), border - rect.height() + ) + offset = diff / 2 + self.offset.setY(self.offset.y() + offset) + pos_delta.setY(offset) + rect.adjust(0, -offset, 0, offset) + else: + rect.setHeight(max(border, event.pos().y() - rect.y())) + + if rect != self.rect(): + self.setRect(rect) + if pos_delta: + self.setPos(self.pos() + pos_delta) + else: + # use the default implementation for ItemIsMovable + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + self.selected_edge = None + super().mouseReleaseEvent(event) + + def hoverMoveEvent(self, event): + edges = self.getEdges(event.pos()) + if not edges: + # self.unsetCursor() + # show a moving cursor when the mouse is over the item + self.setCursor(Qt.CursorShape.OpenHandCursor) + elif edges in ( + Qt.Edge.TopEdge | Qt.Edge.LeftEdge, + Qt.Edge.BottomEdge | Qt.Edge.RightEdge, + ): + self.setCursor(Qt.CursorShape.SizeFDiagCursor) + elif edges in ( + Qt.Edge.BottomEdge | Qt.Edge.LeftEdge, + Qt.Edge.TopEdge | Qt.Edge.RightEdge, + ): + self.setCursor(Qt.CursorShape.SizeBDiagCursor) + elif edges in (Qt.Edge.LeftEdge, Qt.Edge.RightEdge): + self.setCursor(Qt.CursorShape.SizeHorCursor) + else: + self.setCursor(Qt.CursorShape.SizeVerCursor) + super().hoverMoveEvent(event) + + +class ResizableRectWithNameTypeAndResult(ResizableRect): + def __init__( + self, + x, + y, + width, + height, + name, + image_size, + result="", + onCenter=False, + boxChangedCallback=None, + itemSelectedCallback=None, + showOCRRects=True, + ): + super().__init__(x, y, width, height, onCenter) + self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton) + self.setAcceptHoverEvents(True) + self.name = name + self.result = result + self.boxChangedCallback = boxChangedCallback + self.itemSelectedCallback = itemSelectedCallback + self.posItem = QGraphicsSimpleTextItem("{}".format(self.name), parent=self) + self.posItem.setBrush(QBrush(QColor("red"))) + fontPos = QFont("Arial", int(image_size / 60) if image_size > 0 else 32) + fontPos.setWeight(QFont.Weight.Bold) + self.posItem.setFont(fontPos) + self.resultItem = QGraphicsSimpleTextItem("{}".format(self.result), parent=self) + self.resultItem.setBrush(QBrush(QColor("red"))) + fontRes = QFont("Arial", int(image_size / 75) if image_size > 0 else 20) + fontRes.setWeight(QFont.Weight.Bold) + self.resultItem.setFont(fontRes) + # add a semitraansparent background to the text using another rect + self.bgItem = QGraphicsRectItem(self.posItem.boundingRect(), parent=self) + self.bgItem.setBrush(QBrush(QColor(0, 0, 0, 128))) + self.bgItem.setPen(QPen(Qt.GlobalColor.transparent)) + xpos = ( + self.boundingRect().x() + - self.posItem.boundingRect().width() / 2 + + self.boundingRect().width() / 2 + ) + ypos = self.boundingRect().y() - self.posItem.boundingRect().height() + # set the text position to the top left corner of the rect + self.posItem.setPos(xpos, ypos) + self.bgItem.setPos(xpos, ypos) + # z order the text over the rect + self.posItem.setZValue(2) + self.bgItem.setZValue(1) + self.effectiveRect = None + self.extraBoxes = [] + self.showOCRRects = showOCRRects + + def getRect(self): + return self.getOriginalRect() + + def updateResult(self, targetWithResult: TextDetectionTargetWithResult): + self.result = targetWithResult.result + self.resultItem.setText(targetWithResult.result) + # set the result color based on the state + if ( + targetWithResult.result_state + == TextDetectionTargetWithResult.ResultState.Success + ): + self.resultItem.setBrush(QBrush(QColor("green"))) + elif ( + targetWithResult.result_state + == TextDetectionTargetWithResult.ResultState.FailedFilter + ): + self.resultItem.setBrush(QBrush(QColor("yellow"))) + elif ( + targetWithResult.result_state + == TextDetectionTargetWithResult.ResultState.Empty + ): + self.resultItem.setText("EMP") + self.resultItem.setBrush(QBrush(QColor("red"))) + else: + self.resultItem.setBrush(QBrush(QColor("white"))) + # set the result position to the lower left corner of the rect + self.resultItem.setPos( + self.boundingRect().x() + self.pen().width(), + self.boundingRect().y() + + self.boundingRect().height() + - self.resultItem.boundingRect().height(), + ) + self.resultItem.setZValue(2) + + if not self.showOCRRects: + # do not show the effective rect and extra boxes + if self.effectiveRect is not None: + self.effectiveRect.hide() + for extraBox in self.extraBoxes: + # remove from the scene + extraBox.hide() + self.scene().removeItem(extraBox) + self.extraBoxes.clear() + return + else: + if self.effectiveRect is not None: + self.effectiveRect.show() + + if targetWithResult.effectiveRect is not None: + # draw the effective rect in the scene + if self.effectiveRect is None: + self.effectiveRect = QGraphicsRectItem( + targetWithResult.effectiveRect, parent=self + ) + # ignore any mouse events on the effective rect + self.effectiveRect.setAcceptHoverEvents(False) + self.effectiveRect.setAcceptDrops(False) + self.effectiveRect.setAcceptedMouseButtons(Qt.MouseButton.NoButton) + self.effectiveRect.setBrush(QBrush(QColor(0, 0, 0, 0))) + self.effectiveRect.setPen(QPen(QColor("green"), 3)) + self.effectiveRect.setZValue(-1) + else: + self.effectiveRect.setRect(targetWithResult.effectiveRect) + else: + if self.effectiveRect is not None: + self.effectiveRect.hide() + if ( + targetWithResult.extras is not None + and "boxes" in targetWithResult.extras + and len(targetWithResult.extras["boxes"]) > 0 + ): + if len(self.extraBoxes) > 0: + for extraBox in self.extraBoxes: + # remove from the scene + extraBox.hide() + self.scene().removeItem(extraBox) + self.extraBoxes.clear() + for box in targetWithResult.extras["boxes"]: + if not ("x" in box and "y" in box and "w" in box and "h" in box): + continue + # draw the extra boxes in the scene + extraRect = QGraphicsRectItem( + QRectF(box["x"], box["y"], box["w"], box["h"]), parent=self + ) + # ignore any mouse events on the extra rect + extraRect.setAcceptHoverEvents(False) + extraRect.setAcceptDrops(False) + extraRect.setAcceptedMouseButtons(Qt.MouseButton.NoButton) + extraRect.setBrush(QBrush(QColor(0, 0, 0, 0))) + extraRect.setPen(QPen(QColor("blue"), 3)) + extraRect.setZValue(-2) + self.extraBoxes.append(extraRect) + + def mouseReleaseEvent(self, event): + super().mouseReleaseEvent(event) + origRect = self.getRect() + boxRect = QRectF( + origRect.x() + self.x(), + origRect.y() + self.y(), + origRect.width(), + origRect.height(), + ) + self.boxChangedCallback(self.name, boxRect) + + def mousePressEvent(self, event): + super().mousePressEvent(event) + self.itemSelectedCallback(self.name) + + def mouseMoveEvent(self, event): + return super().mouseMoveEvent(event) + + +class ImageViewer(CameraView): + def __init__( + self, + camera_index, + fourCornersAppliedCallback, + detectionTargetsStorage, + itemSelectedCallback, + ): + super().__init__(camera_index, detectionTargetsStorage) + self.setMouseTracking(True) + self.fourCornerSelectionMode = False + self.fourCorners = [] + self.fourCornerPolygon = None + self.fourCornersAppliedCallback = fourCornersAppliedCallback + self.itemSelectedCallback = itemSelectedCallback + self.first_frame_received_signal.connect(self.detectionTargetsChanged) + self.detectionTargetsStorage.data_changed.connect(self.detectionTargetsChanged) + self.timerThread.ocr_result_signal.connect(self.ocrResult) + self.viewport().setAttribute(Qt.WidgetAttribute.WA_AcceptTouchEvents, False) + if fetch_data("scoresight.json", "four_corners"): + self.setFourCorners(fetch_data("scoresight.json", "four_corners")) + self.fourCornersAppliedCallback(self.fourCorners) + self._isScaling = False + self.showOCRRects = True + + def resizeEvent(self, event): + if self._isScaling: + return + super().resizeEvent(event) + self.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) + self.detectionTargetsChanged() + + def toggleOCRRects(self, state): + self.showOCRRects = state + for item in self.scene.items(): + if isinstance(item, ResizableRectWithNameTypeAndResult): + item.showOCRRects = state + + def toggleStabilization(self, state): + if self.firstFrameReceived and self.timerThread: + self.timerThread.toggleStabilization(state) + + def toggleBinary(self): + if self.firstFrameReceived and self.timerThread: + self.timerThread.show_binary = not self.timerThread.show_binary + + def toggleFourCorner(self, state): + self.setFourCorners(None) # clear the current corners + remove_data("scoresight.json", "four_corners") + if state: + # new four corner selection + self.fourCornerSelectionMode = True + + def setFourCorners(self, corners): + self.fourCorners = [] + self.fourCornerPolygon = None + self.setFourCornersForHomography(corners) + + def detectionTargetsChanged(self): + if not self.firstFrameReceived: + return + + # clear the scene from all ResizableRectWithNameTypeAndResult + for item in self.scene.items(): + if isinstance(item, ResizableRectWithNameTypeAndResult): + self.scene.removeItem(item) + # get the detection targets from the storage + detectionTargets: list[TextDetectionTarget] = ( + self.detectionTargetsStorage.get_data() + ) + # add the boxes to the scene + for detectionTarget in detectionTargets: + self.scene.addItem( + ResizableRectWithNameTypeAndResult( + detectionTarget.x(), + detectionTarget.y(), + detectionTarget.width(), + detectionTarget.height(), + detectionTarget.name, + # image size + self.scene.sceneRect().width(), + onCenter=False, + boxChangedCallback=self.boxChanged, + itemSelectedCallback=self.itemSelectedCallback, + showOCRRects=self.showOCRRects, + ) + ) + + def boxChanged(self, name, rect): + # update the detection target in the storage + detectionTargets: list[TextDetectionTarget] = ( + self.detectionTargetsStorage.get_data() + ) + for detectionTarget in detectionTargets: + if detectionTarget.name == name: + detectionTarget.setX(rect.x()) + detectionTarget.setY(rect.y()) + detectionTarget.setWidth(rect.width()) + detectionTarget.setHeight(rect.height()) + self.detectionTargetsStorage.edit_item( + detectionTarget.name, detectionTarget + ) + break + + def findBox(self, name): + # find the box with the name + for item in self.scene.items(): + if isinstance(item, ResizableRectWithNameTypeAndResult): + if item.name == name: + return item + return None + + def removeBox(self, name): + # find the box with the name + item = self.findBox(name) + if item: + self.scene.removeItem(item) + + def mousePressEvent(self, event: QMouseEvent | None) -> None: + if self.fourCornerSelectionMode and event.button() == Qt.MouseButton.LeftButton: + # in four corner mode we want to add a point to the scene + # and connect the points in a polygon + # get the position of the click + # convert the position to the scene position + # create a new point + point = QGraphicsRectItem(-10, -10, 20, 20) + point.setPos(self.mapToScene(event.pos())) + point.setBrush(QBrush(QColor("red"))) + point.setPen(QPen(Qt.GlobalColor.transparent)) + self.scene.addItem(point) + # add the point to the list of points + self.fourCorners.append(point) + # if we have 4 points, create a polygon + if len(self.fourCorners) >= 2: + if not self.fourCornerPolygon: + self.fourCornerPolygon = QGraphicsPolygonItem() + self.fourCornerPolygon.setPen(QPen(QColor("red"), 3)) + self.scene.addItem(self.fourCornerPolygon) + self.fourCornerPolygon.setPolygon( + QPolygonF([corner.pos() for corner in self.fourCorners]) + ) + if len(self.fourCorners) == 4: + # calculate the homography from the corners to the rect + self.setFourCornersForHomography( + [(corner.x(), corner.y()) for corner in self.fourCorners] + ) + self.fourCornerSelectionMode = False + store_data( + "scoresight.json", + "four_corners", + [(corner.x(), corner.y()) for corner in self.fourCorners], + ) + # hide polygon and points + self.fourCornerPolygon.hide() + for corner in self.fourCorners: + corner.hide() + else: + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event: QMouseEvent | None) -> None: + super().mouseReleaseEvent(event) + self.detectionTargetsStorage.saveBoxesToStorage() + + def mouseMoveEvent(self, event: QMouseEvent | None) -> None: + super().mouseMoveEvent(event) + + def wheelEvent(self, event): + # check for ctrl key + if event.modifiers() == Qt.KeyboardModifier.ControlModifier: + self._isScaling = True + factor = 1.05 + if event.angleDelta().y() > 0: + # zoom in + if self.transform().m11() < 3.0: + self.scale(factor, factor) + else: + # zoom out + if self.transform().m11() > 0.33: + self.scale(1 / factor, 1 / factor) + # Use QTimer.singleShot to delay resetting the flag + QTimer.singleShot(0, self.resetScalingFlag) + else: + # scroll the scene + super().wheelEvent(event) + + def resetZoom(self): + self.resetTransform() + self.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) + + def resetScalingFlag(self): + self._isScaling = False # Reset the flag after potential resizeEvent + + def ocrResult(self, results: list[TextDetectionTargetWithResult]): + if not self.firstFrameReceived: + return + # update the rect with the result + for targetWithResult in results: + item = self.findBox(targetWithResult.name) + if item: + item.updateResult(targetWithResult) + else: + logger.debug(f"Could not find item with name {targetWithResult.name}") + + def closeEvent(self, event): + logger.debug("Close") + super().closeEvent(event) diff --git a/storage.py b/storage.py new file mode 100644 index 0000000..460ddc6 --- /dev/null +++ b/storage.py @@ -0,0 +1,263 @@ +import json +import os +from PyQt6.QtCore import QObject, pyqtSignal +from platformdirs import user_data_dir +from defaults import info_for_box_name, normalize_settings_dict + +from text_detection_target import TextDetectionTarget +from sc_logging import logger + + +def store_data(file_path, document_name, data): + # Store data into a JSON file + # get the user data directory + data_dir = user_data_dir("scoresight") + if not os.path.exists(data_dir): + os.makedirs(data_dir) + + # prepend the user data directory + file_path = os.path.join(data_dir, file_path) + + if os.path.exists(file_path): + with open(file_path, "r") as f: + try: + documents = json.load(f) + except json.JSONDecodeError: + documents = {} + else: + documents = {} + + documents[document_name] = data + + with open(file_path, "w") as f: + json.dump(documents, f, indent=2) + + +def remove_data(file_path, document_name): + # Remove data from a JSON file + # prepend the user data directory + file_path = os.path.join(user_data_dir("scoresight"), file_path) + + if not os.path.exists(file_path): + return + + with open(file_path, "r") as f: + documents = json.load(f) + + if document_name in documents: + del documents[document_name] + + with open(file_path, "w") as f: + json.dump(documents, f, indent=2) + + +def fetch_data(file_path, document_name, default=None): + # Fetch data from a JSON file + # prepend the user data directory + file_path = os.path.join(user_data_dir("scoresight"), file_path) + + if not os.path.exists(file_path): + return default + + with open(file_path, "r") as f: + try: + documents = json.load(f) + except json.JSONDecodeError: + return default + + if document_name in documents: + return documents[document_name] + else: + return default + + +def store_custom_box_name(custom_box_name: str): + # get the current custom box names + custom_boxes_names = fetch_data("scoresight.json", "custom_boxes_names", []) + if custom_box_name in custom_boxes_names: + return + custom_boxes_names.append(custom_box_name) + # Store the custom box name in the scoresight.json file + store_data("scoresight.json", "custom_boxes_names", custom_boxes_names) + + +def rename_custom_box_name_in_storage(old_name: str, new_name: str): + # get the current custom box names + custom_boxes_names = fetch_data("scoresight.json", "custom_boxes_names", []) + if old_name in custom_boxes_names: + custom_boxes_names.remove(old_name) + custom_boxes_names.append(new_name) + # Store the custom box name in the scoresight.json file + store_data("scoresight.json", "custom_boxes_names", custom_boxes_names) + + +def remove_custom_box_name_in_storage(custom_box_name: str): + # get the current custom box names + custom_boxes_names = fetch_data("scoresight.json", "custom_boxes_names", []) + if custom_box_name in custom_boxes_names: + custom_boxes_names.remove(custom_box_name) + # Store the custom box name in the scoresight.json file + store_data("scoresight.json", "custom_boxes_names", custom_boxes_names) + + +def fetch_custom_box_names(): + return fetch_data("scoresight.json", "custom_boxes_names", []) + + +class TextDetectionTargetMemoryStorage(QObject): + # This class is used to store the text detection targets in memory + + data_changed = pyqtSignal(list) + + def __init__(self): + super().__init__() + self._data: list[TextDetectionTarget] = [] + + def add_item(self, item: TextDetectionTarget): + self._data.append(item) + self.data_changed.emit(self._data) + + def remove_item(self, item_name: str): + for i, item in enumerate(self._data): + if item.name == item_name: + del self._data[i] + break + self.data_changed.emit(self._data) + + def clear(self): + self._data.clear() + self.data_changed.emit(self._data) + + def edit_item(self, item_name: str, new_item: TextDetectionTarget): + for i, item in enumerate(self._data): + if item.name == item_name: + self._data[i].setRect( + new_item.x(), new_item.y(), new_item.width(), new_item.height() + ) + self._data[i].settings = new_item.settings + self.data_changed.emit(self._data) + return + logger.warn("unable to find item to edit in storage: " + item_name) + + def rename_item(self, old_name: str, new_name: str): + for i, item in enumerate(self._data): + if item.name == old_name: + self._data[i].name = new_name + self.data_changed.emit(self._data) + return True + logger.warn("unable to find item to rename in storage: " + old_name) + return False + + def get_data(self): + return self._data + + def is_empty(self): + return len(self._data) == 0 + + def find_item_by_name(self, name: str): + for item in self._data: + if item.name == name: + return item + return None + + def loadBoxesFromStorage(self) -> bool: + # load the boxes from scoresight.json + boxes = fetch_data("scoresight.json", "boxes") + if not boxes: + return + return self.loadBoxesFromDict(boxes) + + def loadBoxesFromFile(self, file_path) -> bool: + # load the boxes from a file + with open(file_path, "r") as f: + boxes = json.load(f) + return self.loadBoxesFromDict(boxes) + + def loadBoxesFromDict(self, boxes) -> bool: + data_backup = self._data.copy() + self._data.clear() + try: + for box in boxes: + box_info = info_for_box_name(box["name"]) + if "settings" not in box: + box["settings"] = {} + # set the position of the box + self._data.append( + TextDetectionTarget( + box["rect"]["x"], + box["rect"]["y"], + box["rect"]["width"], + box["rect"]["height"], + box["name"], + normalize_settings_dict(box["settings"], box_info), + ) + ) + logger.debug("loaded boxes") + self.data_changed.emit(self._data) + except Exception as e: + logger.error("error loading boxes: " + str(e)) + self._data = data_backup + return False + return True + + def getBoxesForStorage(self): + # save all the boxes to scoresight.json + boxes = [] + for detectionTarget in self._data: + detectionTarget.settings = normalize_settings_dict( + detectionTarget.settings, info_for_box_name(detectionTarget.name) + ) + boxes.append( + { + "name": detectionTarget.name, + "rect": { + "x": detectionTarget.x(), + "y": detectionTarget.y(), + "width": detectionTarget.width(), + "height": detectionTarget.height(), + }, + "settings": { + "obs_source_name": detectionTarget.settings.get( + "obs_source_name" + ), + "format_regex": detectionTarget.settings.get("format_regex"), + "smoothing": detectionTarget.settings.get("smoothing"), + "skip_empty": detectionTarget.settings.get("skip_empty"), + "conf_thresh": detectionTarget.settings.get("conf_thresh"), + "cleanup_thresh": detectionTarget.settings.get( + "cleanup_thresh" + ), + "dilate": detectionTarget.settings.get("dilate"), + "skew": detectionTarget.settings.get("skew"), + "vscale": detectionTarget.settings.get("vscale"), + "autocrop": detectionTarget.settings.get("autocrop"), + "skip_similar_image": detectionTarget.settings.get( + "skip_similar_image" + ), + "remove_leading_zeros": detectionTarget.settings.get( + "remove_leading_zeros" + ), + "rescale_patch": detectionTarget.settings.get("rescale_patch"), + "normalize_wh_ratio": detectionTarget.settings.get( + "normalize_wh_ratio" + ), + "invert_patch": detectionTarget.settings.get("invert_patch"), + "ordinal_indicator": detectionTarget.settings.get( + "ordinal_indicator" + ), + "binarization_method": detectionTarget.settings.get( + "binarization_method" + ), + }, + } + ) + return boxes + + def saveBoxesToFile(self, file_path): + boxes = self.getBoxesForStorage() + with open(file_path, "w") as f: + json.dump(boxes, f, indent=2) + + def saveBoxesToStorage(self): + boxes = self.getBoxesForStorage() + store_data("scoresight.json", "boxes", boxes) diff --git a/tesseract.py b/tesseract.py new file mode 100644 index 0000000..c0a595d --- /dev/null +++ b/tesseract.py @@ -0,0 +1,552 @@ +from os import path +import cv2 +from tesserocr import PyTessBaseAPI, RIL, iterate_level +import numpy as np +from PIL import Image +from defaults import FieldType +from storage import fetch_data +from text_detection_target import TextDetectionTarget, TextDetectionTargetWithResult +import re +from PyQt6.QtCore import QRectF +from threading import Lock + + +def autocrop(image_in): + image = image_in.copy() + # check if this image is black-on-white or white-on-black by looking at the first few pixels + if np.sum(image[0:5, 0:5]) > 0: + # black-on-white + # invert the image + image = 255 - image + + # find the first row that has a pixel + first_row = 0 + for row in range(image.shape[0]): + if np.sum(image[row, :]) > 0: + first_row = row + break + # find the last row that has a pixel + last_row = image.shape[0] - 1 + for row in range(image.shape[0] - 1, -1, -1): + if np.sum(image[row, :]) > 0: + last_row = row + break + # find the first column that has a pixel + first_col = 0 + for col in range(image.shape[1]): + if np.sum(image[:, col]) > 0: + first_col = col + break + # find the last column that has a pixel + last_col = image.shape[1] - 1 + for col in range(image.shape[1] - 1, -1, -1): + if np.sum(image[:, col]) > 0: + last_col = col + break + # leave a 10 pixel border on each side + first_row = max(0, first_row - 10) + last_row = min(image.shape[0] - 1, last_row + 10) + first_col = max(0, first_col - 10) + last_col = min(image.shape[1] - 1, last_col + 10) + return image_in[first_row:last_row, first_col:last_col], ( + first_row, + last_row, + first_col, + last_col, + ) + + +def add_ordinal_indicator(text): + if text == "": + return "" + if text.endswith("1") and text != "11": + return text + "st" + elif text.endswith("2") and text != "12": + return text + "nd" + elif text.endswith("3") and text != "13": + return text + "rd" + else: + return text + "th" + + +def is_valid_regex(pattern): + try: + re.compile(pattern) + return True + except re.error: + return False + + +class TextDetectionResult: + def __init__(self, text, state, rect=None, extra=None): + self.text = text + self.state = state + self.rect = rect + self.extra = extra + + +class TextDetector: + # model name enum: daktronics=0, scoreboard_general=1 + class OcrModelIndex: + DAKTRONICS = 0 + SCOREBOARD_GENERAL = 1 + GENERAL_ENGLISH = 2 + SCOREBOARD_GENERAL_LARGE = 3 + + class BinarizationMethod: + GLOBAL = 0 + NO_BINARIZATION = 1 + LOCAL = 2 + ADAPTIVE = 3 + + def __init__(self): + self.api_lock = Lock() + self.api = None + if ( + fetch_data( + "scoresight.json", + "ocr_model", + TextDetector.OcrModelIndex.SCOREBOARD_GENERAL, + ) + == TextDetector.OcrModelIndex.SCOREBOARD_GENERAL + ): + self.setOcrModel(TextDetector.OcrModelIndex.SCOREBOARD_GENERAL) + else: + self.setOcrModel(TextDetector.OcrModelIndex.DAKTRONICS) + + def setOcrModel(self, ocrModelIndex): + ocr_model = None + if ocrModelIndex == self.OcrModelIndex.DAKTRONICS: + ocr_model = "daktronics" + if ocrModelIndex == self.OcrModelIndex.SCOREBOARD_GENERAL: + ocr_model = "scoreboard_general" + if ocrModelIndex == self.OcrModelIndex.GENERAL_ENGLISH: + ocr_model = "eng" + if ocrModelIndex == self.OcrModelIndex.SCOREBOARD_GENERAL_LARGE: + ocr_model = "scoreboard_general_large" + if ocr_model is None: + return + + with self.api_lock: + if self.api is not None: + self.api.End() + self.api = None + self.api = PyTessBaseAPI( + path=path.abspath( + path.join(path.dirname(__file__), "tesseract/tessdata") + ), + lang=ocr_model, + ) + # single word PSM + self.api.SetPageSegMode(8) + self.api.SetVariable("load_system_dawg", "F") + self.api.SetVariable("load_freq_dawg", "F") + + def detect_text(self, image): + if image is None: + return "" + if not isinstance(image, np.ndarray): + return "" + # check the image has rows and columns + if len(image.shape) < 2 or image.shape[0] < 1 or image.shape[1] < 1: + return "" + pilimage = Image.fromarray(image) + text = "" + with self.api_lock: + self.api.SetImage(pilimage) + text = self.api.GetUTF8Text() + return text.strip() + + def detect_multi_text( + self, binary, gray, rects: list[TextDetectionTarget], multi_crop=False + ) -> list[TextDetectionResult]: + if binary is None: + return [] + if not isinstance(binary, np.ndarray): + return [] + # check the image has rows and columns + if len(binary.shape) < 2 or binary.shape[0] < 1 or binary.shape[1] < 1: + return [] + + if not multi_crop: + pilimage = Image.fromarray(binary) + with self.api_lock: + self.api.SetImage(pilimage) + + texts = [] + for rect in rects: + effectiveRect = None + scale_x = 1.0 + scale_y = 1.0 + if multi_crop: + if ( + rect.x() < 0 + or rect.y() < 0 + or rect.width() < 1 + or rect.height() < 1 + ): + texts.append( + TextDetectionResult( + "", TextDetectionTargetWithResult.ResultState.Empty, None + ) + ) + continue + + if rect.x() + rect.width() > binary.shape[1]: + rect.setWidth(binary.shape[1] - rect.x()) + if rect.y() + rect.height() > binary.shape[0]: + rect.setHeight(binary.shape[0] - rect.y()) + + if ( + rect.settings is not None + and "binarization_method" in rect.settings + and rect.settings["binarization_method"] + != TextDetector.BinarizationMethod.GLOBAL + ): + if ( + rect.settings["binarization_method"] + == TextDetector.BinarizationMethod.NO_BINARIZATION + ): + # no binarization + imagecrop = gray[ + int(rect.y()) : int(rect.y() + rect.height()), + int(rect.x()) : int(rect.x() + rect.width()), + ] + elif ( + rect.settings["binarization_method"] + == TextDetector.BinarizationMethod.LOCAL + ): + # local binarization using Otsu's method + _, imagecrop = cv2.threshold( + gray[ + int(rect.y()) : int(rect.y() + rect.height()), + int(rect.x()) : int(rect.x() + rect.width()), + ], + 0, + 255, + cv2.THRESH_BINARY + cv2.THRESH_OTSU, + ) + elif ( + rect.settings["binarization_method"] + == TextDetector.BinarizationMethod.ADAPTIVE + ): + # apply adaptive binarization + imagecrop = cv2.adaptiveThreshold( + gray[ + int(rect.y()) : int(rect.y() + rect.height()), + int(rect.x()) : int(rect.x() + rect.width()), + ], + 255, + cv2.ADAPTIVE_THRESH_GAUSSIAN_C, + cv2.THRESH_BINARY, + # use a fraction of the patch area + max(int(rect.width() * rect.height() * 0.01), 3) | 1, + 2, + ) + # update the binary image for visualisation in the binary mode + binary[ + int(rect.y()) : int(rect.y() + rect.height()), + int(rect.x()) : int(rect.x() + rect.width()), + ] = imagecrop + else: + imagecrop = binary[ + int(rect.y()) : int(rect.y() + rect.height()), + int(rect.x()) : int(rect.x() + rect.width()), + ] + + if ( + rect.settings is not None + and "cleanup_thresh" in rect.settings + and rect.settings["cleanup_thresh"] > 0 + ): + # cleanup image from small components: find contours and remove small ones + contours, _ = cv2.findContours( + imagecrop, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE + ) + # cleanup_thresh is [0, 1.0], convert to [0, 0.05] + cleanup_thresh = rect.settings["cleanup_thresh"] * 0.05 + img_area_thresh = ( + imagecrop.shape[0] * imagecrop.shape[1] * cleanup_thresh + ) + for contour in contours: + if cv2.contourArea(contour) < img_area_thresh: + cv2.drawContours(imagecrop, [contour], 0, 0, -1) + + if ( + rect.settings is not None + and "vscale" in rect.settings + and rect.settings["vscale"] != 10 + ): + # vertical scale the image + # the vscale input is in the range [1, 10] where 10 is the default (1:1) + # scale the image in the y direction about the center + rows, cols = imagecrop.shape + # calculate the target height + target_height = int(rows * (rect.settings["vscale"] / 10.0)) + scaled = cv2.resize( + imagecrop, (cols, target_height), 0, 0, cv2.INTER_AREA + ) + # add padding to the top and bottom + pad_top = (rows - target_height) // 2 + pad_bottom = rows - target_height - pad_top + scaled = cv2.copyMakeBorder( + scaled, pad_top, pad_bottom, 0, 0, cv2.BORDER_REPLICATE + ) + # make sure the image is the same size as the original + scaled = scaled[:rows, :] + # copy back into imagecrop and binary display + binary[ + int(rect.y()) : int(rect.y() + rect.height()), + int(rect.x()) : int(rect.x() + rect.width()), + ] = scaled + imagecrop = scaled + + if ( + rect.settings is not None + and "skew" in rect.settings + and rect.settings["skew"] != 0 + ): + # skew the image in the x direction about the center + rows, cols = imagecrop.shape + # identity 2x2 matrix + M = np.float32([[1, 0, 0], [0, 1, 0]]) + # add skew factor to matrix + M[0, 1] = rect.settings["skew"] / 40.0 + try: + skewed = cv2.warpAffine(imagecrop, M, (cols, rows)) + binary[ + int(rect.y()) : int(rect.y() + rect.height()), + int(rect.x()) : int(rect.x() + rect.width()), + ] = skewed + imagecrop = skewed + except: + pass + + if ( + rect.settings is not None + and "dilate" in rect.settings + and rect.settings["dilate"] > 0 + and imagecrop.shape[0] > 0 + and imagecrop.shape[1] > 0 + ): + # dilate the image + kernel = np.ones((3, 3), np.uint8) + dilated = cv2.dilate( + imagecrop.copy(), + kernel, + iterations=int(rect.settings["dilate"]), + ) + # copy back into image crop + binary[ + int(rect.y()) : int(rect.y() + rect.height()), + int(rect.x()) : int(rect.x() + rect.width()), + ] = dilated + + if ( + rect.settings is not None + and "invert_patch" in rect.settings + and rect.settings["invert_patch"] + ): + # invert the image + imagecrop = 255 - imagecrop + + if ( + rect.settings is not None + and "skip_similar_image" in rect.settings + and rect.settings["skip_similar_image"] + ): + # compare the image with the last image + if ( + rect.last_image is not None + and rect.last_image.shape == imagecrop.shape + ): + # check if the difference is less than 5% + diff = cv2.absdiff(rect.last_image, imagecrop) + diff = diff.astype(np.float32) + diff = diff / 255.0 + diff = diff.sum() / (imagecrop.shape[0] * imagecrop.shape[1]) + if diff < 0.05: + # skip this image + texts.append( + TextDetectionResult( + "SIM", + TextDetectionTargetWithResult.ResultState.FailedFilter, + effectiveRect, + ) + ) + continue + rect.last_image = imagecrop.copy() + + if ( + rect.settings is not None + and "autocrop" in rect.settings + and rect.settings["autocrop"] + ): + # auto crop the binary image around the text + imagecrop, (first_row, last_row, first_col, last_col) = autocrop( + imagecrop + ) + effectiveRect = QRectF( + first_col, + first_row, + last_col - first_col, + last_row - first_row, + ) + + # check if image is size 0 + if imagecrop.shape[0] == 0 or imagecrop.shape[1] == 0: + texts.append( + TextDetectionResult( + "", + TextDetectionTargetWithResult.ResultState.Empty, + effectiveRect, + ) + ) + continue + + if ( + rect.settings is not None + and "rescale_patch" in rect.settings + and rect.settings["rescale_patch"] + ): + # rescale the image to 35 pixels height + scale_x = 35 / imagecrop.shape[0] + scale_y = scale_x + + if ( + rect.settings is not None + and "normalize_wh_ratio" in rect.settings + and rect.settings["normalize_wh_ratio"] + and "median_wh_ratio" in rect.settings + and rect.settings["median_wh_ratio"] > 0 + ): + # rescale the image in x or in y such that the width-to-height ratio is 0.5 + scale_x *= 0.5 / rect.settings["median_wh_ratio"] + + if scale_x != 1.0 or scale_y != 1.0: + imagecrop = cv2.resize( + imagecrop, + None, + fx=scale_x, + fy=scale_y, + interpolation=cv2.INTER_AREA, + ) + + try: + pilimage = Image.fromarray(imagecrop) + with self.api_lock: + self.api.SetImage(pilimage) + except: + texts.append( + TextDetectionResult( + "", TextDetectionTargetWithResult.ResultState.Empty, None + ) + ) + continue + + if rect.settings["type"] == FieldType.NUMBER: + with self.api_lock: + self.api.SetVariable("tessedit_char_whitelist", "0123456789") + elif rect.settings["type"] == FieldType.TIME: + with self.api_lock: + self.api.SetVariable("tessedit_char_whitelist", "0123456789:.") + else: # general + with self.api_lock: + self.api.SetVariable( + "tessedit_char_whitelist", + "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ .,;:!?-_()[]{}<>@#$%^&*+=|\\~`\"'", + ) + + if not multi_crop: + with self.api_lock: + self.api.SetRectangle( + rect.x(), rect.y(), rect.width(), rect.height() + ) + + text = "" + extras = {} + with self.api_lock: + text = self.api.GetUTF8Text().strip() + if text != "": + # get the per-character boxes using an iterator with RIL_SYMBOL level + it = self.api.GetIterator() + extras["boxes"] = [] + wh_ratios = [] + for w in iterate_level(it, RIL.SYMBOL): + char = w.GetUTF8Text(RIL.SYMBOL) + box_tuple = w.BoundingBox(RIL.SYMBOL) + if ( + box_tuple is None + or char is None + or char == "" + or len(box_tuple) != 4 + ): + continue + box = { + "x": box_tuple[0], + "y": box_tuple[1], + "w": box_tuple[2] - box_tuple[0], + "h": box_tuple[3] - box_tuple[1], + } + # box is a dict with x, y, w and h + if scale_x != 1.0 or scale_y != 1.0: + box["x"] = int(box["x"] / scale_x) + box["y"] = int(box["y"] / scale_y) + box["w"] = int(box["w"] / scale_x) + box["h"] = int(box["h"] / scale_y) + if effectiveRect is not None: + box["x"] = int(box["x"] + effectiveRect.x()) + box["y"] = int(box["y"] + effectiveRect.y()) + extras["boxes"].append(box) + # if char is a "wide character" (like 0,2,3,4,5,6,7,8,9), add the width-to-height ratio + if char in "023456789": + wh_ratios.append(box["w"] / box["h"]) + if ( + "normalize_wh_ratio" in rect.settings + and rect.settings["normalize_wh_ratio"] + and "median_wh_ratio" not in rect.settings + and len(wh_ratios) > 0 + ): + rect.settings["median_wh_ratio"] = np.median(wh_ratios) + + textstate = TextDetectionTargetWithResult.ResultState.Success + if rect.settings is not None: + if "format_regex" in rect.settings: + # validate the regex format is valid + if is_valid_regex(rect.settings["format_regex"]): + # check the text matches the regex fully + if not re.fullmatch(rect.settings["format_regex"], text): + textstate = ( + TextDetectionTargetWithResult.ResultState.FailedFilter + ) + if "conf_thresh" in rect.settings: + with self.api_lock: + meanConf = self.api.MeanTextConf() + if meanConf < rect.settings["conf_thresh"]: + textstate = ( + TextDetectionTargetWithResult.ResultState.FailedFilter + ) + if "smoothing" in rect.settings: + if rect.settings["smoothing"]: + # apply smoother + text = rect.ocrResultPerCharacterSmoother.get_smoothed_result( + text + ) + if text is None: + text = "" + if "remove_leading_zeros" in rect.settings: + if rect.settings["remove_leading_zeros"]: + # remove leading zeros + text = text.lstrip("0") + if text == "": + text = "0" + if "ordinal_indicator" in rect.settings: + if rect.settings["ordinal_indicator"]: + # add ordinal indicator + text = add_ordinal_indicator(text) + + if text == "": + textstate = TextDetectionTargetWithResult.ResultState.Empty + + texts.append(TextDetectionResult(text, textstate, effectiveRect, extras)) + return texts diff --git a/tesseract/tessdata/daktronics.traineddata b/tesseract/tessdata/daktronics.traineddata new file mode 100644 index 0000000..6e21d7f Binary files /dev/null and b/tesseract/tessdata/daktronics.traineddata differ diff --git a/tesseract/tessdata/eng.traineddata b/tesseract/tessdata/eng.traineddata new file mode 100644 index 0000000..f4744c2 Binary files /dev/null and b/tesseract/tessdata/eng.traineddata differ diff --git a/tesseract/tessdata/scoreboard_general.traineddata b/tesseract/tessdata/scoreboard_general.traineddata new file mode 100644 index 0000000..f8088c1 Binary files /dev/null and b/tesseract/tessdata/scoreboard_general.traineddata differ diff --git a/tesseract/tessdata/scoreboard_general_large.traineddata b/tesseract/tessdata/scoreboard_general_large.traineddata new file mode 100644 index 0000000..c9d5f45 Binary files /dev/null and b/tesseract/tessdata/scoreboard_general_large.traineddata differ diff --git a/text_detection_target.py b/text_detection_target.py new file mode 100644 index 0000000..e15dd8c --- /dev/null +++ b/text_detection_target.py @@ -0,0 +1,88 @@ +import enum +from PyQt6.QtCore import QRectF + + +class OCRResultPerCharacterSmoother: + # This class is used to smooth the OCR results per character. + # It holds a list of the last n OCR results (string), split to characters. + # it returns the most common character in the list as the smoothed result per each + # position in the string. + + def __init__(self, max_history=5): + self.max_history = max_history + self.history = [] + + def get_smoothed_result(self, result: str) -> str: + if len(self.history) >= self.max_history: + self.history.pop(0) + self.history.append(result) + + # split the results to characters + characters = [] + for result in self.history: + characters.append(list(result)) + # find the most common character in each position + smoothed_result = "" + for i in range(len(characters[0])): + # get the i'th character from each result + chars = [] + for result in characters: + if len(result) > i: + chars.append(result[i]) + # find the most common character + if len(chars) > 0: + smoothed_result += max(set(chars), key=chars.count) + return smoothed_result + + def clear(self): + self.history.clear() + + +class TextDetectionTarget(QRectF): + def __init__(self, x, y, width, height, name, settings: dict | None = None): + super().__init__(x, y, width, height) + self.name = name + self.settings = settings + self.ocrResultPerCharacterSmoother = OCRResultPerCharacterSmoother() + self.last_image = None + + +class TextDetectionTargetWithResult(TextDetectionTarget): + class ResultState(enum.Enum): + Success = 0 + FailedFilter = 1 + Empty = 2 + + def __init__( + self, + detection_target: TextDetectionTarget, + result, + result_state, + effectiveRect=None, + extras=None, + ): + super().__init__( + detection_target.x(), + detection_target.y(), + detection_target.width(), + detection_target.height(), + detection_target.name, + detection_target.settings, + ) + self.result = result + self.result_state = result_state + self.effectiveRect = effectiveRect + self.extras = extras + + def to_dict(self): + return { + "name": self.name, + "text": self.result, + "state": self.result_state.name, + "rect": { + "x": self.x(), + "y": self.y(), + "width": self.width(), + "height": self.height(), + }, + } diff --git a/update_available.ui b/update_available.ui new file mode 100644 index 0000000..e54cc06 --- /dev/null +++ b/update_available.ui @@ -0,0 +1,144 @@ + + + Dialog + + + + 0 + 0 + 424 + 418 + + + + + 0 + 0 + + + + Dialog + + + + -1 + + + + + Qt::Horizontal + + + QDialogButtonBox::Ok + + + + + + + QFrame::NoFrame + + + <html><head/><body><p>New version of ScoreSight is available!</p><p>Download:</p><p><a href="https://download.scoresight.live/scoresight-windows-latest.zip"><span style=" text-decoration: underline; color:#0000ff;">https://download.scoresight.live/scoresight-windows-latest.zip</span></a></p><p><a href="https://download.scoresight.live/scoresight-macos-x86-latest.dmg"><span style=" text-decoration: underline; color:#0000ff;">https://download.scoresight.live/scoresight-macos-x86-latest.dmg</span></a></p><p><a href="https://download.scoresight.live/scoresight-linux-latest.tar"><span style=" text-decoration: underline; color:#0000ff;">https://download.scoresight.live/scoresight-linux-latest.tar</span></a></p><p>Your configuration will transfer to the new version.</p></body></html> + + + Qt::MarkdownText + + + 0 + + + -1 + + + true + + + + + + + + 0 + 0 + + + + QFrame::NoFrame + + + <html><head/><body><p>You are already running the latest version.</p><p>However, you can always download the latest version from:</p><p><a href="https://download.scoresight.live/scoresight-windows-latest.zip"><span style=" text-decoration: underline; color:#0000ff;">https://download.scoresight.live/scoresight-windows-latest.zip</span></a></p><p><a href="https://download.scoresight.live/scoresight-macos-x86-latest.dmg"><span style=" text-decoration: underline; color:#0000ff;">https://download.scoresight.live/scoresight-macos-x86-latest.dmg</span></a></p><p><a href="https://download.scoresight.live/scoresight-linux-latest.tar"><span style=" text-decoration: underline; color:#0000ff;">https://download.scoresight.live/scoresight-linux-latest.tar</span></a></p></body></html> + + + Qt::RichText + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + 0 + + + -1 + + + + + + + Disable update checks + + + + + + + <html><head/><body><p><span style=" color:#ff0000;">Error: Cannot check for updates. Please contact out support team.</span></p></body></html> + + + + + + + Qt::Horizontal + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/update_check.py b/update_check.py new file mode 100644 index 0000000..abd70c0 --- /dev/null +++ b/update_check.py @@ -0,0 +1,132 @@ +import requests +import datetime +import os +from dotenv import load_dotenv +from io import StringIO +from sc_logging import logger +from storage import fetch_data, store_data +from os import path +from PyQt6.QtWidgets import QDialog +from PyQt6.uic import loadUi + + +def fetch_release_info(update_check_url): + logger.info("Checking for updates...") + # Fetch the release info file from the cloud + try: + response = requests.get(update_check_url, timeout=5) + if response.status_code == 200: + return response.text + else: + return None + except requests.exceptions.RequestException as e: + logger.error(f"Failed to fetch release info file from the cloud: {e}") + return None + + +def get_latest_release_version(release_info: str): + # Parse the release info file and extract the latest release version + # the file is a dotenv so use load_dotenv to parse it + release_info_stream = StringIO(release_info) + load_dotenv(stream=release_info_stream) + latest_release_version = os.getenv("LATEST_RELEASE_TAG") + latest_release_date = os.getenv("LATEST_RELEASE_DATE") + return latest_release_version, latest_release_date + + +def compare_release_dates(current_release_date, latest_release_date): + # Compare the release dates + # they were formatted with unix `date -u +"%Y-%m-%dT%H:%M:%SZ"` + # so we need to convert them to datetime objects + current_date = datetime.datetime.strptime( + current_release_date, "%Y-%m-%dT%H:%M:%SZ" + ) + latest_date = datetime.datetime.strptime(latest_release_date, "%Y-%m-%dT%H:%M:%SZ") + if current_date < latest_date: + return "Newer" + elif current_date > latest_date: + return "Older" + else: + return "Same" + + +def check_for_updates(override_settings: bool) -> bool: + return False + + # if not override_settings: + # # check in storage if update checks are enabled + # check_for_updates_disabled = fetch_data( + # "scoresight.json", "disable_update_checks" + # ) + # if check_for_updates_disabled is not None and check_for_updates_disabled: + # logger.info("Update checks are disabled.") + # return False + + # # Read the current release version from the .env file + # load_dotenv(os.path.abspath(os.path.join(os.path.dirname(__file__), ".env"))) + # current_release_version = os.getenv("LOCAL_RELEASE_TAG") + # current_release_date = os.getenv("LOCAL_RELEASE_DATE") + + # if not current_release_version or not current_release_date: + # logger.warn("Failed to read the current release version from the .env file.") + # if override_settings: + # check_for_updates_dialog(False, True) + # return False + + # # Fetch the release info file from the cloud + # release_info = fetch_release_info(os.getenv("UPDATE_CHECK_URL")) + + # if release_info: + # # Get the latest release version and release date + # latest_release_version, latest_release_date = get_latest_release_version( + # release_info + # ) + + # # Compare the release dates + # comparison_result = compare_release_dates( + # current_release_date, latest_release_date + # ) + + # logger.info(f"Current release version: {current_release_version}") + # logger.info(f"Latest release version: {latest_release_version}") + # logger.info(f"Comparison result: {comparison_result}") + + # if comparison_result == "Newer": + # check_for_updates_dialog(True, False) + # return True + # else: + # if override_settings: + # check_for_updates_dialog(False, True) + # logger.warn("Failed to fetch release info file from the cloud.") + # return False + + # if override_settings: + # check_for_updates_dialog(False, False) + + # return False + + +def check_for_updates_dialog(new_version_available: bool, error: bool = False): + # popup a qdialog with the update info + update_dialog = QDialog() + loadUi( + path.abspath(path.join(path.dirname(__file__), "update_available.ui")), + update_dialog, + ) + update_dialog.setWindowTitle("ScoreSight Update Available") + update_dialog.checkBox_disableUpdateChecks.toggled.connect( + lambda value: store_data("scoresight.json", "disable_update_checks", value) + ) + # update the checkbox state + disable_checks = fetch_data("scoresight.json", "disable_update_checks") + update_dialog.checkBox_disableUpdateChecks.setChecked( + disable_checks if disable_checks is not None else False + ) + update_dialog.label_newVersion.setVisible(new_version_available and not error) + update_dialog.label_noNewVersion.setVisible(not new_version_available and not error) + update_dialog.label_error.setVisible(error) + + # adjust the height to match the height of the content + update_dialog.setFixedSize(update_dialog.width(), update_dialog.height()) + + update_dialog.exec() diff --git a/url_source.ui b/url_source.ui new file mode 100644 index 0000000..975dead --- /dev/null +++ b/url_source.ui @@ -0,0 +1,77 @@ + + + Dialog + + + + 0 + 0 + 400 + 73 + + + + + 0 + 0 + + + + Dialog + + + + + + URL + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/vmix_output.py b/vmix_output.py new file mode 100644 index 0000000..fecb4d7 --- /dev/null +++ b/vmix_output.py @@ -0,0 +1,52 @@ +import requests + +from text_detection_target import TextDetectionTargetWithResult +from sc_logging import logger + + +class VMixAPI: + def __init__(self, host, port, input_number, field_mapping): + self.host = host + self.port = port + self.input_number = input_number + self.field_mapping = field_mapping + self.running = False + + def set_field_mapping(self, field_mapping): + self.field_mapping = field_mapping + + def update_vmix(self, detection: list[TextDetectionTargetWithResult]): + if not self.running: + return + + if not self.field_mapping: + logger.debug("Field mapping is not set") + return + + # Prepare the data to send + data = {} + for target in detection: + if target.name in self.field_mapping: + data[self.field_mapping[target.name]] = target.result + + if not data: + logger.debug("No data to send") + return + + for key, value in data.items(): + # Prepare the URL + url = ( + f"http://{self.host}:{self.port}/api/?Input={self.input_number}&" + + f"Function=SetText&SelectedName={key}&Value={value}" + ) + try: + # Send the request + response = requests.post(url, data=data) + + # Check the response + if response.status_code != 200: + logger.error( + f"Failed to send data, status code: {response.status_code}" + ) + except requests.exceptions.RequestException as e: + logger.error(f"Failed to send data to {url}: {e}") diff --git a/win32DeviceEnum/enum_devices_dshow.py b/win32DeviceEnum/enum_devices_dshow.py new file mode 100644 index 0000000..ded86b7 --- /dev/null +++ b/win32DeviceEnum/enum_devices_dshow.py @@ -0,0 +1,21 @@ +import sys +from os import path + + +def enumerate_video_devices_dshow() -> list[tuple[int, str]]: + sys.path.append(path.abspath(path.dirname(__file__))) + import win32DeviceEnumBind + + # Initialize COM + win32DeviceEnumBind.InitializeCOM() + + # Call the enumeration function + deviceArray = win32DeviceEnumBind.EnumerateVideoDevicesDShow() + + # Convert the array to a list of tuples + devices = list(enumerate(deviceArray)) + + # Uninitialize COM + win32DeviceEnumBind.UninitializeCOM() + + return devices diff --git a/win32DeviceEnum/setup.py b/win32DeviceEnum/setup.py new file mode 100644 index 0000000..ce30b10 --- /dev/null +++ b/win32DeviceEnum/setup.py @@ -0,0 +1,17 @@ +from setuptools import setup, Extension +import pybind11 + +setup( + name="win32DeviceEnumBind", + version="0.0.1", + ext_modules=[ + Extension( + "win32DeviceEnumBind", + ["video_devices_enumerator_ds.cpp"], + include_dirs=[pybind11.get_include()], + language="c++", + libraries=["strmiids", "ole32", "oleaut32", "uuid", "quartz"], + ), + ], + setup_requires=["pybind11"], +) diff --git a/win32DeviceEnum/video_devices_enumerator_ds.cpp b/win32DeviceEnum/video_devices_enumerator_ds.cpp new file mode 100644 index 0000000..443f2c0 --- /dev/null +++ b/win32DeviceEnum/video_devices_enumerator_ds.cpp @@ -0,0 +1,70 @@ +#include +#include +#include +#include +#include + +namespace py = pybind11; + +HRESULT InitializeCOM() { + return CoInitialize(nullptr); +} + +void UninitializeCOM() { + CoUninitialize(); +} + +int EnumerateVideoDevicesDShow(std::vector& deviceNames) { + ICreateDevEnum *pDevEnum = nullptr; + IEnumMoniker *pEnum = nullptr; + deviceNames.clear(); + + HRESULT hr = CoCreateInstance(CLSID_SystemDeviceEnum, nullptr, CLSCTX_INPROC, IID_ICreateDevEnum, + reinterpret_cast(&pDevEnum)); + + if (FAILED(hr)) { + return -1; + } + + hr = pDevEnum->CreateClassEnumerator(CLSID_VideoInputDeviceCategory, &pEnum, 0); + if (!pEnum) { + return -1; + } + + IMoniker *pMoniker = nullptr; + ULONG i_fetched; + while (pEnum->Next(1, &pMoniker, &i_fetched) == S_OK) { + IPropertyBag *pPropBag; + hr = pMoniker->BindToStorage(0, 0, IID_PPV_ARGS(&pPropBag)); + if (FAILED(hr)) { + pMoniker->Release(); + continue; + } + VARIANT var; + VariantInit(&var); + + // Get the friendly name of the device + if (SUCCEEDED(pPropBag->Read(L"FriendlyName", &var, 0))) { + std::wstring deviceName = var.bstrVal; + VariantClear(&var); + deviceNames.push_back(deviceName); + } + + pPropBag->Release(); + pMoniker->Release(); + } + pEnum->Release(); + pDevEnum->Release(); + + return (int)deviceNames.size(); +} + +PYBIND11_MODULE(win32DeviceEnumBind, m) { + m.def("InitializeCOM", &InitializeCOM, "A function that initializes COM"); + m.def("UninitializeCOM", &UninitializeCOM, "A function that uninitializes COM"); + m.def("EnumerateVideoDevicesDShow", []() { + std::vector deviceNames; + EnumerateVideoDevicesDShow(deviceNames); + return deviceNames; + }, "A function that enumerates video devices"); +}