diff --git a/.flake8 b/.flake8 index e6aff2b6..1abd4c9f 100644 --- a/.flake8 +++ b/.flake8 @@ -3,13 +3,15 @@ color=always max-line-length=120 ; Auto generated exclude=src/gen/ -; TODO: We want to configure this -ignore=W503,N801,N802,N803,N806,N815,N816 -per-file-ignores = - ; imported but unused - ; line too long - ; mixed case - __init__.pyi:F401,E501,N816 -; TODO: Bring WAY down +; Linebreak before binary operator +; Allow default value other than "..." +; Allow imports at the bottom of file +ignore=W503,Y015,E402 +per-file-ignores= + ; Quotes + __init__.pyi:Q000 +; PyQt methods +ignore-names=closeEvent,paintEvent,keyPressEvent,mousePressEvent,mouseMoveEvent,mouseReleaseEvent +; TODO: Bring down to 15, same as SonarLint max-complexity=55 -inline-quotes = " +inline-quotes=" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 00000000..f74d7190 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,67 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ main, master, develop, dev, 2.0.0] + pull_request: + # The branches below must be a subset of the branches above + branches: [ develop, dev, 2.0.0 ] + schedule: + - cron: '26 13 * * 6' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + # Learn more: + # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/build-and-lint.yml b/.github/workflows/lint-and-build.yml similarity index 88% rename from .github/workflows/build-and-lint.yml rename to .github/workflows/lint-and-build.yml index ae10b564..77eced8b 100644 --- a/.github/workflows/build-and-lint.yml +++ b/.github/workflows/lint-and-build.yml @@ -1,17 +1,26 @@ # https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions -name: Build and lint +name: Lint and build on: push: branches: - main - master - 2.0.0 + paths: + - '**.py' + - '**.pyi' + - '**.ui' pull_request: branches: - main - master - dev - dev* + - 2.0.0 + paths: + - '**.py' + - '**.pyi' + - '**.ui' jobs: Pyright: runs-on: windows-latest @@ -30,6 +39,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip + pip install wheel pip install -r "scripts/requirements.txt" npm install -g pyright - run: scripts/compile_resources.bat @@ -50,11 +60,12 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip + pip install wheel pip install -r "scripts/requirements.txt" - run: scripts/compile_resources.bat - name: Analysing the code with ${{ job.name }} run: pylint --reports=y --output-format=colorized $(git ls-files '**/*.py*') - Bandit: + Flake8: runs-on: windows-latest strategy: matrix: @@ -69,11 +80,12 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip + pip install wheel pip install -r "scripts/requirements.txt" - run: scripts/compile_resources.bat - name: Analysing the code with ${{ job.name }} - run: bandit -n 1 --severity-level medium --recursive src - Flake8: + run: flake8 + Bandit: runs-on: windows-latest strategy: matrix: @@ -88,10 +100,11 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip + pip install wheel pip install -r "scripts/requirements.txt" - run: scripts/compile_resources.bat - name: Analysing the code with ${{ job.name }} - run: flake8 + run: bandit -n 1 --severity-level medium --recursive src Build: runs-on: windows-latest strategy: @@ -107,5 +120,11 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip + pip install wheel pip install -r "scripts/requirements.txt" - run: scripts/build.bat + - name: Upload Build Artifact + uses: actions/upload-artifact@v2 + with: + name: AutoSplit (Python ${{ matrix.python-version }}) + path: dist/AutoSplit.exe diff --git a/.markdownlint.json b/.markdownlint.json index efb4ef2f..9ecf9f17 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1,5 +1,6 @@ { "default": true, - "MD025": false, - "MD013": false + "MD001": false, + "MD013": false, + "MD025": false } diff --git a/.vscode/extensions.json b/.vscode/extensions.json index d4464eda..49ec5de6 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,4 @@ +// Keep in alphabetical order { "recommendations": [ "bungcip.better-toml", @@ -5,11 +6,22 @@ "eamodio.gitlens", "ms-python.python", "ms-python.vscode-pylance", + "pkief.material-icon-theme", "shardulm94.trailing-spaces", "sonarsource.sonarlint-vscode" ], "unwantedRecommendations": [ + // VSCode has implemented an optimized version + "coenraads.bracket-pair-colorizer", + "coenraads.bracket-pair-colorizer-2", + // Lots of conflicts + "esbenp.prettier-vscode", + // Replaced by ESLint + "eg2.tslint", + "ms-vscode.vscode-typescript-tslint-plugin", + // Obsoleted by Pylance "ms-pyright.pyright", - "esbenp.prettier-vscode" + // The ESLint plugin is sufficient in JS-only projects + "sonarsource.sonarlint-vscode", ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 5336311a..6252fc0e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,36 +10,11 @@ }, "editor.detectIndentation": false, "editor.tabSize": 2, - "[python]": { - "editor.tabSize": 4, - "editor.rulers": [ - 72, // PEP8-17 docstrings - // 79, // PEP8-17 default max - // 88, // Black default - 99, // PEP8-17 acceptable max - 120, // Our hard rule - ] - }, "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll": true, "source.fixAll.markdownlint": true, }, - "python.linting.enabled": true, - "python.linting.pylintEnabled": true, - "python.linting.pylintCategorySeverity.convention": "Warning", - "python.linting.pylintCategorySeverity.refactor": "Warning", - "python.linting.flake8Enabled": true, - "python.linting.flake8CategorySeverity.E": "Warning", - // PyRight obsoletes mypy - "python.linting.mypyEnabled": false, - // Is already wrapped by Flake8, prospector and pylama - "python.linting.pycodestyleEnabled": false, - // Just another wrapper, use Flake8 OR this - "python.linting.prospectorEnabled": false, - // Just another wrapper, use Flake8 OR this - "python.linting.pylamaEnabled": false, - "python.linting.banditEnabled": true, "files.insertFinalNewline": true, "trailing-spaces.includeEmptyLines": true, "trailing-spaces.trimOnSave": true, @@ -67,4 +42,41 @@ "**/*.code-search": true, "typings": true, }, + "[python]": { + "editor.tabSize": 4, + "editor.rulers": [ + 72, // PEP8-17 docstrings + // 79, // PEP8-17 default max + // 88, // Black default + 99, // PEP8-17 acceptable max + 120, // Our hard rule + ] + }, + "python.linting.enabled": true, + "python.linting.pylintEnabled": true, + "python.linting.pylintCategorySeverity.convention": "Warning", + "python.linting.pylintCategorySeverity.refactor": "Warning", + "python.linting.flake8Enabled": true, + "python.linting.flake8CategorySeverity.E": "Warning", + // PyRight obsoletes mypy + "python.linting.mypyEnabled": false, + // Is already wrapped by Flake8, prospector and pylama + "python.linting.pycodestyleEnabled": false, + // Just another wrapper, use Flake8 OR this + "python.linting.prospectorEnabled": false, + // Just another wrapper, use Flake8 OR this + "python.linting.pylamaEnabled": false, + "python.linting.banditEnabled": true, + // Copy those over to your user settings + "sonarlint.rules": { + "python:S1192": { + "level": "off" + }, + "python:S3776": { + "level": "off" + }, + "python:S107": { + "level": "off" + }, + }, } diff --git a/PyInstaller/hooks/hook-cv2.py b/PyInstaller/hooks/hook-cv2.py new file mode 100644 index 00000000..172c078c --- /dev/null +++ b/PyInstaller/hooks/hook-cv2.py @@ -0,0 +1,23 @@ +# ------------------------------------------------------------------ +# Copyright (c) 2020 PyInstaller Development Team. +# +# This file is distributed under the terms of the GNU General Public +# License (version 2.0 or later). +# +# The full license is available in LICENSE.GPL.txt, distributed with +# this software. +# +# SPDX-License-Identifier: GPL-2.0-or-later +# ------------------------------------------------------------------ +# https://github.com/pyinstaller/pyinstaller-hooks-contrib/blob/master/src/_pyinstaller_hooks_contrib/hooks/stdhooks/hook-cv2.py + +from PyInstaller.utils.hooks import collect_dynamic_libs, collect_data_files + +hiddenimports = ["numpy"] + +# Include any DLLs from site-packages/cv2 (opencv_videoio_ffmpeg*.dll can be found there in the PyPI version) +binaries = collect_dynamic_libs("cv2") + +# https://github.com/pyinstaller/pyinstaller-hooks-contrib/issues/110 +# OpenCV loader from 4.5.4.60 requires extra config files and modules +datas = collect_data_files("cv2", include_py_files=True, includes=["**/*.py"]) diff --git a/PyInstaller/hooks/hook-requests.py b/PyInstaller/hooks/hook-requests.py new file mode 100644 index 00000000..13de4b6b --- /dev/null +++ b/PyInstaller/hooks/hook-requests.py @@ -0,0 +1,4 @@ +from PyInstaller.utils.hooks import collect_data_files + +# Get the cacert.pem +datas = collect_data_files("certifi") diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..ee4fd8d7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | :-------: | +| main | :white_check_mark: | +| dev* | :white_check_mark: | +| everything else | WIP branches, security support may be lacking | + +## Reporting a Vulnerability + +This is a small project, maintained by a few volunteers of the community. Just open up an issue, be clear, explain why it's a vulnerability as well as your recommendations to fix it. Please provide sources if possible. diff --git a/pyproject.toml b/pyproject.toml index 9b35cb8f..116b94b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,9 +36,9 @@ reportUnknownMemberType = "none" # https://pylint.pycqa.org/en/latest/technical_reference/features.html [tool.pylint.REPORTS] # Just like default but any error will make drop to 9 or less -evaluation="10.0 - error - ((float(warning + refactor + convention) / statement) * 10)" +evaluation = "10.0 - error - ((float(warning + refactor + convention) / statement) * 10)" [tool.pylint.MASTER] -fail-under=9.0 +fail-under = 9.0 # https://pylint.pycqa.org/en/latest/technical_reference/extensions.html load-plugins = [ "pylint.extensions.emptystring", @@ -71,18 +71,31 @@ ignore-paths = [ "^src/gen/.*$", ] # No need to mention the fixmes -disable=["fixme"] +disable = ["fixme"] extension-pkg-allow-list = ["PyQt6", "win32ui"] [tool.pylint.FORMAT] max-line-length = 120 +[tool.pylint.DESIGN] +max-attributes = 15 + [tool.pylint.'MESSAGES CONTROL'] +# Same as SonarLint +max-complexity = 15 +# At least same as max-complexity +max-branches = 15 +# https://pylint.pycqa.org/en/latest/user_guide/options.html#naming-styles +module-naming-style = "any" +# Can't make private class with PascalCase +class-rgx = "_?_?[a-zA-Z]+?$" +good-names = [ + # PyQt methods + "closeEvent", "paintEvent", "keyPressEvent", "mousePressEvent", "mouseMoveEvent", "mouseReleaseEvent", + # https://github.com/PyCQA/pylint/issues/2018 + "x", "y", "a0", "i", "t0", "t1"] disable = [ "missing-docstring", - # TODO: We want to configure this - # https://pylint.pycqa.org/en/latest/user_guide/options.html#naming-styles - "invalid-name", # We group imports "wrong-import-position", # Already taken care of and grayed out. Also conflicts with Pylance reportIncompatibleMethodOverride diff --git a/res/about.ui b/res/about.ui index d963f5e2..4cb2cc3d 100644 --- a/res/about.ui +++ b/res/about.ui @@ -1,7 +1,7 @@ - aboutAutoSplitWidget - + AboutAutoSplitWidget + 0 @@ -34,7 +34,7 @@ :/resources/icon.ico:/resources/icon.ico - + 180 @@ -47,7 +47,7 @@ OK - + 10 @@ -60,7 +60,7 @@ <html><head/><body><p>Created by <a href="https://twitter.com/toufool"><span style=" text-decoration: underline; color:#0000ff;">Toufool</span></a> and <a href="https://twitter.com/faschz"><span style=" text-decoration: underline; color:#0000ff;">Faschz</span></a><br/>Maintained by <a href="https://twitter.com/Avasam06"><span style=" text-decoration: underline; color:#0000ff;">Avasam</span></a></p></body></html> - + 10 @@ -73,7 +73,7 @@ Version: - + 30 @@ -90,7 +90,7 @@ consider donating. Thank you! Qt::AlignCenter - + 60 @@ -109,7 +109,7 @@ consider donating. Thank you! Qt::AlignCenter - + 190 @@ -131,9 +131,9 @@ consider donating. Thank you! - okButton + ok_button clicked() - aboutAutoSplitWidget + AboutAutoSplitWidget close() diff --git a/res/design.ui b/res/design.ui index af4e7559..3b0caf42 100644 --- a/res/design.ui +++ b/res/design.ui @@ -46,8 +46,8 @@ Qt::LeftToRight - - + + 20 @@ -60,7 +60,7 @@ Split Image Folder: - + 130 @@ -73,7 +73,7 @@ true - + 540 @@ -89,7 +89,7 @@ Browse... - + 25 @@ -102,7 +102,7 @@ X - + true @@ -124,7 +124,7 @@ false - + 5 @@ -140,7 +140,7 @@ Select Region - + 7 @@ -153,7 +153,7 @@ Default similarity threshold: - + 155 @@ -172,7 +172,7 @@ 0.900000000000000 - + 500 @@ -188,7 +188,10 @@ Start Auto Splitter - + + + false + 500 @@ -204,7 +207,10 @@ Reset - + + + false + 494 @@ -220,7 +226,10 @@ Undo Split - + + + false + 560 @@ -236,7 +245,7 @@ Skip Split - + 7 @@ -249,23 +258,23 @@ Default pause time (sec): - + 5 225 - 53 - 21 + 54 + 23 Qt::NoFocus - Max FPS + Avg. FPS - + 87 @@ -278,7 +287,7 @@ FPS - + true @@ -300,7 +309,7 @@ false - + true @@ -322,7 +331,7 @@ false - + 171 @@ -335,7 +344,7 @@ - + 171 @@ -348,7 +357,7 @@ - + 230 @@ -361,7 +370,7 @@ Start / Split - + 230 @@ -374,7 +383,7 @@ Reset - + 230 @@ -387,7 +396,7 @@ Skip Split - + 230 @@ -400,7 +409,7 @@ Undo Split - + 300 @@ -413,7 +422,7 @@ true - + 300 @@ -429,7 +438,7 @@ true - + 300 @@ -442,7 +451,7 @@ true - + 300 @@ -455,7 +464,7 @@ true - + 390 @@ -471,7 +480,7 @@ Set Hotkey - + 390 @@ -487,7 +496,7 @@ Set Hotkey - + 390 @@ -503,7 +512,7 @@ Set Hotkey - + 390 @@ -519,7 +528,7 @@ Set Hotkey - + 220 @@ -538,7 +547,7 @@ Qt::Vertical - + 230 @@ -554,7 +563,7 @@ Qt::AlignCenter - + 490 @@ -573,7 +582,7 @@ Qt::Vertical - + 120 @@ -592,7 +601,7 @@ Qt::AlignCenter - + 380 @@ -611,7 +620,7 @@ Qt::AlignCenter - + 450 @@ -624,7 +633,7 @@ Current Split Image - + 12 @@ -637,7 +646,7 @@ Width - + 66 @@ -650,10 +659,10 @@ Height - + - 58 + 60 225 26 20 @@ -663,7 +672,7 @@ - + 6 @@ -682,7 +691,7 @@ 640 - + 62 @@ -701,7 +710,7 @@ 480 - + 200 @@ -714,7 +723,7 @@ Capture Region - + 8 @@ -727,7 +736,7 @@ FPS Limit: - + 62 @@ -739,23 +748,23 @@ - - 0 - - 30.000000000000000 + 30 - 5000.000000000000000 + 5000 - 1.000000000000000 + 1 - 60.000000000000000 + 60 + + + 0 - + 380 @@ -771,7 +780,7 @@ Qt::AlignCenter - + 260 @@ -787,7 +796,7 @@ Take Screenshot - + 6 @@ -815,7 +824,7 @@ 0 - + 62 @@ -840,7 +849,7 @@ 0 - + 81 @@ -853,7 +862,7 @@ Y - + 125 @@ -878,7 +887,7 @@ - + 155 @@ -897,7 +906,7 @@ 10.000000000000000 - + 7 @@ -910,7 +919,7 @@ Comparison Method - + 5 @@ -926,7 +935,7 @@ Align Region - + 230 @@ -942,7 +951,7 @@ false - + 5 @@ -958,7 +967,7 @@ Select Window - + 379 @@ -971,7 +980,7 @@ Image Loop: - + 230 @@ -984,7 +993,7 @@ Pause - + 300 @@ -1000,7 +1009,7 @@ true - + 390 @@ -1016,7 +1025,7 @@ Set Hotkey - + true @@ -1038,7 +1047,7 @@ false - + 500 @@ -1057,7 +1066,7 @@ false - + 500 @@ -1073,7 +1082,7 @@ Reload Start Image - + 440 @@ -1086,7 +1095,7 @@ Start image: - + 7 @@ -1099,7 +1108,7 @@ Current similarity threshold: - + 171 @@ -1112,7 +1121,7 @@ - + 120 @@ -1128,7 +1137,7 @@ Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter - + true @@ -1144,76 +1153,76 @@ Force Full Content Rendering (slower) - splitimagefolderLabel - splitimagefolderLineEdit - browseButton - xLabel - liveimageCheckBox - selectregionButton - similaritythresholdLabel - similaritythresholdDoubleSpinBox - startautosplitterButton - resetButton - undosplitButton - skipsplitButton - pauseLabel - checkfpsButton - fpsLabel - showlivesimilarityCheckBox - showhighestsimilarityCheckBox - livesimilarityLabel - highestsimilarityLabel - splitLabel - resetLabel - skiptsplitLabel - undosplitLabel - splitLineEdit - undosplitLineEdit - skipsplitLineEdit - resetLineEdit - setsplithotkeyButton - setresethotkeyButton - setskipsplithotkeyButton - setundosplithotkeyButton - line_left - timerglobalhotkeysLabel - line_right - currentsplitimageLabel - liveImage - currentSplitImage - widthLabel - heightLabel - fpsvalueLabel - widthSpinBox - heightSpinBox - captureregionLabel - fpslimitLabel - fpslimitSpinBox - currentsplitimagefileLabel - takescreenshotButton - xSpinBox - ySpinBox - yLabel - comparisonmethodComboBox - pauseDoubleSpinBox - comparisonmethodLabel - alignregionButton - groupDummySplitsCheckBox - selectwindowButton - imageloopLabel - pausehotkeyLabel - pausehotkeyLineEdit - setpausehotkeyButton - loopCheckBox - autostartonresetCheckBox - startImageReloadButton - startImageLabel - currentsimilaritythresholdLabel - currentsimilaritythresholdnumberLabel - captureregionwindowLabel - forcePrintWindowCheckBox + split_image_folder_label + split_image_folder_input + browse_button + x_label + live_image_checkbox + select_region_button + similarity_threshold_label + similarity_threshold_spinbox + start_auto_splitter_button + reset_button + undo_split_button + skip_split_button + pause_label + check_fps_button + fps_label + show_live_similarity_checkbox + show_highest_similarity_checkbox + live_similarity_label + highest_similarity_label + split_label + reset_label + skip_split_label + undo_split_label + split_input + undo_split_input + skip_split_input + reset_input + set_split_hotkey_button + set_reset_hotkey_button + set_skip_split_hotkey_button + set_undo_split_hotkey_button + left_line + timer_global_hotkeys_label + right_line + current_split_image_label + live_image + current_split_image + width_label + height_label + fps_value_label + width_spinbox + height_spinbox + capture_region_label + fps_limit_label + fps_limit_spinbox + current_split_image_file_label + take_screenshot_button + x_spinbox + y_spinbox + y_label + comparison_method_combobox + pause_spinbox + comparison_method_label + align_region_button + group_dummy_splits_checkbox + select_window_button + image_loop_label + pause_hotkey_label + pause_hotkey_input + set_pause_hotkey_button + loop_checkbox + auto_start_on_reset_checkbox + start_image_reload_button + start_image_label + current_similarity_threshold_label + current_similarity_threshold_number_label + capture_region_window_label + force_print_window_checkbox - + 0 @@ -1222,62 +1231,57 @@ 22 - + Help - - - - + + + + - + File - - - + + + - - + + - + View Help - + About - - - Split Image Settings - - - + Save Settings - + Load Settings - + Save Settings As... - + Check for Updates... - + true @@ -1293,23 +1297,23 @@ - splitimagefolderLineEdit - xSpinBox - ySpinBox - widthSpinBox - heightSpinBox - fpslimitSpinBox - liveimageCheckBox - comparisonmethodComboBox - showlivesimilarityCheckBox - showhighestsimilarityCheckBox - similaritythresholdDoubleSpinBox - pauseDoubleSpinBox - splitLineEdit - resetLineEdit - skipsplitLineEdit - undosplitLineEdit - groupDummySplitsCheckBox + split_image_folder_input + x_spinbox + y_spinbox + width_spinbox + height_spinbox + fps_limit_spinbox + live_image_checkbox + comparison_method_combobox + show_live_similarity_checkbox + show_highest_similarity_checkbox + similarity_threshold_spinbox + pause_spinbox + split_input + reset_input + skip_split_input + undo_split_input + group_dummy_splits_checkbox diff --git a/res/update_checker.ui b/res/update_checker.ui index a2f767ae..53567fc1 100644 --- a/res/update_checker.ui +++ b/res/update_checker.ui @@ -46,7 +46,7 @@ :/resources/icon.ico:/resources/icon.ico - + 20 @@ -65,7 +65,7 @@ There is an update available for AutoSplit. - + 20 @@ -78,7 +78,7 @@ Current Version: - + 20 @@ -91,7 +91,7 @@ Latest Version: - + 20 @@ -104,7 +104,7 @@ Open download page? - + 150 @@ -120,7 +120,7 @@ Open - + 230 @@ -133,7 +133,7 @@ Later - + 120 @@ -146,7 +146,7 @@ - + 120 @@ -159,7 +159,7 @@ - + 20 @@ -178,7 +178,7 @@ - pushButtonRight + right_button clicked() UpdateChecker close() diff --git a/scripts/build.bat b/scripts/build.bat index f8a5f6b8..bfc6d7ee 100644 --- a/scripts/build.bat +++ b/scripts/build.bat @@ -1,2 +1,2 @@ CALL "%~p0compile_resources.bat" -pyinstaller -w -F --icon=res\icon.ico "%~p0..\src\AutoSplit.py" +pyinstaller --windowed --onefile --additional-hooks-dir=Pyinstaller\hooks --icon=res\icon.ico "%~p0..\src\AutoSplit.py" diff --git a/scripts/compile_resources.bat b/scripts/compile_resources.bat index a6c092aa..3c1398ff 100644 --- a/scripts/compile_resources.bat +++ b/scripts/compile_resources.bat @@ -1,4 +1,5 @@ cd "%~dp0.." +md .\src\gen pyuic6 ".\res\about.ui" -o ".\src\gen\about.py" pyuic6 ".\res\design.ui" -o ".\src\gen\design.py" pyuic6 ".\res\update_checker.ui" -o ".\src\gen\update_checker.py" diff --git a/scripts/lint.ps1 b/scripts/lint.ps1 index 97a72d41..6f66ff32 100644 --- a/scripts/lint.ps1 +++ b/scripts/lint.ps1 @@ -4,8 +4,8 @@ pyright echo "`nRunning Pylint..." pylint --score=n --output-format=colorized $(git ls-files '**/*.py*') -echo "`nRunning Bandit..." -bandit -f custom --silent --recursive src - echo "`nRunning Flake8..." flake8 + +echo "`nRunning Bandit..." +bandit -f custom --silent --recursive src diff --git a/scripts/requirements.txt b/scripts/requirements.txt index 30cf1dab..30eb0766 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -10,24 +10,27 @@ # Creating AutoSplit.exe with PyInstaller: .\scripts\build.bat # # Dependencies: +ImageHash +keyboard numpy>=1.22.0rc1 -opencv-python<=4.5.3.56 # https://github.com/pyinstaller/pyinstaller-hooks-contrib/issues/110 +opencv-python +packaging +Pillow PyQt6 PyQt6-tools PySide6 -Pillow -ImageHash -pywin32 -keyboard -packaging pyautogui +pywin32 requests # Linting and Types -pywin32-stubs -types-requests +bandit flake8 +flake8-quotes pylint -bandit +pywin32-stubs +simplejson +types-simplejson>=3.17.2 +types-requests # # Comment this out if you don't want to build AutoSplit.exe: PyInstaller diff --git a/src/AutoControlledWorker.py b/src/AutoControlledWorker.py index 7ef33a0c..3894a5d8 100644 --- a/src/AutoControlledWorker.py +++ b/src/AutoControlledWorker.py @@ -18,26 +18,25 @@ def run(self): try: line = input() except RuntimeError: - self.autosplit.showErrorSignal.emit(error_messages.stdinLostError) + self.autosplit.show_error_signal.emit(error_messages.stdin_lost) break # TODO: "AutoSplit Integration" needs to call this and wait instead of outright killing the app. - # TODO: See if we can also get LiveSplit to wait on Exit in "AutoSplit Integration" # For now this can only used in a Development environment if line == "kill": self.autosplit.closeEvent() break if line == "start": - self.autosplit.startAutoSplitter() + self.autosplit.start_auto_splitter() elif line in {"split", "skip"}: - self.autosplit.startSkipSplit() + self.autosplit.skip_split_signal.emit() elif line == "undo": - self.autosplit.startUndoSplit() + self.autosplit.undo_split_signal.emit() elif line == "reset": - self.autosplit.startReset() + self.autosplit.reset_signal.emit() elif line.startswith("settings"): # Allow for any split character between "settings" and the path self.autosplit.load_settings_file_path = line[9:] - settings.loadSettings(self.autosplit, load_settings_from_livesplit=True) + settings.load_settings(self.autosplit, load_settings_from_livesplit=True) # TODO: Not yet implemented in AutoSplit Integration # elif line == 'pause': - # self.startPause() + # self.pause_signal.emit() diff --git a/src/AutoSplit.py b/src/AutoSplit.py index ff8f476e..0381ae6b 100644 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -9,47 +9,54 @@ from __future__ import annotations from collections.abc import Callable from types import FunctionType, TracebackType -from typing import Literal, Optional, Union, cast +from typing import Optional, Union import sys import os import ctypes import signal import traceback -from copy import copy from time import time +import certifi import cv2 -import numpy as np from PyQt6 import QtCore, QtGui, QtTest from PyQt6.QtWidgets import QApplication, QFileDialog, QMainWindow, QMessageBox, QWidget from win32 import win32gui -from win32con import MAXBYTE +from AutoSplitImage import COMPARISON_RESIZE, AutoSplitImage, ImageType import error_messages import settings_file as settings -import split_parser from AutoControlledWorker import AutoControlledWorker from capture_windows import capture_region, Rect -from compare import checkIfImageHasTransparency, compareImage from gen import about, design, update_checker -from hotkeys import send_command, afterSettingHotkey, setSplitHotkey, setResetHotkey, setSkipSplitHotkey, \ - setUndoSplitHotkey, setPauseHotkey -from menu_bar import open_about, VERSION, viewHelp, checkForUpdates, open_update_checker -from screen_region import selectRegion, selectWindow, alignRegion, validateBeforeComparison -from split_parser import BELOW_FLAG, DUMMY_FLAG, PAUSE_FLAG - - -# Resize to these width and height so that FPS performance increases -COMPARISON_RESIZE_WIDTH = 320 -COMPARISON_RESIZE_HEIGHT = 240 -COMPARISON_RESIZE = (COMPARISON_RESIZE_WIDTH, COMPARISON_RESIZE_HEIGHT) -DISPLAY_RESIZE_WIDTH = 240 -DISPLAY_RESIZE_HEIGHT = 180 -DISPLAY_RESIZE = (DISPLAY_RESIZE_WIDTH, DISPLAY_RESIZE_HEIGHT) -CREATE_NEW_ISSUE_MESSAGE = \ - "Please create a New Issue at " -"github.com/Toufool/Auto-Split/issues, describe what happened, and copy & paste the error message below" +from hotkeys import send_command, after_setting_hotkey, set_split_hotkey, set_reset_hotkey, set_skip_split_hotkey, \ + set_undo_split_hotkey, set_pause_hotkey +from menu_bar import open_about, VERSION, view_help, check_for_updates, open_update_checker +from screen_region import select_region, select_window, align_region, validate_before_parsing +from settings_file import FROZEN +from split_parser import BELOW_FLAG, DUMMY_FLAG, PAUSE_FLAG, parse_and_validate_images + +CREATE_NEW_ISSUE_MESSAGE = "Please create a New Issue at " \ + "github.com/Toufool/Auto-Split/issues, describe what happened, and copy & paste the error message below" +START_IMAGE_TEXT = "Start Image" +START_AUTO_SPLITTER_TEXT = "Start Auto Splitter" +CHECK_FPS_ITERATIONS = 10 + +# Needed when compiled, along with the custom hook-requests PyInstaller hook +os.environ["REQUESTS_CA_BUNDLE"] = certifi.where() + + +def make_excepthook(main_window: AutoSplit): + def excepthook(exception_type: type[BaseException], exception: BaseException, _traceback: Optional[TracebackType]): + # Catch Keyboard Interrupts for a clean close + if exception_type is KeyboardInterrupt or isinstance(exception, KeyboardInterrupt): + sys.exit(0) + main_window.show_error_signal.emit(lambda: error_messages.exception_traceback( + "AutoSplit encountered an unhandled exception and will try to recover, " + f"however, there is no guarantee everything will work properly. {CREATE_NEW_ISSUE_MESSAGE}", + exception)) + return excepthook class AutoSplit(QMainWindow, design.Ui_MainWindow): @@ -60,46 +67,25 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): is_auto_controlled = "--auto-controlled" in sys.argv # Signals - updateCurrentSplitImage = QtCore.pyqtSignal(QtGui.QImage) - startAutoSplitterSignal = QtCore.pyqtSignal() - resetSignal = QtCore.pyqtSignal() - skipSplitSignal = QtCore.pyqtSignal() - undoSplitSignal = QtCore.pyqtSignal() - pauseSignal = QtCore.pyqtSignal() - afterSettingHotkeySignal = QtCore.pyqtSignal() - updateCheckerWidgetSignal = QtCore.pyqtSignal(str, bool) + start_auto_splitter_signal = QtCore.pyqtSignal() + reset_signal = QtCore.pyqtSignal() + skip_split_signal = QtCore.pyqtSignal() + undo_split_signal = QtCore.pyqtSignal() + pause_signal = QtCore.pyqtSignal() + after_setting_hotkey_signal = QtCore.pyqtSignal() + update_checker_widget_signal = QtCore.pyqtSignal(str, bool) # Use this signal when trying to show an error from outside the main thread - showErrorSignal = QtCore.pyqtSignal(FunctionType) + show_error_signal = QtCore.pyqtSignal(FunctionType) # Timers - timerLiveImage = QtCore.QTimer() - timerStartImage = QtCore.QTimer() + timer_live_image = QtCore.QTimer() + timer_start_image = QtCore.QTimer() - # Windows - AboutWidget: about.Ui_aboutAutoSplitWidget + # Widgets + AboutWidget: about.Ui_AboutAutoSplitWidget UpdateCheckerWidget: update_checker.Ui_UpdateChecker CheckForUpdatesThread: QtCore.QThread - # Settings - split_image_directory = "" - similarity_threshold: float - comparison_index: int - pause: float - fps_limit: int - split_key = "" - reset_key = "" - skip_split_key = "" - undo_split_key = "" - pause_key = "" - hwnd_title = "" - group_dummy_splits_undo_skip_setting: Literal[0, 1] - loop_setting: Literal[0, 1] - auto_start_on_reset_setting: Literal[0, 1] - - # Default Settings for the region capture - hwnd = 0 - selection = Rect() - # hotkeys need to be initialized to be passed as thread arguments in hotkeys.py # and for type safety in both hotkeys.py and settings_file.py split_hotkey: Optional[Callable[[], None]] = None @@ -109,78 +95,71 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow): pause_hotkey: Optional[Callable[[], None]] = None # Initialize a few attributes - last_saved_settings: Optional[list[Union[str, float, int]]] = None + split_image_directory = "" + hwnd = 0 + selection = Rect() + last_saved_settings: list[Union[str, float, int, bool]] = [] save_settings_file_path = "" load_settings_file_path = "" live_image_function_on_open = True - split_image_loop_amount: list[int] = [] split_image_number = 0 - loop_number = 1 + split_images_and_loop_number: list[tuple[AutoSplitImage, int]] = [] + split_groups: list[list[int]] = [] # Last loaded settings and last successful loaded settings file path to None until we try to load them - last_loaded_settings: Optional[list[Union[str, float, int]]] = None + last_loaded_settings: list[Union[str, float, int]] = [] last_successfully_loaded_settings_file_path: Optional[str] = None # Automatic timer start - timerStartImage_is_running = False - start_image = None highest_similarity = 0.0 check_start_image_timestamp = 0.0 # Define all other attributes setting_check_for_updates_on_open: QtCore.QSettings - imageHasTransparency: bool start_image_split_below_threshold: bool waiting_for_split_delay: bool split_below_threshold: bool - split_image_path: str - split_image_filenames: list[str] - split_image_filenames_including_loops: list[str] - split_image_filenames_and_loop_number: list[tuple[str, int, int]] - split_groups: list[list[int]] run_start_time: float similarity: float - reset_image_threshold: float - reset_image_pause_time: float split_delay: float - flags: int - reset_image: Optional[cv2.ndarray] - reset_mask: Optional[cv2.ndarray] - split_image: cv2.ndarray - image_mask: Optional[cv2.ndarray] + start_image: Optional[AutoSplitImage] = None + reset_image: Optional[AutoSplitImage] = None + split_images: list[AutoSplitImage] = [] + split_image: AutoSplitImage def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent) + + # Setup global error handling + self.show_error_signal.connect(lambda errorMessageBox: errorMessageBox()) + # Whithin LiveSplit excepthook needs to use main_window's signals to show errors + sys.excepthook = make_excepthook(self) + self.setupUi(self) - settings.loadPyQtSettings(self) + settings.load_pyqt_settings(self) # close all processes when closing window - self.actionView_Help.triggered.connect(viewHelp) - self.actionAbout.triggered.connect(lambda: open_about(self)) - self.actionCheck_for_Updates.triggered.connect(lambda: checkForUpdates(self)) - self.actionSave_Settings.triggered.connect(lambda: settings.saveSettings(self)) - self.actionSave_Settings_As.triggered.connect(lambda: settings.saveSettingsAs(self)) - self.actionLoad_Settings.triggered.connect(lambda: settings.loadSettings(self)) - - # disable buttons upon open - self.undosplitButton.setEnabled(False) - self.skipsplitButton.setEnabled(False) - self.resetButton.setEnabled(False) + self.action_view_help.triggered.connect(view_help) + self.action_about.triggered.connect(lambda: open_about(self)) + self.action_check_for_updates.triggered.connect(lambda: check_for_updates(self)) + self.action_save_settings.triggered.connect(lambda: settings.save_settings(self)) + self.action_save_settings_as.triggered.connect(lambda: settings.save_settings_as(self)) + self.action_load_settings.triggered.connect(lambda: settings.load_settings(self)) if self.is_auto_controlled: - self.setsplithotkeyButton.setEnabled(False) - self.setresethotkeyButton.setEnabled(False) - self.setskipsplithotkeyButton.setEnabled(False) - self.setundosplithotkeyButton.setEnabled(False) - self.setpausehotkeyButton.setEnabled(False) - self.startautosplitterButton.setEnabled(False) - self.splitLineEdit.setEnabled(False) - self.resetLineEdit.setEnabled(False) - self.skipsplitLineEdit.setEnabled(False) - self.undosplitLineEdit.setEnabled(False) - self.pausehotkeyLineEdit.setEnabled(False) - self.timerglobalhotkeysLabel.setText("Hotkeys Inactive - Use LiveSplit Hotkeys") + self.set_split_hotkey_button.setEnabled(False) + self.set_reset_hotkey_button.setEnabled(False) + self.set_skip_split_hotkey_button.setEnabled(False) + self.set_undo_split_hotkey_button.setEnabled(False) + self.set_pause_hotkey_button.setEnabled(False) + self.start_auto_splitter_button.setEnabled(False) + self.split_input.setEnabled(False) + self.reset_input.setEnabled(False) + self.skip_split_input.setEnabled(False) + self.undo_split_input.setEnabled(False) + self.pause_hotkey_input.setEnabled(False) + self.timer_global_hotkeys_label.setText("Hotkeys Inactive - Use LiveSplit Hotkeys") # Send version and process ID to stdout print(f"{VERSION}\n{os.getpid()}", flush=True) @@ -193,67 +172,66 @@ def __init__(self, parent: Optional[QWidget] = None): self.update_auto_control.start() # split image folder line edit text - self.splitimagefolderLineEdit.setText("No Folder Selected") + self.split_image_folder_input.setText("No Folder Selected") # Connecting button clicks to functions - self.browseButton.clicked.connect(self.browse) - self.selectregionButton.clicked.connect(lambda: selectRegion(self)) - self.takescreenshotButton.clicked.connect(self.takeScreenshot) - self.startautosplitterButton.clicked.connect(self.autoSplitter) - self.checkfpsButton.clicked.connect(self.checkFPS) - self.resetButton.clicked.connect(self.reset) - self.skipsplitButton.clicked.connect(self.skipSplit) - self.undosplitButton.clicked.connect(self.undoSplit) - self.setsplithotkeyButton.clicked.connect(lambda: setSplitHotkey(self)) - self.setresethotkeyButton.clicked.connect(lambda: setResetHotkey(self)) - self.setskipsplithotkeyButton.clicked.connect(lambda: setSkipSplitHotkey(self)) - self.setundosplithotkeyButton.clicked.connect(lambda: setUndoSplitHotkey(self)) - self.setpausehotkeyButton.clicked.connect(lambda: setPauseHotkey(self)) - self.alignregionButton.clicked.connect(lambda: alignRegion(self)) - self.selectwindowButton.clicked.connect(lambda: selectWindow(self)) - self.startImageReloadButton.clicked.connect(lambda: self.loadStartImage(True, True)) - self.actionCheck_for_Updates_on_Open.changed.connect(lambda: self.set_check_for_updates_on_open( - self.actionCheck_for_Updates_on_Open.isChecked()) + self.browse_button.clicked.connect(self.__browse) + self.select_region_button.clicked.connect(lambda: select_region(self)) + self.take_screenshot_button.clicked.connect(self.__take_screenshot) + self.start_auto_splitter_button.clicked.connect(self.__auto_splitter) + self.check_fps_button.clicked.connect(self.__check_fps) + self.reset_button.clicked.connect(self.reset) + self.skip_split_button.clicked.connect(self.__skip_split) + self.undo_split_button.clicked.connect(self.__undo_split) + self.set_split_hotkey_button.clicked.connect(lambda: set_split_hotkey(self)) + self.set_reset_hotkey_button.clicked.connect(lambda: set_reset_hotkey(self)) + self.set_skip_split_hotkey_button.clicked.connect(lambda: set_skip_split_hotkey(self)) + self.set_undo_split_hotkey_button.clicked.connect(lambda: set_undo_split_hotkey(self)) + self.set_pause_hotkey_button.clicked.connect(lambda: set_pause_hotkey(self)) + self.align_region_button.clicked.connect(lambda: align_region(self)) + self.select_window_button.clicked.connect(lambda: select_window(self)) + self.start_image_reload_button.clicked.connect(lambda: self.load_start_image(True, True)) + self.action_check_for_updates_on_open.changed.connect(lambda: settings.set_check_for_updates_on_open( + self, + self.action_check_for_updates_on_open.isChecked()) ) # update x, y, width, and height when changing the value of these spinbox's are changed - self.xSpinBox.valueChanged.connect(self.updateX) - self.ySpinBox.valueChanged.connect(self.updateY) - self.widthSpinBox.valueChanged.connect(self.updateWidth) - self.heightSpinBox.valueChanged.connect(self.updateHeight) + self.x_spinbox.valueChanged.connect(self.__update_x) + self.y_spinbox.valueChanged.connect(self.__update_y) + self.width_spinbox.valueChanged.connect(self.__update_width) + self.height_spinbox.valueChanged.connect(self.__update_height) # connect signals to functions - self.updateCurrentSplitImage.connect(self.updateSplitImageGUI) - self.afterSettingHotkeySignal.connect(lambda: afterSettingHotkey(self)) - self.startAutoSplitterSignal.connect(self.autoSplitter) - self.updateCheckerWidgetSignal.connect(lambda latest_version, check_on_open: - open_update_checker(self, latest_version, check_on_open)) - self.resetSignal.connect(self.reset) - self.skipSplitSignal.connect(self.skipSplit) - self.undoSplitSignal.connect(self.undoSplit) - self.showErrorSignal.connect(lambda errorMessageBox: errorMessageBox()) + self.after_setting_hotkey_signal.connect(lambda: after_setting_hotkey(self)) + self.start_auto_splitter_signal.connect(self.__auto_splitter) + self.update_checker_widget_signal.connect(lambda latest_version, check_on_open: + open_update_checker(self, latest_version, check_on_open)) + self.reset_signal.connect(self.reset) + self.skip_split_signal.connect(self.__skip_split) + self.undo_split_signal.connect(self.__undo_split) + self.pause_signal.connect(self.pause) # live image checkbox - self.liveimageCheckBox.clicked.connect(self.checkLiveImage) - self.timerLiveImage.timeout.connect(self.liveImageFunction) + self.live_image_checkbox.clicked.connect(self.check_live_image) + self.timer_live_image.timeout.connect(self.__live_image_function) # Automatic timer start - self.timerStartImage.timeout.connect(self.startImageFunction) - - # Last loaded settings and last successful loaded settings file path to None until we try to load them - self.last_loaded_settings = None - self.last_successfully_loaded_settings_file_path = None + self.timer_start_image.timeout.connect(self.__start_image_function) if not self.is_auto_controlled: - settings.loadSettings(self, load_settings_on_open=True) + settings.load_settings(self, load_settings_on_open=True) - # FUNCTIONS + self.show() - def getGlobalSettingsValues(self): - self.setting_check_for_updates_on_open = QtCore.QSettings("AutoSplit", "Check For Updates On Open") + # Needs to be after Ui_MainWindow.show() to be shown overtop + if self.action_check_for_updates_on_open.isChecked(): + check_for_updates(self, check_on_open=True) + + # FUNCTIONS # TODO add checkbox for going back to image 1 when resetting. - def browse(self): + def __browse(self): # User selects the file with the split images in it. new_split_image_directory = QFileDialog.getExistingDirectory( self, @@ -264,299 +242,251 @@ def browse(self): if new_split_image_directory: # set the split image folder line to the directory text self.split_image_directory = new_split_image_directory - self.splitimagefolderLineEdit.setText(f"{new_split_image_directory}/") - self.loadStartImage() + self.split_image_folder_input.setText(f"{new_split_image_directory}/") + self.load_start_image() - def checkLiveImage(self): - if self.liveimageCheckBox.isChecked(): - self.timerLiveImage.start(int(1000 / 60)) + def check_live_image(self): + if self.live_image_checkbox.isChecked(): + self.timer_live_image.start(int(1000 / 60)) else: - self.timerLiveImage.stop() - self.liveImageFunction() + self.timer_live_image.stop() + self.__live_image_function() - def liveImageFunction(self): + def __live_image_function(self): try: - windowText = win32gui.GetWindowText(self.hwnd) - self.captureregionwindowLabel.setText(windowText) - if not windowText: - self.timerLiveImage.stop() - self.liveImage.clear() + window_text = win32gui.GetWindowText(self.hwnd) + self.capture_region_window_label.setText(window_text) + if not window_text: + self.timer_live_image.stop() + self.live_image.clear() if self.live_image_function_on_open: self.live_image_function_on_open = False else: - error_messages.regionError() + error_messages.region() return - capture = capture_region(self.hwnd, self.selection, self.forcePrintWindowCheckBox.isChecked()) - capture = cv2.resize(capture, DISPLAY_RESIZE, interpolation=cv2.INTER_NEAREST) + capture = capture_region(self.hwnd, self.selection, self.force_print_window_checkbox.isChecked()) - capture = cv2.cvtColor(capture, cv2.COLOR_BGRA2RGB) - - # Convert to set it on the label - qImage = QtGui.QImage(cast(bytes, capture), + # Set live image in UI + capture = cv2.cvtColor(capture, cv2.COLOR_BGRA2BGR) + qimage = QtGui.QImage(capture.data, capture.shape[1], capture.shape[0], - capture.shape[1] * 3, - QtGui.QImage.Format.Format_RGB888) - pix = QtGui.QPixmap(qImage) - self.liveImage.setPixmap(pix) + capture.shape[1] * capture.shape[2], + QtGui.QImage.Format.Format_BGR888) + self.live_image.setPixmap(QtGui.QPixmap(qimage).scaled( + self.live_image.size(), + QtCore.Qt.AspectRatioMode.IgnoreAspectRatio)) except AttributeError: pass - def loadStartImage(self, started_by_button: bool = False, wait_for_delay: bool = True): - self.timerStartImage.stop() - self.currentsplitimagefileLabel.setText(" ") - self.startImageLabel.setText("Start image: not found") + def load_start_image(self, started_by_button: bool = False, wait_for_delay: bool = True): + self.timer_start_image.stop() + self.current_split_image_file_label.setText(" ") + self.start_image_label.setText(f"{START_IMAGE_TEXT}: not found") QApplication.processEvents() - if not validateBeforeComparison(self, started_by_button): + if not self.is_auto_controlled \ + and (not self.split_input.text() + or not self.reset_input.text() + or not self.pause_hotkey_input.text()): + error_messages.load_start_image() return - - self.start_image_name = None - for image in os.listdir(self.split_image_directory): - if "start_auto_splitter" in image.lower(): - if self.start_image_name is None: - self.start_image_name = image - else: - if started_by_button: - error_messages.multipleKeywordImagesError("start_auto_splitter") - return - - if self.start_image_name is None: + if self.start_image is None: if started_by_button: - error_messages.noKeywordImageError("start_auto_splitter") + error_messages.no_keyword_image("start_auto_splitter") return - if self.start_image_name is not None \ - and not self.is_auto_controlled \ - and (not self.splitLineEdit.text() - or not self.resetLineEdit.text() - or not self.pausehotkeyLineEdit.text()): - error_messages.loadStartImageError() + if not (validate_before_parsing(self, started_by_button) and parse_and_validate_images(self)): return - self.split_image_filenames = os.listdir(self.split_image_directory) self.split_image_number = 0 - self.start_image_mask = None - path = os.path.join(self.split_image_directory, self.start_image_name) - self.start_image = cv2.imread(path, cv2.IMREAD_UNCHANGED) - if self.start_image is None: - error_messages.imageTypeError(path) - return - # if image has transparency, create a mask - self.imageHasTransparency = checkIfImageHasTransparency(self.start_image) - if self.imageHasTransparency: - self.start_image = cv2.resize(self.start_image, COMPARISON_RESIZE, interpolation=cv2.INTER_NEAREST) - # Create mask based on resized, nearest neighbor interpolated split image - lower = np.array([0, 0, 0, 1], dtype="uint8") - upper = np.array([MAXBYTE, MAXBYTE, MAXBYTE, MAXBYTE], dtype="uint8") - self.start_image_mask = cv2.inRange(self.start_image, lower, upper) - - # set split image as BGR - self.start_image = cv2.cvtColor(self.start_image, cv2.COLOR_BGRA2BGR) - - # otherwise, open image normally. - else: - self.start_image = cv2.imread(path, cv2.IMREAD_COLOR) - self.start_image = cv2.resize(self.start_image, COMPARISON_RESIZE, interpolation=cv2.INTER_NEAREST) - - start_image_pause = split_parser.pause_from_filename(self.start_image_name) - if not wait_for_delay and start_image_pause is not None and start_image_pause > 0: - self.check_start_image_timestamp = time() + start_image_pause - self.startImageLabel.setText("Start image: paused") - self.highestsimilarityLabel.setText(" ") - self.currentsimilaritythresholdnumberLabel.setText(" ") + start_pause_time = self.start_image.get_pause_time(self) + if not wait_for_delay and start_pause_time > 0: + self.check_start_image_timestamp = time() + start_pause_time + self.start_image_label.setText(f"{START_IMAGE_TEXT}: paused") + self.highest_similarity_label.setText(" ") + self.current_similarity_threshold_number_label.setText(" ") else: self.check_start_image_timestamp = 0.0 - self.startImageLabel.setText("Start image: ready") - self.updateSplitImage(self.start_image_name, from_start_image=True) + self.start_image_label.setText(f"{START_IMAGE_TEXT}: ready") + self.__update_split_image(self.start_image, from_start_image=True) self.highest_similarity = 0.0 self.start_image_split_below_threshold = False - self.timerStartImage.start(int(1000 / self.fpslimitSpinBox.value())) + self.timer_start_image.start(int(1000 / self.fps_limit_spinbox.value())) QApplication.processEvents() - def startImageFunction(self): - + def __start_image_function(self): if self.start_image is None \ - or not self.start_image_name \ + or not self.start_image \ or time() < self.check_start_image_timestamp \ - or (not self.splitLineEdit.text() and not self.is_auto_controlled): + or (not self.split_input.text() and not self.is_auto_controlled): pause_time_left = f"{self.check_start_image_timestamp - time():.1f}" - self.currentSplitImage.setText( - f"None\n (Paused before loading Start Image).\n {pause_time_left} sec remaining") + self.current_split_image.setText( + f"None\n (Paused before loading {START_IMAGE_TEXT}).\n {pause_time_left} sec remaining") return if self.check_start_image_timestamp > 0: self.check_start_image_timestamp = 0.0 - self.startImageLabel.setText("Start image: ready") - self.updateSplitImage(self.start_image_name, from_start_image=True) - - capture = self.getCaptureForComparison() - start_image_similarity = compareImage( - self.comparisonmethodComboBox.currentIndex(), - self.start_image, - capture, - self.start_image_mask) - start_image_threshold = split_parser.threshold_from_filename(self.start_image_name) \ - or self.similaritythresholdDoubleSpinBox.value() - self.currentsimilaritythresholdnumberLabel.setText(f"{start_image_threshold:.2f}") - start_image_flags = split_parser.flags_from_filename(self.start_image_name) - start_image_delay = split_parser.delay_from_filename(self.start_image_name) + self.start_image_label.setText(f"{START_IMAGE_TEXT}: ready") + self.__update_split_image(self.start_image, from_start_image=True) + + capture = self.__get_capture_for_comparison() + start_image_threshold = self.start_image.get_similarity_threshold(self) + start_image_similarity = self.start_image.compare_with_capture(self, capture) + self.current_similarity_threshold_number_label.setText(f"{start_image_threshold:.2f}") # Show live similarity if the checkbox is checked - self.livesimilarityLabel.setText(str(start_image_similarity)[:4] - if self.showlivesimilarityCheckBox.isChecked() - else " ") + self.live_similarity_label.setText(str(start_image_similarity)[:4] + if self.show_live_similarity_checkbox.isChecked() + else " ") # If the similarity becomes higher than highest similarity, set it as such. if start_image_similarity > self.highest_similarity: self.highest_similarity = start_image_similarity # Show live highest similarity if the checkbox is checked - self.highestsimilarityLabel.setText(str(self.highest_similarity)[:4] - if self.showlivesimilarityCheckBox.isChecked() - else " ") + self.highest_similarity_label.setText(str(self.highest_similarity)[:4] + if self.show_live_similarity_checkbox.isChecked() + else " ") # If the {b} flag is set, let similarity go above threshold first, then split on similarity below threshold # Otherwise just split when similarity goes above threshold - if start_image_flags & BELOW_FLAG == BELOW_FLAG \ + below_flag = self.start_image.check_flag(BELOW_FLAG) + if below_flag \ and not self.start_image_split_below_threshold \ and start_image_similarity >= start_image_threshold: self.start_image_split_below_threshold = True return - if (start_image_flags & BELOW_FLAG == BELOW_FLAG + if (below_flag and self.start_image_split_below_threshold and start_image_similarity < start_image_threshold) \ - or (start_image_similarity >= start_image_threshold and not start_image_flags & BELOW_FLAG): - def split(): - send_command(self, "start") - # Email sent to pyqt@riverbankcomputing.com - QtTest.QTest.qWait(1 / self.fpslimitSpinBox.value()) # type: ignore - self.startAutoSplitter() + or (start_image_similarity >= start_image_threshold and not below_flag): - self.timerStartImage.stop() + self.timer_start_image.stop() self.start_image_split_below_threshold = False # delay start image if needed - if start_image_delay > 0: - self.startImageLabel.setText("Start image: delaying start...") + if self.start_image.delay > 0: + self.start_image_label.setText(f"{START_IMAGE_TEXT}: delaying start...") delay_start_time = time() - while time() - delay_start_time < (start_image_delay / 1000): - delay_time_left = round((start_image_delay / 1000) - (time() - delay_start_time), 1) - self.currentSplitImage.setText(f"Delayed Before Starting:\n {delay_time_left} sec remaining") + start_delay = self.start_image.delay / 1000 + while time() - delay_start_time < start_delay: + delay_time_left = round(start_delay - (time() - delay_start_time), 1) + self.current_split_image.setText( + f"Delayed Before Starting:\n {delay_time_left} sec remaining") # Email sent to pyqt@riverbankcomputing.com QtTest.QTest.qWait(1) # type: ignore - self.startImageLabel.setText("Start image: started") - split() + self.start_image_label.setText(f"{START_IMAGE_TEXT}: started") + send_command(self, "start") + # Email sent to pyqt@riverbankcomputing.com + QtTest.QTest.qWait(1 / self.fps_limit_spinbox.value()) # type: ignore + self.start_auto_splitter() # update x, y, width, height when spinbox values are changed - def updateX(self): + def __update_x(self): try: - self.selection.left = self.xSpinBox.value() - self.selection.right = self.selection.left + self.widthSpinBox.value() - self.checkLiveImage() + self.selection.left = self.x_spinbox.value() + self.selection.right = self.selection.left + self.width_spinbox.value() + self.check_live_image() except AttributeError: pass - def updateY(self): + def __update_y(self): try: - self.selection.top = self.ySpinBox.value() - self.selection.bottom = self.selection.top + self.heightSpinBox.value() - self.checkLiveImage() + self.selection.top = self.y_spinbox.value() + self.selection.bottom = self.selection.top + self.height_spinbox.value() + self.check_live_image() except AttributeError: pass - def updateWidth(self): - self.selection.right = self.selection.left + self.widthSpinBox.value() - self.checkLiveImage() - - def updateHeight(self): - self.selection.bottom = self.selection.top + self.heightSpinBox.value() - self.checkLiveImage() + def __update_width(self): + self.selection.right = self.selection.left + self.width_spinbox.value() + self.check_live_image() - # update current split image. needed this to avoid updating it through the hotkey thread. - def updateSplitImageGUI(self, qImage: QtGui.QImage): - pix = QtGui.QPixmap(qImage) - self.currentSplitImage.setPixmap(pix) + def __update_height(self): + self.selection.bottom = self.selection.top + self.height_spinbox.value() + self.check_live_image() - def takeScreenshot(self): - if not validateBeforeComparison(self, check_empty_directory=False): + def __take_screenshot(self): + if not validate_before_parsing(self, check_empty_directory=False): return - take_screenshot_filename = "001_SplitImage" # check if file exists and rename it if it does - # Below starts the FileNameNumber at #001 up to #999. After that it will go to 1000, + # Below starts the file_name_number at #001 up to #999. After that it will go to 1000, # which is a problem, but I doubt anyone will get to 1000 split images... - i = 1 - while os.path.exists(os.path.join(self.split_image_directory, f"{take_screenshot_filename}.png")): - FileNameNumber = (f"{i:03}") - take_screenshot_filename = f"{FileNameNumber}_SplitImage" - i += 1 + screenshot_index = 1 + while True: + screenshot_path = os.path.join(self.split_image_directory, f"{screenshot_index:03}_SplitImage.png") + if not os.path.exists(screenshot_path): + break + screenshot_index += 1 # grab screenshot of capture region - capture = capture_region(self.hwnd, self.selection, self.forcePrintWindowCheckBox.isChecked()) - capture = cv2.cvtColor(capture, cv2.COLOR_BGRA2BGR) + capture = capture_region(self.hwnd, self.selection, self.force_print_window_checkbox.isChecked()) # save and open image - cv2.imwrite(os.path.join(self.split_image_directory, f"{take_screenshot_filename}.png"), capture) - os.startfile(os.path.join(self.split_image_directory, f"{take_screenshot_filename}.png")) + cv2.imwrite(screenshot_path, capture) + os.startfile(screenshot_path) - # check max FPS button connects here. - # TODO: Average on all images and check for transparency (cv2.COLOR_BGRA2RGB and cv2.IMREAD_UNCHANGED) - def checkFPS(self): - if not validateBeforeComparison(self): + def __check_fps(self): + self.fps_value_label.setText(" ") + if not (validate_before_parsing(self) and parse_and_validate_images(self)): return - split_image_filenames = os.listdir(self.split_image_directory) - split_images = [ - cv2.imread(os.path.join(self.split_image_directory, image), cv2.IMREAD_COLOR) - for image - in split_image_filenames] - for i, image in enumerate(split_images): - if image is None: - error_messages.imageTypeError(split_image_filenames[i]) - return - - # grab first image in the split image folder - split_image = split_images[0] - split_image = cv2.cvtColor(split_image, cv2.COLOR_BGR2RGB) - split_image = cv2.resize(split_image, COMPARISON_RESIZE, interpolation=cv2.INTER_NEAREST) + images = self.split_images + if self.start_image: + images.append(self.start_image) + if self.reset_image: + images.append(self.reset_image) - # run 10 iterations of screenshotting capture region + comparison. - count = 0 + # run X iterations of screenshotting capture region + comparison + displaying. t0 = time() - while count < 10: - capture = capture_region(self.hwnd, self.selection, self.forcePrintWindowCheckBox.isChecked()) - capture = cv2.resize(capture, COMPARISON_RESIZE, interpolation=cv2.INTER_NEAREST) - capture = cv2.cvtColor(capture, cv2.COLOR_BGRA2RGB) - compareImage(self.comparisonmethodComboBox.currentIndex(), split_image, capture) - count += 1 + for image in images: + count = 0 + while count < CHECK_FPS_ITERATIONS: + capture = self.__get_capture_for_comparison() + _ = image.compare_with_capture(self, capture) + # Fallback to capture bytes just for test and type safety + numpy_array = image.bytes if image.bytes is not None else capture + # Set current split image in UI + split_image_display = cv2.cvtColor(numpy_array, cv2.COLOR_BGRA2RGBA) + qimage = QtGui.QImage(split_image_display.data, + split_image_display.shape[1], + split_image_display.shape[0], + split_image_display.shape[1] * split_image_display.shape[2], + QtGui.QImage.Format.Format_RGBA8888) + self.current_split_image.setPixmap(QtGui.QPixmap(qimage).scaled( + self.current_split_image.size(), + QtCore.Qt.AspectRatioMode.IgnoreAspectRatio)) + count += 1 + self.current_split_image.clear() # calculate FPS t1 = time() - fps = str(int(10 / (t1 - t0))) - self.fpsvalueLabel.setText(fps) + fps = int((CHECK_FPS_ITERATIONS * len(images)) / (t1 - t0)) + self.fps_value_label.setText(str(fps)) - def is_current_split_out_of_range(self): + def __is_current_split_out_of_range(self): return self.split_image_number < 0 \ - or self.split_image_number > len(self.split_image_filenames_including_loops) - 1 + or self.split_image_number > len(self.split_images_and_loop_number) - 1 # undo split button and hotkey connect to here - def undoSplit(self): + def __undo_split(self): # Can't undo until timer is started # or Undoing past the first image - if self.startautosplitterButton.text() == "Start Auto Splitter" \ - or "Delayed Split" in self.currentSplitImage.text() \ - or (not self.undosplitButton.isEnabled() and not self.is_auto_controlled) \ - or self.is_current_split_out_of_range(): + if self.start_auto_splitter_button.text() == START_AUTO_SPLITTER_TEXT \ + or "Delayed Split" in self.current_split_image.text() \ + or (not self.undo_split_button.isEnabled() and not self.is_auto_controlled) \ + or self.__is_current_split_out_of_range(): return - if self.groupDummySplitsCheckBox.isChecked(): + if self.group_dummy_splits_checkbox.isChecked(): for i, group in enumerate(self.split_groups): if i > 0 and self.split_image_number in group: self.split_image_number = self.split_groups[i - 1][0] @@ -564,21 +494,19 @@ def undoSplit(self): else: self.split_image_number -= 1 - self.updateSplitImage() - - return + self.__update_split_image() # skip split button and hotkey connect to here - def skipSplit(self): + def __skip_split(self): # Can't skip or split until timer is started # or Splitting/skipping when there are no images left - if self.startautosplitterButton.text() == "Start Auto Splitter" \ - or "Delayed Split" in self.currentSplitImage.text() \ - or (not self.skipsplitButton.isEnabled() and not self.is_auto_controlled) \ - or self.is_current_split_out_of_range(): + if self.start_auto_splitter_button.text() == START_AUTO_SPLITTER_TEXT \ + or "Delayed Split" in self.current_split_image.text() \ + or (not self.skip_split_button.isEnabled() and not self.is_auto_controlled) \ + or self.__is_current_split_out_of_range(): return - if self.groupDummySplitsCheckBox.isChecked(): + if self.group_dummy_splits_checkbox.isChecked(): for group in self.split_groups: if self.split_image_number in group: self.split_image_number = group[-1] + 1 @@ -586,137 +514,80 @@ def skipSplit(self): else: self.split_image_number += 1 - self.updateSplitImage() - - return + self.__update_split_image() - # def pause(self): + def pause(self): # TODO add what to do when you hit pause hotkey, if this even needs to be done + pass def reset(self): # When the reset button or hotkey is pressed, it will change this text, - # which will trigger in the autoSplitter function, if running, to abort and change GUI. - self.startautosplitterButton.setText("Start Auto Splitter") + # which will trigger in the __auto_splitter function, if running, to abort and change GUI. + self.start_auto_splitter_button.setText(START_AUTO_SPLITTER_TEXT) # Functions for the hotkeys to return to the main thread from signals and start their corresponding functions - def startAutoSplitter(self): + def start_auto_splitter(self): # If the auto splitter is already running or the button is disabled, don't emit the signal to start it. - if self.startautosplitterButton.text() == "Running..." \ - or (not self.startautosplitterButton.isEnabled() and not self.is_auto_controlled): + if self.start_auto_splitter_button.text() == "Running..." \ + or (not self.start_auto_splitter_button.isEnabled() and not self.is_auto_controlled): return - if self.startImageLabel.text() == "Start image: ready" or self.startImageLabel.text() == "Start image: paused": - self.startImageLabel.setText("Start image: not ready") - - self.startAutoSplitterSignal.emit() - - def startReset(self): - self.resetSignal.emit() + start_label: str = self.start_image_label.text() + if start_label.endswith("ready") or start_label.endswith("paused"): + self.start_image_label.setText(f"{START_IMAGE_TEXT}: not ready") - def startSkipSplit(self): - self.skipSplitSignal.emit() + self.start_auto_splitter_signal.emit() - def startUndoSplit(self): - self.undoSplitSignal.emit() - - def startPause(self): - self.pauseSignal.emit() - - def checkForReset(self): - if self.startautosplitterButton.text() == "Start Auto Splitter": - if self.autostartonresetCheckBox.isChecked(): - self.startAutoSplitterSignal.emit() + def __check_for_reset(self): + if self.start_auto_splitter_button.text() == START_AUTO_SPLITTER_TEXT: + if self.auto_start_on_reset_checkbox.isChecked(): + self.start_auto_splitter_signal.emit() else: - self.guiChangesOnReset() + self.gui_changes_on_reset() return True return False - def autoSplitter(self): - if not validateBeforeComparison(self): - self.guiChangesOnReset() + def __auto_splitter(self): + if not self.split_input.text() and not self.is_auto_controlled: + self.gui_changes_on_reset() + error_messages.split_hotkey() return - if not self.splitLineEdit.text() and not self.is_auto_controlled: - self.guiChangesOnReset() - error_messages.splitHotkeyError() + if not (validate_before_parsing(self) and parse_and_validate_images(self)): + self.gui_changes_on_reset() return - # get split image filenames - self.split_image_filenames = os.listdir(self.split_image_directory) - - split_parser.validate_images_before_parsing(self) - - # find reset image then remove it from the list - self.findResetImage() - - # Find start_auto_splitter_image and then remove it from the list - split_parser.removeStartAutoSplitterImage(self.split_image_filenames) - -# region TODO I feel this entire region could be simplified - - # construct loop amounts for each split image - split_image_loop_amount = [ - split_parser.loop_from_filename(image) - for image - in self.split_image_filenames] - - # construct a list of filenames, each filename copied with # of loops it has. - self.split_image_filenames_including_loops: list[str] = [] - for i, filename in enumerate(self.split_image_filenames): - current_loop = 1 - while split_image_loop_amount[i] >= current_loop: - self.split_image_filenames_including_loops.append(filename) - current_loop = current_loop + 1 - - # construct a list of corresponding loop number to the filenames - loop_numbers: list[int] = [] - loop_count = 1 - for i, filename in enumerate(self.split_image_filenames_including_loops): - if i == 0: - loop_numbers.append(1) - else: - if self.split_image_filenames_including_loops[i] != self.split_image_filenames_including_loops[i - 1]: - loop_count = 1 - else: - loop_count += 1 - loop_numbers.append(loop_count) - - # Merge them - self.split_image_filenames_and_loop_number = [ - (filename, loop_numbers[i], self.split_image_filenames_including_loops.count(filename)) - for i, filename in enumerate(self.split_image_filenames_including_loops) - ] - - # construct groups of splits if needed - self.split_groups: list[list[int]] = [] - if self.groupDummySplitsCheckBox.isChecked(): + # Construct a list of images + loop count tuples. + self.split_images_and_loop_number = [ + item for flattenlist + in [[(split_image, i + 1) for i in range(split_image.loops)] + for split_image + in self.split_images] + for item in flattenlist] + + # Construct groups of splits if needed + self.split_groups = [] + if self.group_dummy_splits_checkbox.isChecked(): current_group: list[int] = [] self.split_groups.append(current_group) - for i, image in enumerate(self.split_image_filenames_including_loops): + for i, image in enumerate(self.split_images): current_group.append(i) - flags = split_parser.flags_from_filename(image) - if flags & DUMMY_FLAG != DUMMY_FLAG and i < len(self.split_image_filenames_including_loops) - 1: + if not image.check_flag(DUMMY_FLAG) and i < len(self.split_images) - 1: current_group = [] self.split_groups.append(current_group) - -# endregion - - self.guiChangesOnStart() + self.gui_changes_on_start() # Initialize a few attributes self.split_image_number = 0 self.waiting_for_split_delay = False self.split_below_threshold = False split_time = 0 - number_of_split_images = len(self.split_image_filenames_including_loops) - dummy_splits_array = [ - split_parser.flags_from_filename(image) & DUMMY_FLAG == DUMMY_FLAG - for image - in self.split_image_filenames_including_loops] + number_of_split_images = len(self.split_images_and_loop_number) + dummy_splits_array = [image.check_flag(DUMMY_FLAG) for image in self.split_images] self.run_start_time = time() - windowText = win32gui.GetWindowText(self.hwnd) + window_text = win32gui.GetWindowText(self.hwnd) # First while loop: stays in this loop until all of the split images have been split while self.split_image_number < number_of_split_images: @@ -728,144 +599,117 @@ def autoSplitter(self): QApplication.processEvents() continue - self.updateSplitImage() + self.__update_split_image() # second while loop: stays in this loop until similarity threshold is met # skip loop if we just finished waiting for the split delay and need to press the split key! start = time() while True: # reset if the set screen region window was closed - if not windowText: + if not window_text: self.reset() - if self.checkForReset(): + if self.__check_for_reset(): return # calculate similarity for reset image - capture = self.getCaptureForComparison() - - if self.shouldCheckResetImage(): - reset_similarity = compareImage( - self.comparisonmethodComboBox.currentIndex(), - self.reset_image, - capture, - self.reset_mask) - if reset_similarity >= self.reset_image_threshold: - send_command(self, "reset") - self.reset() + capture = self.__get_capture_for_comparison() - if self.checkForReset(): - return + _ = self.__reset_if_should(capture) - # TODO: Check is this actually still needed? - # get capture again if current and reset image have different mask flags - if self.imageHasTransparency != (self.reset_mask is not None): - capture = self.getCaptureForComparison() + if self.__check_for_reset(): + return # calculate similarity for split image - self.similarity = compareImage( - self.comparisonmethodComboBox.currentIndex(), - self.split_image, - capture, - self.image_mask) + self.similarity = self.split_image.compare_with_capture(self, capture) # show live similarity if the checkbox is checked - if self.showlivesimilarityCheckBox.isChecked(): - self.livesimilarityLabel.setText(str(self.similarity)[:4]) - else: - self.livesimilarityLabel.setText(" ") + self.live_similarity_label.setText( + str(self.similarity)[:4] + if self.show_live_similarity_checkbox.isChecked() + else " ") # if the similarity becomes higher than highest similarity, set it as such. if self.similarity > self.highest_similarity: self.highest_similarity = self.similarity # show live highest similarity if the checkbox is checked - if self.showhighestsimilarityCheckBox.isChecked(): - self.highestsimilarityLabel.setText(str(self.highest_similarity)[:4]) - else: - self.highestsimilarityLabel.setText(" ") + self.highest_similarity_label.setText( + str(self.highest_similarity)[:4] + if self.show_highest_similarity_checkbox.isChecked() + else " ") if not self.is_auto_controlled: # if its the last split image or can't skip due to grouped dummy splits, disable skip split button is_last = self.split_image_number == number_of_split_images - 1 \ - or (self.groupDummySplitsCheckBox.isChecked() + or (self.group_dummy_splits_checkbox.isChecked() and dummy_splits_array[self.split_image_number:].count(False) <= 1) - self.skipsplitButton.setEnabled(not is_last) + self.skip_split_button.setEnabled(not is_last) # if its the first split image, disable the undo split button - self.undosplitButton.setEnabled(self.split_image_number != 0) + self.undo_split_button.setEnabled(self.split_image_number != 0) # if the b flag is set, let similarity go above threshold first, # then split on similarity below threshold. # if no b flag, just split when similarity goes above threshold. if not self.waiting_for_split_delay: - if self.flags & BELOW_FLAG == BELOW_FLAG: - if self.split_below_threshold: - if self.similarity < self.similarity_threshold: - self.split_below_threshold = False - break - elif self.similarity >= self.similarity_threshold: + if self.similarity >= self.split_image.get_similarity_threshold(self): + if not self.split_image.check_flag(BELOW_FLAG): + break + if not self.split_below_threshold: self.split_below_threshold = True continue - elif self.similarity >= self.similarity_threshold: + elif self.split_image.check_flag(BELOW_FLAG) and self.split_below_threshold: + self.split_below_threshold = False break # limit the number of time the comparison runs to reduce cpu usage - frame_interval = 1 / self.fpslimitSpinBox.value() + frame_interval: float = 1 / self.fps_limit_spinbox.value() # Email sent to pyqt@riverbankcomputing.com - QtTest.QTest.qWait(frame_interval - (time() - start) % frame_interval) # type: ignore + QtTest.QTest.qWait(int(frame_interval - (time() - start) % frame_interval)) # type: ignore QApplication.processEvents() # comes here when threshold gets met # We need to make sure that this isn't a dummy split before sending # the key press. - if self.flags & DUMMY_FLAG != DUMMY_FLAG: + if not self.split_image.check_flag(DUMMY_FLAG): # If it's a delayed split, check if the delay has passed # Otherwise calculate the split time for the key press if self.split_delay > 0 and not self.waiting_for_split_delay: split_time = int(round(time() * 1000) + self.split_delay) self.waiting_for_split_delay = True - self.undosplitButton.setEnabled(False) - self.skipsplitButton.setEnabled(False) - self.currentsplitimagefileLabel.setText(" ") + self.undo_split_button.setEnabled(False) + self.skip_split_button.setEnabled(False) + self.current_split_image_file_label.setText(" ") # check for reset while delayed and display a counter of the remaining split delay time delay_start_time = time() split_delay = self.split_delay / 1000 while time() - delay_start_time < split_delay: delay_time_left = round(split_delay - (time() - delay_start_time), 1) - self.currentSplitImage.setText(f"Delayed Split: {delay_time_left} sec remaining") + self.current_split_image.setText(f"Delayed Split: {delay_time_left} sec remaining") # check for reset - if not windowText: + if not window_text: self.reset() - if self.checkForReset(): + if self.__check_for_reset(): return # calculate similarity for reset image - if self.shouldCheckResetImage(): - capture = self.getCaptureForComparison() - - reset_similarity = compareImage( - self.comparisonmethodComboBox.currentIndex(), - self.reset_image, - capture, - self.reset_mask) - if reset_similarity >= self.reset_image_threshold: - send_command(self, "reset") - self.reset() - continue + capture = self.__get_capture_for_comparison() + if self.__reset_if_should(capture): + continue # Email sent to pyqt@riverbankcomputing.com QtTest.QTest.qWait(1) # type: ignore self.waiting_for_split_delay = False # if {p} flag hit pause key, otherwise hit split hotkey - send_command(self, "pause" if self.flags & PAUSE_FLAG == PAUSE_FLAG else "split") + send_command(self, "pause" if self.split_image.check_flag(PAUSE_FLAG) else "split") # if loop check box is checked and its the last split, go to first split. # else go to the next split image. - if self.loopCheckBox.isChecked() and self.split_image_number == number_of_split_images - 1: + if self.loop_checkbox.isChecked() and self.split_image_number == number_of_split_images - 1: self.split_image_number = 0 else: self.split_image_number += 1 @@ -880,28 +724,29 @@ def autoSplitter(self): if not self.is_auto_controlled: # if its the last split image and last loop number, disable skip split button is_last = self.split_image_number == number_of_split_images - 1 \ - or (self.groupDummySplitsCheckBox.isChecked() + or (self.group_dummy_splits_checkbox.isChecked() and dummy_splits_array[self.split_image_number:].count(False) <= 1) - self.skipsplitButton.setEnabled(not is_last) + self.skip_split_button.setEnabled(not is_last) # if its the first split image, disable the undo split button - self.undosplitButton.setEnabled(self.split_image_number != 0) + self.undo_split_button.setEnabled(self.split_image_number != 0) QApplication.processEvents() # A pause loop to check if the user presses skip split, undo split, or reset here. # Also updates the current split image text, counting down the time until the next split image - if self.pause > 0: - self.currentsplitimagefileLabel.setText(" ") - self.imageloopLabel.setText("Image Loop: -") + pause_time = self.split_image.get_pause_time(self) + if pause_time > 0: + self.current_split_image_file_label.setText(" ") + self.image_loop_label.setText("Image Loop: -") pause_start_time = time() - while time() - pause_start_time < self.pause: - pause_time_left = round(self.pause - (time() - pause_start_time), 1) - self.currentSplitImage.setText(f"None (Paused). {pause_time_left} sec remaining") + while time() - pause_start_time < pause_time: + pause_time_left = round(pause_time - (time() - pause_start_time), 1) + self.current_split_image.setText(f"None (Paused). {pause_time_left} sec remaining") # check for reset - if not windowText: + if not window_text: self.reset() - if self.checkForReset(): + if self.__check_for_reset(): return # check for skip/undo split: @@ -909,201 +754,115 @@ def autoSplitter(self): break # calculate similarity for reset image - if self.shouldCheckResetImage(): - capture = self.getCaptureForComparison() - - reset_similarity = compareImage( - self.comparisonmethodComboBox.currentIndex(), - self.reset_image, - capture, - self.reset_mask) - if reset_similarity >= self.reset_image_threshold: - send_command(self, "reset") - self.reset() - continue + capture = self.__get_capture_for_comparison() + if self.__reset_if_should(capture): + send_command(self, "reset") + self.reset() + continue # Email sent to pyqt@riverbankcomputing.com QtTest.QTest.qWait(1) # type: ignore # loop breaks to here when the last image splits - self.guiChangesOnReset() + self.gui_changes_on_reset() - def guiChangesOnStart(self): - self.timerStartImage.stop() - self.startautosplitterButton.setText("Running...") - self.browseButton.setEnabled(False) - self.groupDummySplitsCheckBox.setEnabled(False) - self.startImageReloadButton.setEnabled(False) + def gui_changes_on_start(self): + self.timer_start_image.stop() + self.start_auto_splitter_button.setText("Running...") + self.browse_button.setEnabled(False) + self.group_dummy_splits_checkbox.setEnabled(False) + self.start_image_reload_button.setEnabled(False) if not self.is_auto_controlled: - self.startautosplitterButton.setEnabled(False) - self.resetButton.setEnabled(True) - self.undosplitButton.setEnabled(True) - self.skipsplitButton.setEnabled(True) - self.setsplithotkeyButton.setEnabled(False) - self.setresethotkeyButton.setEnabled(False) - self.setskipsplithotkeyButton.setEnabled(False) - self.setundosplithotkeyButton.setEnabled(False) - self.setpausehotkeyButton.setEnabled(False) + self.start_auto_splitter_button.setEnabled(False) + self.reset_button.setEnabled(True) + self.undo_split_button.setEnabled(True) + self.skip_split_button.setEnabled(True) + self.set_split_hotkey_button.setEnabled(False) + self.set_reset_hotkey_button.setEnabled(False) + self.set_skip_split_hotkey_button.setEnabled(False) + self.set_undo_split_hotkey_button.setEnabled(False) + self.set_pause_hotkey_button.setEnabled(False) QApplication.processEvents() - def guiChangesOnReset(self): - self.startautosplitterButton.setText("Start Auto Splitter") - self.imageloopLabel.setText("Image Loop: -") - self.currentSplitImage.setText(" ") - self.currentsplitimagefileLabel.setText(" ") - self.livesimilarityLabel.setText(" ") - self.highestsimilarityLabel.setText(" ") - self.currentsimilaritythresholdnumberLabel.setText(" ") - self.browseButton.setEnabled(True) - self.groupDummySplitsCheckBox.setEnabled(True) - self.startImageReloadButton.setEnabled(True) + def gui_changes_on_reset(self): + self.start_auto_splitter_button.setText(START_AUTO_SPLITTER_TEXT) + self.image_loop_label.setText("Image Loop: -") + self.current_split_image.setText(" ") + self.current_split_image_file_label.setText(" ") + self.live_similarity_label.setText(" ") + self.highest_similarity_label.setText(" ") + self.current_similarity_threshold_number_label.setText(" ") + self.browse_button.setEnabled(True) + self.group_dummy_splits_checkbox.setEnabled(True) + self.start_image_reload_button.setEnabled(True) if not self.is_auto_controlled: - self.startautosplitterButton.setEnabled(True) - self.resetButton.setEnabled(False) - self.undosplitButton.setEnabled(False) - self.skipsplitButton.setEnabled(False) - self.setsplithotkeyButton.setEnabled(True) - self.setresethotkeyButton.setEnabled(True) - self.setskipsplithotkeyButton.setEnabled(True) - self.setundosplithotkeyButton.setEnabled(True) - self.setpausehotkeyButton.setEnabled(True) + self.start_auto_splitter_button.setEnabled(True) + self.reset_button.setEnabled(False) + self.undo_split_button.setEnabled(False) + self.skip_split_button.setEnabled(False) + self.set_split_hotkey_button.setEnabled(True) + self.set_reset_hotkey_button.setEnabled(True) + self.set_skip_split_hotkey_button.setEnabled(True) + self.set_undo_split_hotkey_button.setEnabled(True) + self.set_pause_hotkey_button.setEnabled(True) QApplication.processEvents() - self.loadStartImage(False, False) - - def getCaptureForComparison(self): - # grab screenshot of capture region - capture = capture_region(self.hwnd, self.selection, self.forcePrintWindowCheckBox.isChecked()) - capture = cv2.resize(capture, COMPARISON_RESIZE, interpolation=cv2.INTER_NEAREST) - # convert to BGR - return cv2.cvtColor(capture, cv2.COLOR_BGRA2BGR) - - def shouldCheckResetImage(self): - return self.reset_image is not None and time() - self.run_start_time > self.reset_image_pause_time - - def findResetImage(self): - self.reset_image = None - self.reset_mask = None - - reset_image_file = None - for image in self.split_image_filenames: - if split_parser.is_reset_image(image): - reset_image_file = image - break - - if reset_image_file is None: - return - - self.split_image_filenames.remove(reset_image_file) - - # create reset image and keep in memory - path = os.path.join(self.split_image_directory, reset_image_file) - - # Override values if they have been specified on the file - pause_from_filename = split_parser.pause_from_filename(reset_image_file) - self.reset_image_pause_time = self.pauseDoubleSpinBox.value() \ - if pause_from_filename is None \ - else pause_from_filename - threshold_from_filename = split_parser.threshold_from_filename(reset_image_file) - self.reset_image_threshold = self.similaritythresholdDoubleSpinBox.value() \ - if threshold_from_filename is None \ - else threshold_from_filename - - self.reset_image = cv2.imread(path, cv2.IMREAD_UNCHANGED) - if self.reset_image is None: - error_messages.imageTypeError(path) - return - # if image has transparency, create a mask - if checkIfImageHasTransparency(self.reset_image): - self.reset_image = cv2.resize(self.reset_image, COMPARISON_RESIZE, interpolation=cv2.INTER_NEAREST) - # Create mask based on resized, nearest neighbor interpolated split image - lower = np.array([0, 0, 0, 1], dtype="uint8") - upper = np.array([MAXBYTE, MAXBYTE, MAXBYTE, MAXBYTE], dtype="uint8") - self.reset_mask = cv2.inRange(self.reset_image, lower, upper) - - # set split image as BGR - self.reset_image = cv2.cvtColor(self.reset_image, cv2.COLOR_BGRA2BGR) - - # otherwise, open image normally. - else: - self.reset_image = cv2.imread(path, cv2.IMREAD_COLOR) - self.reset_image = cv2.resize(self.reset_image, COMPARISON_RESIZE, interpolation=cv2.INTER_NEAREST) + self.load_start_image(False, False) + + def __get_capture_for_comparison(self): + # Grab screenshot of capture region + capture = capture_region(self.hwnd, self.selection, self.force_print_window_checkbox.isChecked()) + return cv2.resize(capture, COMPARISON_RESIZE, interpolation=cv2.INTER_NEAREST) + + def __reset_if_should(self, capture: cv2.ndarray): + """ + Check if we should reset, resets if it's the case, and returns the result + """ + if not self.reset_image: + return False + + reset_similarity = self.reset_image.compare_with_capture(self, capture) + should_reset = reset_similarity >= self.reset_image.get_similarity_threshold(self) \ + and time() - self.run_start_time > self.reset_image.get_pause_time(self) + + if should_reset: + send_command(self, "reset") + self.reset() + return should_reset - def updateSplitImage(self, custom_image_file: str = "", from_start_image: bool = False): + def __update_split_image(self, specific_image: Optional[AutoSplitImage] = None, from_start_image: bool = False): # Splitting/skipping when there are no images left or Undoing past the first image # Start image is expected to be out of range (index 0 of 0-length array) - if "START_AUTO_SPLITTER" not in custom_image_file.upper() and self.is_current_split_out_of_range(): + if (not specific_image or specific_image.image_type != ImageType.START) \ + and self.__is_current_split_out_of_range(): self.reset() return - # get split image path - split_image_file = custom_image_file or self.split_image_filenames_including_loops[0 + self.split_image_number] - self.split_image_path = os.path.join(self.split_image_directory, split_image_file) - - # get flags - self.flags = split_parser.flags_from_filename(split_image_file) - - self.split_image = cv2.imread(self.split_image_path, cv2.IMREAD_UNCHANGED) - if self.split_image is None: - error_messages.imageTypeError(self.split_image_path) - return - self.imageHasTransparency = checkIfImageHasTransparency(self.split_image) - # if image has transparency, create a mask - if self.imageHasTransparency: - split_image_display = copy(self.split_image) - # Transform transparency into UI's gray BG color - transparent_mask = split_image_display[:, :, 3] == 0 - split_image_display[transparent_mask] = [240, 240, 240, MAXBYTE] - split_image_display = cv2.cvtColor(split_image_display, cv2.COLOR_BGRA2RGB) - - self.split_image = cv2.resize(self.split_image, COMPARISON_RESIZE, interpolation=cv2.INTER_NEAREST) - # Create mask based on resized, nearest neighbor interpolated split image - lower = np.array([0, 0, 0, 1], dtype="uint8") - upper = np.array([MAXBYTE, MAXBYTE, MAXBYTE, MAXBYTE], dtype="uint8") - self.image_mask = cv2.inRange(self.split_image, lower, upper) - - # set split image as BGR - self.split_image = cv2.cvtColor(self.split_image, cv2.COLOR_BGRA2BGR) - - # otherwise, open image normally. - else: - self.split_image = cv2.imread(self.split_image_path, cv2.IMREAD_COLOR) - split_image_display = cv2.cvtColor(copy(self.split_image), cv2.COLOR_BGR2RGB) - self.split_image = cv2.resize(self.split_image, COMPARISON_RESIZE, interpolation=cv2.INTER_NEAREST) - self.image_mask = None - - split_image_display = cv2.resize(split_image_display, DISPLAY_RESIZE) - # Set current split image in UI - qImage = QtGui.QImage(cast(bytes, split_image_display), - split_image_display.shape[1], - split_image_display.shape[0], - split_image_display.shape[1] * 3, - QtGui.QImage.Format.Format_RGB888) - self.updateCurrentSplitImage.emit(qImage) - self.currentsplitimagefileLabel.setText(split_image_file) - - # Override values if they have been specified on the file - pause_from_filename = split_parser.pause_from_filename(split_image_file) - self.pause = self.pauseDoubleSpinBox.value() \ - if pause_from_filename is None \ - else pause_from_filename - threshold_from_filename = split_parser.threshold_from_filename(split_image_file) - self.similarity_threshold = self.similaritythresholdDoubleSpinBox.value() \ - if threshold_from_filename is None \ - else threshold_from_filename - self.currentsimilaritythresholdnumberLabel.setText(f"{self.similarity_threshold:.2f}") - - # Get delay for split, if any - self.split_delay = split_parser.delay_from_filename(split_image_file) + # Get split image + self.split_image = specific_image or self.split_images_and_loop_number[0 + self.split_image_number][0] + if self.split_image.bytes is not None: + # Set current split image in UI + split_image_display = cv2.cvtColor(self.split_image.bytes, cv2.COLOR_BGRA2RGBA) + qimage = QtGui.QImage(split_image_display.data, + split_image_display.shape[1], + split_image_display.shape[0], + split_image_display.shape[1] * split_image_display.shape[2], + QtGui.QImage.Format.Format_RGBA8888) + self.current_split_image.setPixmap(QtGui.QPixmap(qimage).scaled( + self.current_split_image.size(), + QtCore.Qt.AspectRatioMode.IgnoreAspectRatio)) + + self.current_split_image_file_label.setText(self.split_image.filename) + self.current_similarity_threshold_number_label.setText(f"{self.split_image.get_similarity_threshold(self):.2f}") # Set Image Loop # if not from_start_image: - loop_tuple = self.split_image_filenames_and_loop_number[self.split_image_number] - self.imageloopLabel.setText(f"Image Loop: {loop_tuple[1]}/{loop_tuple[2]}") + loop_tuple = self.split_images_and_loop_number[self.split_image_number] + self.image_loop_label.setText(f"Image Loop: {loop_tuple[1]}/{loop_tuple[0].loops}") else: - self.imageloopLabel.setText("Image Loop: 1/1") + self.image_loop_label.setText("Image Loop: N/A") # need to set split below threshold to false each time an image updates. self.split_below_threshold = False @@ -1111,10 +870,12 @@ def updateSplitImage(self, custom_image_file: str = "", from_start_image: bool = self.similarity = 0 self.highest_similarity = 0.001 - # exit safely when closing the window - def closeEvent(self, a0: Optional[QtGui.QCloseEvent] = None): - def exitProgram(): + """ + Exit safely when closing the window + """ + + def exit_program(): if a0 is not None: a0.accept() if self.is_auto_controlled: @@ -1128,9 +889,9 @@ def exitProgram(): # This also more gracefully exits LiveSplit # Users can still manually save their settings if a0 is None: - exitProgram() + exit_program() - if settings.haveSettingsChanged(self): + if settings.have_settings_changed(self): # Give a different warning if there was never a settings file that was loaded successfully, # and "save as" instead of "save". settings_file_name = "Untitled" \ @@ -1146,39 +907,37 @@ def exitProgram(): if warning is QMessageBox.StandardButton.Yes: # TODO: Don't close if user cancelled the save - self.saveSettingsAs() - exitProgram() + self.save_settings_as() + exit_program() if warning is QMessageBox.StandardButton.No: - exitProgram() + exit_program() if warning is QMessageBox.StandardButton.Cancel: a0.ignore() else: - exitProgram() + exit_program() def main(): + # Call to QApplication outside the try-except so we can show error messages app = QApplication(sys.argv) try: app.setWindowIcon(QtGui.QIcon(":/resources/icon.ico")) - main_window = AutoSplit() - main_window.show() - # Needs to be after main_window.show() to be shown over - if main_window.actionCheck_for_Updates_on_Open.isChecked(): - checkForUpdates(main_window, check_on_open=True) + AutoSplit() - # Kickoff the event loop every so often so we can handle KeyboardInterrupt (^C) - timer = QtCore.QTimer() - timer.timeout.connect(lambda: None) - timer.start(500) + if not FROZEN: + # Kickoff the event loop every so often so we can handle KeyboardInterrupt (^C) + timer = QtCore.QTimer() + timer.timeout.connect(lambda: None) + timer.start(500) exit_code = app.exec() - except Exception as exception: # pylint: disable=broad-except + except Exception as exception: # pylint: disable=broad-except # We really want to catch everything here + message = f"AutoSplit encountered an unrecoverable exception and will now close. {CREATE_NEW_ISSUE_MESSAGE}" # Print error to console if not running in executable - if getattr(sys, "frozen", False): - error_messages.exceptionTraceback( - f"AutoSplit encountered an unrecoverable exception and will now close. {CREATE_NEW_ISSUE_MESSAGE}", - exception) + if FROZEN: + error_messages.exception_traceback(message, exception) else: + print(message) traceback.print_exception(type(exception), exception, exception.__traceback__) sys.exit(1) @@ -1188,16 +947,5 @@ def main(): sys.exit(exit_code) -def excepthook(exceptionType: type[BaseException], exception: BaseException, _traceback: Optional[TracebackType]): - # Catch Keyboard Interrupts for a clean close - if exceptionType is KeyboardInterrupt: - sys.exit(0) - error_messages.exceptionTraceback( - "AutoSplit encountered an unhandled exception and will try to recover, " - f"however, there is no guarantee everything will work properly. {CREATE_NEW_ISSUE_MESSAGE}", - exception) - - if __name__ == "__main__": - sys.excepthook = excepthook main() diff --git a/src/AutoSplitImage.py b/src/AutoSplitImage.py new file mode 100644 index 00000000..bb066537 --- /dev/null +++ b/src/AutoSplitImage.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from enum import Enum +import os +from typing import Optional, Union, TYPE_CHECKING +if TYPE_CHECKING: + from AutoSplit import AutoSplit + +import cv2 +import numpy as np +from win32con import MAXBYTE +import error_messages +from compare import check_if_image_has_transparency, compare_histograms, compare_l2_norm, compare_phash + + +# Resize to these width and height so that FPS performance increases +COMPARISON_RESIZE_WIDTH = 320 +COMPARISON_RESIZE_HEIGHT = 240 +COMPARISON_RESIZE = (COMPARISON_RESIZE_WIDTH, COMPARISON_RESIZE_HEIGHT) + + +class ImageType(Enum): + SPLIT = 0 + RESET = 1 + START = 2 + + +class AutoSplitImage(): + path: str + filename: str + flags: int + loops: int + delay: float + image_type: ImageType + bytes: Optional[cv2.ndarray] = None + mask: Optional[cv2.ndarray] = None + # This value is internal, check for mask instead + _has_transparency: bool + # These values should be overriden by defaults if null, use getters instead + __pause_time: Optional[float] = None + __similarity_threshold: Optional[float] = None + + def get_pause_time(self, default: Union[AutoSplit, float]): + """ + Get image's pause time or fallback to the default value from spinbox + """ + default_value: float = default \ + if isinstance(default, float) \ + else default.pause_spinbox.value() + return default_value if self.__pause_time is None else self.__pause_time + + def get_similarity_threshold(self, default: Union[AutoSplit, float]): + """ + Get image's similarity threashold or fallback to the default value from spinbox + """ + default_value: float = default \ + if isinstance(default, float) \ + else default.similarity_threshold_spinbox.value() + return default_value if self.__similarity_threshold is None else self.__similarity_threshold + + def __init__(self, path: str): + self.path = path + self.filename = os.path.split(path)[-1].lower() + self.flags = flags_from_filename(self.filename) + self.loops = loop_from_filename(self.filename) + self.delay = delay_from_filename(self.filename) + self._pause_time = pause_from_filename(self.filename) + self.__similarity_threshold = threshold_from_filename(self.filename) + self.__read_image_bytes(path) + + if "start_auto_splitter" in self.filename: + self.image_type = ImageType.START + elif "reset" in self.filename: + self.image_type = ImageType.RESET + else: + self.image_type = ImageType.SPLIT + + def __read_image_bytes(self, path: str): + image = cv2.imread(path, cv2.IMREAD_UNCHANGED) + if image is None: + self.bytes = None + error_messages.image_type(path) + return + + image = cv2.resize(image, COMPARISON_RESIZE, interpolation=cv2.INTER_NEAREST) + self._has_transparency = check_if_image_has_transparency(image) + # If image has transparency, create a mask + if self._has_transparency: + # Create mask based on resized, nearest neighbor interpolated split image + lower = np.array([0, 0, 0, 1], dtype="uint8") + upper = np.array([MAXBYTE, MAXBYTE, MAXBYTE, MAXBYTE], dtype="uint8") + self.mask = cv2.inRange(image, lower, upper) + # Add Alpha channel if missing + elif image.shape[2] == 3: + image = cv2.cvtColor(image, cv2.COLOR_BGR2BGRA) + + self.bytes = image + + def check_flag(self, flag: int): + return self.flags & flag == flag + + def compare_with_capture( + self, + comparison: Union[AutoSplit, int], + capture: Optional[cv2.ndarray] + ): + """ + Compare image with capture using comparison method from combobox + """ + comparison_method: int = comparison \ + if isinstance(comparison, int) \ + else comparison.comparison_method_combobox.currentIndex() + + if self.bytes is None or capture is None: + return 0.0 + if comparison_method == 0: + return compare_l2_norm(self.bytes, capture, self.mask) + if comparison_method == 1: + return compare_histograms(self.bytes, capture, self.mask) + if comparison_method == 2: + return compare_phash(self.bytes, capture, self.mask) + return 0.0 + + +from split_parser import delay_from_filename, flags_from_filename, loop_from_filename, pause_from_filename, \ + threshold_from_filename diff --git a/src/capture_windows.py b/src/capture_windows.py index 2f6fe1aa..1da044ae 100644 --- a/src/capture_windows.py +++ b/src/capture_windows.py @@ -30,7 +30,7 @@ class Rect(ctypes.wintypes.RECT): bottom: int = -1 # type: ignore -def capture_region(hwnd: int, selection: Rect, forcePrintWindow: bool): +def capture_region(hwnd: int, selection: Rect, force_print_window: bool): """ Captures an image of the region for a window matching the given parameters of the bounding box @@ -42,38 +42,38 @@ def capture_region(hwnd: int, selection: Rect, forcePrintWindow: bool): # Windows 11 has some jank, and we're not ready to fully investigate it # for now let's ensure it works at the cost of performance - is_accelerated_window = forcePrintWindow or is_windows_11 or accelerated_windows.get(hwnd) + is_accelerated_window = force_print_window or is_windows_11 or accelerated_windows.get(hwnd) # The window type is not yet known, let's find out! if is_accelerated_window is None: # We need to get the image at least once to check if it's full black - image = __get_image(hwnd, selection, False) + image = __get_capture_image(hwnd, selection, False) # TODO check for first non-black pixel, no need to iterate through the whole image is_accelerated_window = not np.count_nonzero(image) accelerated_windows[hwnd] = is_accelerated_window - return __get_image(hwnd, selection, True) if is_accelerated_window else image + return __get_capture_image(hwnd, selection, True) if is_accelerated_window else image - return __get_image(hwnd, selection, is_accelerated_window) + return __get_capture_image(hwnd, selection, is_accelerated_window) -def __get_image(hwnd: int, selection: Rect, print_window: bool = False): +def __get_capture_image(hwnd: int, selection: Rect, print_window: bool = False): width: int = selection.right - selection.left height: int = selection.bottom - selection.top # If the window closes while it's being manipulated, it could cause a crash try: - windowDC: int = win32gui.GetWindowDC(hwnd) + window_dc: int = win32gui.GetWindowDC(hwnd) # https://github.com/kaluluosi/pywin32-stubs/issues/6 - dcObject: PyCDC = win32ui.CreateDCFromHandle(windowDC) # type: ignore + dc_object: PyCDC = win32ui.CreateDCFromHandle(window_dc) # type: ignore # Causes a 10-15x performance drop. But allows recording hardware accelerated windows if print_window: - ctypes.windll.user32.PrintWindow(hwnd, dcObject.GetSafeHdc(), PW_RENDERFULLCONTENT) + ctypes.windll.user32.PrintWindow(hwnd, dc_object.GetSafeHdc(), PW_RENDERFULLCONTENT) - compatibleDC = cast(PyCDC, dcObject.CreateCompatibleDC()) + compatible_dc = cast(PyCDC, dc_object.CreateCompatibleDC()) bitmap: PyCBitmap = win32ui.CreateBitmap() - bitmap.CreateCompatibleBitmap(dcObject, width, height) - compatibleDC.SelectObject(bitmap) - compatibleDC.BitBlt((0, 0), (width, height), dcObject, (selection.left, selection.top), win32con.SRCCOPY) + bitmap.CreateCompatibleBitmap(dc_object, width, height) + compatible_dc.SelectObject(bitmap) + compatible_dc.BitBlt((0, 0), (width, height), dc_object, (selection.left, selection.top), win32con.SRCCOPY) # https://github.com/kaluluosi/pywin32-stubs/issues/5 # pylint: disable=no-member except (win32ui.error, pywintypes.error): # type: ignore @@ -83,9 +83,9 @@ def __get_image(hwnd: int, selection: Rect, print_window: bool = False): image.shape = (height, width, 4) try: - dcObject.DeleteDC() - compatibleDC.DeleteDC() - win32gui.ReleaseDC(hwnd, windowDC) + dc_object.DeleteDC() + compatible_dc.DeleteDC() + win32gui.ReleaseDC(hwnd, window_dc) win32gui.DeleteObject(bitmap.GetHandle()) # https://github.com/kaluluosi/pywin32-stubs/issues/5 except win32ui.error: # type: ignore diff --git a/src/compare.py b/src/compare.py index 7faae841..9a5a9e1c 100644 --- a/src/compare.py +++ b/src/compare.py @@ -8,28 +8,11 @@ MAXRANGE = MAXBYTE + 1 channels = [0, 1, 2] -histSize = [8, 8, 8] +histogram_size = [8, 8, 8] ranges = [0, MAXRANGE, 0, MAXRANGE, 0, MAXRANGE] -def compareImage( - comparisonMethod: int, - image: Optional[cv2.ndarray], - capture: Optional[cv2.ndarray], - mask: Optional[cv2.ndarray] = None -): - if image is None or capture is None: - return 0.0 - if comparisonMethod == 0: - return compare_l2_norm(image, capture, mask) - if comparisonMethod == 1: - return compare_histograms(image, capture, mask) - if comparisonMethod == 2: - return compare_phash(image, capture, mask) - return 0.0 - - -def compare_histograms(source: cv2.ndarray, capture: cv2.ndarray, mask: Optional[cv2.ndarray] = None) -> float: +def compare_histograms(source: cv2.ndarray, capture: cv2.ndarray, mask: Optional[cv2.ndarray] = None): """ Compares two images by calculating their histograms, normalizing them, and then comparing them using Bhattacharyya distance. @@ -40,8 +23,8 @@ def compare_histograms(source: cv2.ndarray, capture: cv2.ndarray, mask: Optional @return: The similarity between the histograms as a number 0 to 1. """ - source_hist = cv2.calcHist([source], channels, mask, histSize, ranges) - capture_hist = cv2.calcHist([capture], channels, mask, histSize, ranges) + source_hist = cv2.calcHist([source], channels, mask, histogram_size, ranges) + capture_hist = cv2.calcHist([capture], channels, mask, histogram_size, ranges) cv2.normalize(source_hist, source_hist) cv2.normalize(capture_hist, capture_hist) @@ -67,11 +50,11 @@ def compare_l2_norm(source: cv2.ndarray, capture: cv2.ndarray, mask: Optional[cv else (3 * np.count_nonzero(mask) * MAXBYTE * MAXBYTE) ** 0.5 if not max_error: - return 0 + return 0.0 return 1 - (error / max_error) -def compare_template(source: cv2.ndarray, capture: cv2.ndarray, mask: Optional[cv2.ndarray] = None) -> float: +def compare_template(source: cv2.ndarray, capture: cv2.ndarray, mask: Optional[cv2.ndarray] = None): """ Checks if the source is located within the capture by using the sum of square differences. The mask is used to search for non-rectangular images within the capture @@ -95,7 +78,7 @@ def compare_template(source: cv2.ndarray, capture: cv2.ndarray, mask: Optional[c return 1 - (min_val / max_error) -def compare_phash(source: cv2.ndarray, capture: cv2.ndarray, mask: Optional[cv2.ndarray] = None) -> float: +def compare_phash(source: cv2.ndarray, capture: cv2.ndarray, mask: Optional[cv2.ndarray] = None): """ Compares the pHash of the two given images and returns the similarity between the two. @@ -110,7 +93,7 @@ def compare_phash(source: cv2.ndarray, capture: cv2.ndarray, mask: Optional[cv2. # each of the images. As a result of this, this function is not going to be very # helpful for large masks as the images when shrinked down to 8x8 will mostly be # the same - if mask: + if mask is not None: source = cv2.bitwise_and(source, source, mask=mask) capture = cv2.bitwise_and(capture, capture, mask=mask) @@ -118,15 +101,15 @@ def compare_phash(source: cv2.ndarray, capture: cv2.ndarray, mask: Optional[cv2. capture_hash = imagehash.phash(Image.fromarray(capture)) hash_diff = source_hash - capture_hash if not hash_diff: - return 0 + return 0.0 return 1 - (hash_diff / 64.0) -def checkIfImageHasTransparency(image: cv2.ndarray) -> bool: +def check_if_image_has_transparency(image: cv2.ndarray): # Check if there's a transparency channel (4th channel) and if at least one pixel is transparent (< 255) if image.shape[2] != 4: return False - mean = np.mean(image[:, :, 3]) + mean: float = np.mean(image[:, :, 3]) if mean == 0: # Non-transparent images code path is usually faster and simpler, so let's return that return False diff --git a/src/error_messages.py b/src/error_messages.py index bad19bd9..c8a674e2 100644 --- a/src/error_messages.py +++ b/src/error_messages.py @@ -3,107 +3,107 @@ from PyQt6 import QtCore, QtWidgets -def setTextMessage(message: str, details: str = ""): - messageBox = QtWidgets.QMessageBox() - messageBox.setWindowTitle("Error") - messageBox.setTextFormat(QtCore.Qt.TextFormat.RichText) - messageBox.setText(message) +def set_text_message(message: str, details: str = ""): + message_box = QtWidgets.QMessageBox() + message_box.setWindowTitle("Error") + message_box.setTextFormat(QtCore.Qt.TextFormat.RichText) + message_box.setText(message) if details: - messageBox.setDetailedText(details) - for button in messageBox.buttons(): - if messageBox.buttonRole(button) == QtWidgets.QMessageBox.ButtonRole.ActionRole: + message_box.setDetailedText(details) + for button in message_box.buttons(): + if message_box.buttonRole(button) == QtWidgets.QMessageBox.ButtonRole.ActionRole: button.click() break - messageBox.exec() + message_box.exec() -def splitImageDirectoryError(): - setTextMessage("No split image folder is selected.") +def split_image_directory(): + set_text_message("No split image folder is selected.") -def splitImageDirectoryNotFoundError(): - setTextMessage("The Split Image Folder does not exist.") +def split_image_directory_not_found(): + set_text_message("The Split Image Folder does not exist.") -def splitImageDirectoryEmpty(): - setTextMessage("The Split Image Folder is empty.") +def split_image_directory_empty(): + set_text_message("The Split Image Folder is empty.") -def imageTypeError(image: str): - setTextMessage(f'"{image}" is not a valid image file, does not exist, ' - "or the full image file path contains a special character.") +def image_type(image: str): + set_text_message(f'"{image}" is not a valid image file, does not exist, ' + "or the full image file path contains a special character.") -def regionError(): - setTextMessage("No region is selected or the Capture Region window is not open. " - "Select a region or load settings while the Capture Region window is open.") +def region(): + set_text_message("No region is selected or the Capture Region window is not open. " + "Select a region or load settings while the Capture Region window is open.") -def splitHotkeyError(): - setTextMessage("No split hotkey has been set.") +def split_hotkey(): + set_text_message("No split hotkey has been set.") -def pauseHotkeyError(): - setTextMessage("Your split image folder contains an image filename with a pause flag {p}, " - "but no pause hotkey is set.") +def pause_hotkey(): + set_text_message("Your split image folder contains an image filename with a pause flag {p}, " + "but no pause hotkey is set.") -def alignRegionImageTypeError(): - setTextMessage("File not a valid image file") +def align_region_image_type(): + set_text_message("File not a valid image file") -def alignmentNotMatchedError(): - setTextMessage("No area in capture region matched reference image. Alignment failed.") +def alignment_not_matched(): + set_text_message("No area in capture region matched reference image. Alignment failed.") -def noKeywordImageError(keyword: str): - setTextMessage(f'Your split image folder does not contain an image with the keyword "{keyword}".') +def no_keyword_image(keyword: str): + set_text_message(f'Your split image folder does not contain an image with the keyword "{keyword}".') -def multipleKeywordImagesError(keyword: str): - setTextMessage(f'Only one image with the keyword "{keyword}" is allowed.') +def multiple_keyword_images(keyword: str): + set_text_message(f'Only one image with the keyword "{keyword}" is allowed.') -def resetHotkeyError(): - setTextMessage("Your split image folder contains a reset image, but no reset hotkey is set.") +def reset_hotkey(): + set_text_message("Your split image folder contains a reset image, but no reset hotkey is set.") -def dummySplitsError(): - setTextMessage("Group dummy splits when undoing/skipping cannot be checked " - "if any split image has a loop parameter greater than 1") +def dummy_splits(): + set_text_message("Group dummy splits when undoing/skipping cannot be checked " + "if any split image has a loop parameter greater than 1") -def oldVersionSettingsFileError(): - setTextMessage("Old version settings file detected. This version allows settings files from v1.3 and above.") +def old_version_settings_file(): + set_text_message("Old version settings file detected. This version allows settings files from v1.3 and above.") -def invalidSettingsError(): - setTextMessage("Invalid settings file.") +def invalid_settings(): + set_text_message("Invalid settings file.") -def noSettingsFileOnOpenError(): - setTextMessage("No settings file found. One can be loaded on open if placed in the same folder as AutoSplit.exe") +def no_settings_file_on_open(): + set_text_message("No settings file found. One can be loaded on open if placed in the same folder as AutoSplit.exe") -def tooManySettingsFilesOnOpenError(): - setTextMessage("Too many settings files found. " - "Only one can be loaded on open if placed in the same folder as AutoSplit.exe") +def too_many_settings_files_on_open(): + set_text_message("Too many settings files found. " + "Only one can be loaded on open if placed in the same folder as AutoSplit.exe") -def checkForUpdatesError(): - setTextMessage("An error occurred while attempting to check for updates. Please check your connection.") +def check_for_updates(): + set_text_message("An error occurred while attempting to check for updates. Please check your connection.") -def loadStartImageError(): - setTextMessage("Start Image found, but cannot be loaded unless Start, Reset, and Pause hotkeys are set. " - "Please set these hotkeys, and then click the Reload Start Image button.") +def load_start_image(): + set_text_message("Start Image found, but cannot be loaded unless Start, Reset, and Pause hotkeys are set. " + "Please set these hotkeys, and then click the Reload Start Image button.") -def stdinLostError(): - setTextMessage("stdin not supported or lost, external control like LiveSplit integration will not work.") +def stdin_lost(): + set_text_message("stdin not supported or lost, external control like LiveSplit integration will not work.") -def exceptionTraceback(message: str, exception: BaseException): - setTextMessage( +def exception_traceback(message: str, exception: BaseException): + set_text_message( message, "\n".join(traceback.format_exception(None, exception, exception.__traceback__))) diff --git a/src/gen/about.pyi b/src/gen/about.pyi deleted file mode 100644 index c686e0ea..00000000 --- a/src/gen/about.pyi +++ /dev/null @@ -1,6 +0,0 @@ -from PyQt6.QtWidgets import QWidget - - -class Ui_aboutAutoSplitWidget(): - def setupUi(self, aboutAutoSplitWidget: QWidget) -> None: - ... diff --git a/src/gen/design.pyi b/src/gen/design.pyi deleted file mode 100644 index a7a38ee1..00000000 --- a/src/gen/design.pyi +++ /dev/null @@ -1,9 +0,0 @@ -from PyQt6.QtGui import QAction -from PyQt6.QtWidgets import QMainWindow - - -class Ui_MainWindow(): - actionCheck_for_Updates_on_Open: QAction - - def setupUi(self, MainWindow: QMainWindow) -> None: - ... diff --git a/src/gen/update_checker.pyi b/src/gen/update_checker.pyi deleted file mode 100644 index 027afe46..00000000 --- a/src/gen/update_checker.pyi +++ /dev/null @@ -1,6 +0,0 @@ -from PyQt6.QtWidgets import QWidget - - -class Ui_UpdateChecker(): - def setupUi(self, UpdateChecker: QWidget) -> None: - ... diff --git a/src/hotkeys.py b/src/hotkeys.py index b586272b..e3d60553 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -8,34 +8,37 @@ from keyboard._keyboard_event import KeyboardEvent, KEY_DOWN import keyboard # https://github.com/boppreh/keyboard/issues/505 import pyautogui # https://github.com/asweigart/pyautogui/issues/645 -# While not usually recommended, we don't manipulate the mouse, and we don't want the extra delay +# While not usually recommended, we don'thread manipulate the mouse, and we don'thread want the extra delay pyautogui.FAILSAFE = False +SET_HOTKEY_TEXT = "Set Hotkey" +PRESS_A_KEY_TEXT = "Press a key..." -# do all of these after you click "set hotkey" but before you type the hotkey. -def beforeSettingHotkey(autosplit: AutoSplit): - autosplit.startautosplitterButton.setEnabled(False) - autosplit.setsplithotkeyButton.setEnabled(False) - autosplit.setresethotkeyButton.setEnabled(False) - autosplit.setskipsplithotkeyButton.setEnabled(False) - autosplit.setundosplithotkeyButton.setEnabled(False) - autosplit.setpausehotkeyButton.setEnabled(False) + +# do all of these after you click "Set Hotkey" but before you type the hotkey. +def before_setting_hotkey(autosplit: AutoSplit): + autosplit.start_auto_splitter_button.setEnabled(False) + autosplit.set_split_hotkey_button.setEnabled(False) + autosplit.set_reset_hotkey_button.setEnabled(False) + autosplit.set_skip_split_hotkey_button.setEnabled(False) + autosplit.set_undo_split_hotkey_button.setEnabled(False) + autosplit.set_pause_hotkey_button.setEnabled(False) # do all of these things after you set a hotkey. a signal connects to this because # changing GUI stuff in the hotkey thread was causing problems -def afterSettingHotkey(autosplit: AutoSplit): - autosplit.setsplithotkeyButton.setText("Set Hotkey") - autosplit.setresethotkeyButton.setText("Set Hotkey") - autosplit.setskipsplithotkeyButton.setText("Set Hotkey") - autosplit.setundosplithotkeyButton.setText("Set Hotkey") - autosplit.setpausehotkeyButton.setText("Set Hotkey") - autosplit.startautosplitterButton.setEnabled(True) - autosplit.setsplithotkeyButton.setEnabled(True) - autosplit.setresethotkeyButton.setEnabled(True) - autosplit.setskipsplithotkeyButton.setEnabled(True) - autosplit.setundosplithotkeyButton.setEnabled(True) - autosplit.setpausehotkeyButton.setEnabled(True) +def after_setting_hotkey(autosplit: AutoSplit): + autosplit.set_split_hotkey_button.setText(SET_HOTKEY_TEXT) + autosplit.set_reset_hotkey_button.setText(SET_HOTKEY_TEXT) + autosplit.set_skip_split_hotkey_button.setText(SET_HOTKEY_TEXT) + autosplit.set_undo_split_hotkey_button.setText(SET_HOTKEY_TEXT) + autosplit.set_pause_hotkey_button.setText(SET_HOTKEY_TEXT) + autosplit.start_auto_splitter_button.setEnabled(True) + autosplit.set_split_hotkey_button.setEnabled(True) + autosplit.set_reset_hotkey_button.setEnabled(True) + autosplit.set_skip_split_hotkey_button.setEnabled(True) + autosplit.set_undo_split_hotkey_button.setEnabled(True) + autosplit.set_pause_hotkey_button.setEnabled(True) def is_digit(key: Optional[str]): @@ -51,17 +54,27 @@ def send_command(autosplit: AutoSplit, command: str): if autosplit.is_auto_controlled: print(command, flush=True) elif command in {"split", "start"}: - _send_hotkey(autosplit.splitLineEdit.text()) + _send_hotkey(autosplit.split_input.text()) elif command == "pause": - _send_hotkey(autosplit.pausehotkeyLineEdit.text()) + _send_hotkey(autosplit.pause_hotkey_input.text()) elif command == "reset": - _send_hotkey(autosplit.resetLineEdit.text()) + _send_hotkey(autosplit.reset_input.text()) else: raise KeyError(f"'{command}' is not a valid LiveSplit.AutoSplitIntegration command") -# Supports sending the appropriate scan code for all the special cases +def _unhook(hotkey: Optional[Callable[[], None]]): + try: + if hotkey: + keyboard.unhook_key(hotkey) + except (AttributeError, KeyError, ValueError): + pass + + def _send_hotkey(key_or_scan_code: Union[int, str]): + """ + Supports sending the appropriate scan code for all the special cases + """ if not key_or_scan_code: return @@ -80,7 +93,7 @@ def __validate_keypad(expected_key: str, keyboard_event: KeyboardEvent) -> bool: # Prevent "(keypad)delete", "(keypad)./decimal" and "del" from triggering each other # as well as "." and "(keypad)./decimal" if keyboard_event.scan_code in {83, 52}: - # TODO: "del" won't work with "(keypad)delete" if localized in non-english (ie: "suppr" in french) + # TODO: "del" won'thread work with "(keypad)delete" if localized in non-english (ie: "suppr" in french) return expected_key == keyboard_event.name # Prevent "action keys" from triggering "keypad keys" if keyboard_event.name and is_digit(keyboard_event.name[-1]): @@ -101,9 +114,9 @@ def __validate_keypad(expected_key: str, keyboard_event: KeyboardEvent) -> bool: # We're doing the check here instead of saving the key code because it'll # cause issues with save files and the non-keypad shared keys are localized -# while the keypad ones aren't. +# while the keypad ones aren'thread. -# Since we reuse the key string we set to send to LiveSplit, we can't use fake names like "num home". +# Since we reuse the key string we set to send to LiveSplit, we can'thread use fake names like "num home". # We're also trying to achieve the same hotkey behaviour as LiveSplit has. def _hotkey_action(keyboard_event: KeyboardEvent, key_name: str, action: Callable[[], None]): if keyboard_event.event_type == KEY_DOWN and __validate_keypad(key_name, keyboard_event): @@ -117,40 +130,28 @@ def __get_key_name(keyboard_event: KeyboardEvent): def __is_key_already_set(autosplit: AutoSplit, key_name: str): - return key_name in (autosplit.splitLineEdit.text(), - autosplit.resetLineEdit.text(), - autosplit.skipsplitLineEdit.text(), - autosplit.undosplitLineEdit.text(), - autosplit.pausehotkeyLineEdit.text()) + return key_name in (autosplit.split_input.text(), + autosplit.reset_input.text(), + autosplit.skip_split_input.text(), + autosplit.undo_split_input.text(), + autosplit.pause_hotkey_input.text()) # --------------------HOTKEYS-------------------------- # TODO: Refactor to de-duplicate all this code, including settings_file.py # Going to comment on one func, and others will be similar. -def setSplitHotkey(autosplit: AutoSplit): - autosplit.setsplithotkeyButton.setText("Press a key...") +def set_split_hotkey(autosplit: AutoSplit, preselected_key: str = ""): + autosplit.set_split_hotkey_button.setText(PRESS_A_KEY_TEXT) # disable some buttons - beforeSettingHotkey(autosplit) + before_setting_hotkey(autosplit) # new thread points to callback. this thread is needed or GUI will freeze # while the program waits for user input on the hotkey - def callback(hotkey: Callable[[], None]): - # try to remove the previously set hotkey if there is one. - try: - keyboard.unhook_key(hotkey) - # KeyError was coming up when loading the program and - # the lineEdit area was empty (no hotkey set), then you - # set one, reload the setting once back to blank works, - # but if you click reload settings again, it errors - # we can just have it pass, but don't want to throw in - # generic exception here in case another one of these - # pops up somewhere. - except (AttributeError, KeyError): - pass - + def callback(): + # use the selected key OR # wait until user presses the hotkey, then keyboard module reads the input - key_name = __get_key_name(keyboard.read_event(True)) + key_name = preselected_key if preselected_key else __get_key_name(keyboard.read_event(True)) try: # If the key the user presses is equal to itself or another hotkey already set, # this causes issues. so here, it catches that, and will make no changes to the hotkey. @@ -159,17 +160,17 @@ def callback(hotkey: Callable[[], None]): # keyboard module allows you to hit multiple keys for a hotkey. they are joined # together by +. If user hits two keys at the same time, make no changes to the - # hotkey. A try and except is needed if a hotkey hasn't been set yet. I'm not + # hotkey. A try and except is needed if a hotkey hasn'thread been set yet. I'm not # allowing for these multiple-key hotkeys because it can cause crashes, and # not many people are going to really use or need this. if __is_key_already_set(autosplit, key_name) or (key_name != "+" and "+" in key_name): - autosplit.afterSettingHotkeySignal.emit() + autosplit.after_setting_hotkey_signal.emit() return except AttributeError: - autosplit.afterSettingHotkeySignal.emit() + autosplit.after_setting_hotkey_signal.emit() return - # add the key as the hotkey, set the text into the LineEdit, set it as old_xxx_key, + # add the key as the hotkey, set the text into the _input, set it as old_xxx_key, # then emite a signal to re-enable some buttons and change some text in GUI. # We need to inspect the event to know if it comes from numpad because of _canonial_names. @@ -178,134 +179,115 @@ def callback(hotkey: Callable[[], None]): # See: https://github.com/boppreh/keyboard/issues/216#issuecomment-431999553 autosplit.split_hotkey = keyboard.hook_key( key_name, - lambda e: _hotkey_action(e, key_name, autosplit.startAutoSplitter)) - autosplit.splitLineEdit.setText(key_name) - autosplit.split_key = key_name - autosplit.afterSettingHotkeySignal.emit() + lambda error: _hotkey_action(error, key_name, autosplit.start_auto_splitter)) + autosplit.split_input.setText(key_name) + autosplit.after_setting_hotkey_signal.emit() - t = threading.Thread(target=callback, args=(autosplit.split_hotkey,)) - t.start() + # try to remove the previously set hotkey if there is one. + _unhook(autosplit.split_hotkey) + thread = threading.Thread(target=callback) + thread.start() -def setResetHotkey(autosplit: AutoSplit): - autosplit.setresethotkeyButton.setText("Press a key...") - beforeSettingHotkey(autosplit) - - def callback(hotkey: Callable[[], None]): - try: - keyboard.unhook_key(hotkey) - except (AttributeError, KeyError): - pass +def set_reset_hotkey(autosplit: AutoSplit, preselected_key: str = ""): + autosplit.set_reset_hotkey_button.setText(PRESS_A_KEY_TEXT) + before_setting_hotkey(autosplit) - key_name = __get_key_name(keyboard.read_event(True)) + def callback(): + key_name = preselected_key if preselected_key else __get_key_name(keyboard.read_event(True)) try: if __is_key_already_set(autosplit, key_name) or (key_name != "+" and "+" in key_name): - autosplit.afterSettingHotkeySignal.emit() + autosplit.after_setting_hotkey_signal.emit() return except AttributeError: - autosplit.afterSettingHotkeySignal.emit() + autosplit.after_setting_hotkey_signal.emit() return autosplit.reset_hotkey = keyboard.hook_key( key_name, - lambda e: _hotkey_action(e, key_name, autosplit.startReset)) - autosplit.resetLineEdit.setText(key_name) - autosplit.reset_key = key_name - autosplit.afterSettingHotkeySignal.emit() + lambda error: _hotkey_action(error, key_name, autosplit.reset_signal.emit)) + autosplit.reset_input.setText(key_name) + autosplit.after_setting_hotkey_signal.emit() - t = threading.Thread(target=callback, args=(autosplit.reset_hotkey,)) - t.start() + _unhook(autosplit.reset_hotkey) + thread = threading.Thread(target=callback) + thread.start() -def setSkipSplitHotkey(autosplit: AutoSplit): - autosplit.setskipsplithotkeyButton.setText("Press a key...") - beforeSettingHotkey(autosplit) +def set_skip_split_hotkey(autosplit: AutoSplit, preselected_key: str = ""): + autosplit.set_skip_split_hotkey_button.setText(PRESS_A_KEY_TEXT) + before_setting_hotkey(autosplit) - def callback(hotkey: Callable[[], None]): - try: - keyboard.unhook_key(hotkey) - except (AttributeError, KeyError): - pass - - key_name = __get_key_name(keyboard.read_event(True)) + def callback(): + key_name = preselected_key if preselected_key else __get_key_name(keyboard.read_event(True)) try: if __is_key_already_set(autosplit, key_name) or (key_name != "+" and "+" in key_name): - autosplit.afterSettingHotkeySignal.emit() + autosplit.after_setting_hotkey_signal.emit() return except AttributeError: - autosplit.afterSettingHotkeySignal.emit() + autosplit.after_setting_hotkey_signal.emit() return autosplit.skip_split_hotkey = keyboard.hook_key( key_name, - lambda e: _hotkey_action(e, key_name, autosplit.startSkipSplit)) - autosplit.skipsplitLineEdit.setText(key_name) - autosplit.skip_split_key = key_name - autosplit.afterSettingHotkeySignal.emit() - - t = threading.Thread(target=callback, args=(autosplit.skip_split_hotkey,)) - t.start() + lambda error: _hotkey_action(error, key_name, autosplit.skip_split_signal.emit)) + autosplit.skip_split_input.setText(key_name) + autosplit.after_setting_hotkey_signal.emit() + _unhook(autosplit.skip_split_hotkey) + thread = threading.Thread(target=callback) + thread.start() -def setUndoSplitHotkey(autosplit: AutoSplit): - autosplit.setundosplithotkeyButton.setText("Press a key...") - beforeSettingHotkey(autosplit) - def callback(hotkey: Callable[[], None]): - try: - keyboard.unhook_key(hotkey) - except (AttributeError, KeyError): - pass +def set_undo_split_hotkey(autosplit: AutoSplit, preselected_key: str = ""): + autosplit.set_undo_split_hotkey_button.setText(PRESS_A_KEY_TEXT) + before_setting_hotkey(autosplit) - key_name = __get_key_name(keyboard.read_event(True)) + def callback(): + key_name = preselected_key if preselected_key else __get_key_name(keyboard.read_event(True)) try: if __is_key_already_set(autosplit, key_name) or (key_name != "+" and "+" in key_name): - autosplit.afterSettingHotkeySignal.emit() + autosplit.after_setting_hotkey_signal.emit() return except AttributeError: - autosplit.afterSettingHotkeySignal.emit() + autosplit.after_setting_hotkey_signal.emit() return autosplit.undo_split_hotkey = keyboard.hook_key( key_name, - lambda e: _hotkey_action(e, key_name, autosplit.startUndoSplit)) - autosplit.undosplitLineEdit.setText(key_name) - autosplit.undo_split_key = key_name - autosplit.afterSettingHotkeySignal.emit() - - t = threading.Thread(target=callback, args=(autosplit.undo_split_hotkey,)) - t.start() + lambda error: _hotkey_action(error, key_name, autosplit.undo_split_signal.emit)) + autosplit.undo_split_input.setText(key_name) + autosplit.after_setting_hotkey_signal.emit() + _unhook(autosplit.undo_split_hotkey) + thread = threading.Thread(target=callback) + thread.start() -def setPauseHotkey(autosplit: AutoSplit): - autosplit.setpausehotkeyButton.setText("Press a key...") - beforeSettingHotkey(autosplit) - def callback(hotkey: Callable[[], None]): - try: - keyboard.unhook_key(hotkey) - except (AttributeError, KeyError): - pass +def set_pause_hotkey(autosplit: AutoSplit, preselected_key: str = ""): + autosplit.set_pause_hotkey_button.setText(PRESS_A_KEY_TEXT) + before_setting_hotkey(autosplit) - key_name = __get_key_name(keyboard.read_event(True)) + def callback(): + key_name = preselected_key if preselected_key else __get_key_name(keyboard.read_event(True)) try: if __is_key_already_set(autosplit, key_name) or (key_name != "+" and "+" in key_name): - autosplit.afterSettingHotkeySignal.emit() + autosplit.after_setting_hotkey_signal.emit() return except AttributeError: - autosplit.afterSettingHotkeySignal.emit() + autosplit.after_setting_hotkey_signal.emit() return autosplit.pause_hotkey = keyboard.hook_key( key_name, - lambda e: _hotkey_action(e, key_name, autosplit.startPause)) - autosplit.pausehotkeyLineEdit.setText(key_name) - autosplit.undo_split_key = key_name - autosplit.afterSettingHotkeySignal.emit() + lambda error: _hotkey_action(error, key_name, autosplit.pause_signal.emit)) + autosplit.pause_hotkey_input.setText(key_name) + autosplit.after_setting_hotkey_signal.emit() - t = threading.Thread(target=callback, args=(autosplit.pause_hotkey,)) - t.start() + _unhook(autosplit.pause_hotkey) + thread = threading.Thread(target=callback) + thread.start() diff --git a/src/menu_bar.py b/src/menu_bar.py index cb156f24..6acfdc9e 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -13,64 +13,63 @@ from requests.exceptions import RequestException import error_messages -import settings_file +import settings_file as settings from gen import about, design, resources_rc, update_checker # noqa: F401 - # AutoSplit Version number VERSION = "1.6.1" # About Window -class __AboutWidget(QtWidgets.QWidget, about.Ui_aboutAutoSplitWidget): +class __AboutWidget(QtWidgets.QWidget, about.Ui_AboutAutoSplitWidget): def __init__(self): super().__init__() self.setupUi(self) - self.createdbyLabel.setOpenExternalLinks(True) - self.donatebuttonLabel.setOpenExternalLinks(True) - self.versionLabel.setText(f"Version: {VERSION}") + self.created_by_label.setOpenExternalLinks(True) + self.donate_button_label.setOpenExternalLinks(True) + self.version_label.setText(f"Version: {VERSION}") self.show() -def open_about(self: AutoSplit): - self.AboutWidget = __AboutWidget() +def open_about(autosplit: AutoSplit): + autosplit.AboutWidget = __AboutWidget() class __UpdateCheckerWidget(QtWidgets.QWidget, update_checker.Ui_UpdateChecker): def __init__(self, latest_version: str, design_window: design.Ui_MainWindow, check_on_open: bool = False): super().__init__() self.setupUi(self) - self.labelCurrentVersionNumber.setText(VERSION) - self.labelLatestVersionNumber.setText(latest_version) - self.pushButtonLeft.clicked.connect(self.openUpdate) - self.checkBoxDoNotAskMeAgain.stateChanged.connect(self.doNotAskMeAgainStateChanged) + self.current_version_number_label.setText(VERSION) + self.latest_version_number_label.setText(latest_version) + self.left_button.clicked.connect(self.open_update) + self.do_not_ask_again_checkbox.stateChanged.connect(self.do_not_ask_me_again_state_changed) self.design_window = design_window if version.parse(latest_version) > version.parse(VERSION): - self.checkBoxDoNotAskMeAgain.setVisible(check_on_open) + self.do_not_ask_again_checkbox.setVisible(check_on_open) self.show() elif not check_on_open: - self.labelUpdateStatus.setText("You are on the latest AutoSplit version.") - self.labelGoToDownload.setVisible(False) - self.pushButtonLeft.setVisible(False) - self.pushButtonRight.setText("OK") - self.checkBoxDoNotAskMeAgain.setVisible(False) + self.update_status_label.setText("You are on the latest AutoSplit version.") + self.go_to_download_label.setVisible(False) + self.left_button.setVisible(False) + self.right_button.setText("OK") + self.do_not_ask_again_checkbox.setVisible(False) self.show() - def openUpdate(self): + def open_update(self): os.system('start "" https://github.com/Toufool/Auto-Split/releases/latest') self.close() - def doNotAskMeAgainStateChanged(self): - settings_file.set_check_for_updates_on_open( + def do_not_ask_me_again_state_changed(self): + settings.set_check_for_updates_on_open( self.design_window, - self.checkBoxDoNotAskMeAgain.isChecked()) + self.do_not_ask_again_checkbox.isChecked()) def open_update_checker(autosplit: AutoSplit, latest_version: str, check_on_open: bool): autosplit.UpdateCheckerWidget = __UpdateCheckerWidget(latest_version, autosplit, check_on_open) -def viewHelp(): +def view_help(): os.system('start "" https://github.com/Toufool/Auto-Split#tutorial') @@ -82,14 +81,14 @@ def __init__(self, autosplit: AutoSplit, check_on_open: bool): def run(self): try: - response = requests.get("https://duckduckgo.com/?q=pyright+generate+stub+file&t=opera&ia=web") + response = requests.get("https://api.github.com/repos/Toufool/Auto-Split/releases/latest") latest_version = response.json()["name"].split("v")[1] - self.autosplit.updateCheckerWidgetSignal.emit(latest_version, self.check_on_open) + self.autosplit.update_checker_widget_signal.emit(latest_version, self.check_on_open) except (RequestException, KeyError, JSONDecodeError): if not self.check_on_open: - self.autosplit.showErrorSignal.emit(error_messages.checkForUpdatesError) + self.autosplit.show_error_signal.emit(error_messages.check_for_updates) -def checkForUpdates(autosplit: AutoSplit, check_on_open: bool = False): +def check_for_updates(autosplit: AutoSplit, check_on_open: bool = False): autosplit.CheckForUpdatesThread = __CheckForUpdatesThread(autosplit, check_on_open) autosplit.CheckForUpdatesThread.start() diff --git a/src/screen_region.py b/src/screen_region.py index 88c4ae3f..e2492a62 100644 --- a/src/screen_region.py +++ b/src/screen_region.py @@ -19,7 +19,7 @@ user32 = ctypes.windll.user32 -def selectRegion(autosplit: AutoSplit): +def select_region(autosplit: AutoSplit): # Create a screen selector widget selector = SelectRegionWidget() @@ -41,10 +41,6 @@ def selectRegion(autosplit: AutoSplit): while win32gui.IsChild(win32gui.GetParent(autosplit.hwnd), autosplit.hwnd): autosplit.hwnd = user32.GetAncestor(autosplit.hwnd, GA_ROOT) - windowText = win32gui.GetWindowText(autosplit.hwnd) - if autosplit.hwnd > 0 or windowText: - autosplit.hwnd_title = windowText - # Convert the Desktop Coordinates to Window Coordinates # Pull the window's coordinates relative to desktop into selection ctypes.windll.dwmapi.DwmGetWindowAttribute( @@ -57,9 +53,9 @@ def selectRegion(autosplit: AutoSplit): # TODO: Since this occurs on Windows 10, is DwmGetWindowAttribute even required over GetWindowRect alone? # Research needs to be done to figure out why it was used it over win32gui in the first place... # I have a feeling it was due to a misunderstanding and not getting the correct parent window before. - windowRect = win32gui.GetWindowRect(autosplit.hwnd) - offset_left = autosplit.selection.left - windowRect[0] - offset_top = autosplit.selection.top - windowRect[1] + window_rect = win32gui.GetWindowRect(autosplit.hwnd) + offset_left = autosplit.selection.left - window_rect[0] + offset_top = autosplit.selection.top - window_rect[1] autosplit.selection.left = selector.left - (autosplit.selection.left - offset_left) autosplit.selection.top = selector.top - (autosplit.selection.top - offset_top) @@ -69,16 +65,16 @@ def selectRegion(autosplit: AutoSplit): # Delete that widget since it is no longer used from here on out del selector - autosplit.widthSpinBox.setValue(width) - autosplit.heightSpinBox.setValue(height) - autosplit.xSpinBox.setValue(autosplit.selection.left) - autosplit.ySpinBox.setValue(autosplit.selection.top) + autosplit.width_spinbox.setValue(width) + autosplit.height_spinbox.setValue(height) + autosplit.x_spinbox.setValue(autosplit.selection.left) + autosplit.y_spinbox.setValue(autosplit.selection.top) # check if live image needs to be turned on or just set a single image - autosplit.checkLiveImage() + autosplit.check_live_image() -def selectWindow(autosplit: AutoSplit): +def select_window(autosplit: AutoSplit): # Create a screen selector widget selector = SelectWindowWidget() @@ -102,10 +98,6 @@ def selectWindow(autosplit: AutoSplit): while win32gui.IsChild(win32gui.GetParent(autosplit.hwnd), autosplit.hwnd): autosplit.hwnd = user32.GetAncestor(autosplit.hwnd, GA_ROOT) - windowText = win32gui.GetWindowText(autosplit.hwnd) - if autosplit.hwnd > 0 or windowText: - autosplit.hwnd_title = windowText - # getting window bounds # on windows there are some invisble pixels that are not accounted for # also the top bar with the window name is not accounted for @@ -117,18 +109,18 @@ def selectWindow(autosplit: AutoSplit): autosplit.selection.right = 8 + selection[2] autosplit.selection.bottom = 31 + selection[3] - autosplit.widthSpinBox.setValue(selection[2]) - autosplit.heightSpinBox.setValue(selection[3]) - autosplit.xSpinBox.setValue(autosplit.selection.left) - autosplit.ySpinBox.setValue(autosplit.selection.top) + autosplit.width_spinbox.setValue(selection[2]) + autosplit.height_spinbox.setValue(selection[3]) + autosplit.x_spinbox.setValue(autosplit.selection.left) + autosplit.y_spinbox.setValue(autosplit.selection.top) - autosplit.checkLiveImage() + autosplit.check_live_image() -def alignRegion(autosplit: AutoSplit): +def align_region(autosplit: AutoSplit): # check to see if a region has been set if autosplit.hwnd <= 0 or not win32gui.GetWindowText(autosplit.hwnd): - error_messages.regionError() + error_messages.region() return # This is the image used for aligning the capture region # to the best fit for the user. @@ -146,7 +138,7 @@ def alignRegion(autosplit: AutoSplit): # shouldn't need this, but just for caution, throw a type error if file is not a valid image file if template is None: - error_messages.alignRegionImageTypeError() + error_messages.align_region_image_type() return # Obtaining the capture of a region which contains the @@ -154,8 +146,7 @@ def alignRegion(autosplit: AutoSplit): capture = capture_windows.capture_region( autosplit.hwnd, autosplit.selection, - autosplit.forcePrintWindowCheckBox.isChecked()) - capture = cv2.cvtColor(capture, cv2.COLOR_BGRA2BGR) + autosplit.force_print_window_checkbox.isChecked()) # Obtain the best matching point for the template within the # capture. This assumes that the template is actually smaller @@ -197,7 +188,7 @@ def alignRegion(autosplit: AutoSplit): # Go ahead and check if this satisfies our requirement before setting the region # We don't want a low similarity image to be aligned. if best_match < 0.9: - error_messages.alignmentNotMatchedError() + error_messages.alignment_not_matched() return # The new region can be defined by using the min_loc point and the @@ -207,22 +198,22 @@ def alignRegion(autosplit: AutoSplit): autosplit.selection.right = autosplit.selection.left + best_width autosplit.selection.bottom = autosplit.selection.top + best_height - autosplit.xSpinBox.setValue(autosplit.selection.left) - autosplit.ySpinBox.setValue(autosplit.selection.top) - autosplit.widthSpinBox.setValue(best_width) - autosplit.heightSpinBox.setValue(best_height) + autosplit.x_spinbox.setValue(autosplit.selection.left) + autosplit.y_spinbox.setValue(autosplit.selection.top) + autosplit.width_spinbox.setValue(best_width) + autosplit.height_spinbox.setValue(best_height) -def validateBeforeComparison(autosplit: AutoSplit, show_error: bool = True, check_empty_directory: bool = True): +def validate_before_parsing(autosplit: AutoSplit, show_error: bool = True, check_empty_directory: bool = True): error = None if not autosplit.split_image_directory: - error = error_messages.splitImageDirectoryError + error = error_messages.split_image_directory elif not os.path.isdir(autosplit.split_image_directory): - error = error_messages.splitImageDirectoryNotFoundError + error = error_messages.split_image_directory_not_found elif check_empty_directory and not os.listdir(autosplit.split_image_directory): - error = error_messages.splitImageDirectoryEmpty + error = error_messages.split_image_directory_empty elif autosplit.hwnd <= 0 or not win32gui.GetWindowText(autosplit.hwnd): - error = error_messages.regionError + error = error_messages.region if error and show_error: error() return not error @@ -287,10 +278,10 @@ def width(self): def paintEvent(self, a0: QtGui.QPaintEvent): if self.__begin != self.__end: - qPainter = QtGui.QPainter(self) - qPainter.setPen(QtGui.QPen(QtGui.QColor("red"), 2)) - qPainter.setBrush(QtGui.QColor("opaque")) - qPainter.drawRect(QtCore.QRect(self.__begin, self.__end)) + qpainter = QtGui.QPainter(self) + qpainter.setPen(QtGui.QPen(QtGui.QColor("red"), 2)) + qpainter.setBrush(QtGui.QColor("opaque")) + qpainter.drawRect(QtCore.QRect(self.__begin, self.__end)) def mousePressEvent(self, a0: QtGui.QMouseEvent): self.__begin = a0.position().toPoint() diff --git a/src/settings_file.py b/src/settings_file.py index 1a6322c2..8879377d 100644 --- a/src/settings_file.py +++ b/src/settings_file.py @@ -1,5 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any + if TYPE_CHECKING: from AutoSplit import AutoSplit @@ -10,13 +11,15 @@ from win32 import win32gui from PyQt6 import QtCore, QtWidgets -from gen import design import error_messages -# TODO with settings refactoring -from hotkeys import _hotkey_action # type: ignore +from gen import design +from hotkeys import set_pause_hotkey, set_reset_hotkey, set_skip_split_hotkey, set_split_hotkey, set_undo_split_hotkey + +# Keyword "frozen" is for setting basedir while in onefile mode in pyinstaller +FROZEN = hasattr(sys, "frozen") # Get the directory of either AutoSplit.exe or AutoSplit.py -auto_split_directory = os.path.dirname(sys.executable if getattr(sys, "frozen", False) else os.path.abspath(__file__)) +auto_split_directory = os.path.dirname(sys.executable if FROZEN else os.path.abspath(__file__)) class RestrictedUnpickler(pickle.Unpickler): @@ -25,102 +28,59 @@ def find_class(self, module: str, name: str): raise pickle.UnpicklingError(f"'{module}.{name}' is forbidden") -def loadPyQtSettings(autosplit: AutoSplit): +def load_pyqt_settings(autosplit: AutoSplit): # These are only global settings values. They are not *pkl settings values. - autosplit.getGlobalSettingsValues() + autosplit.setting_check_for_updates_on_open = QtCore.QSettings("AutoSplit", "Check For Updates On Open") check_for_updates_on_open = autosplit.setting_check_for_updates_on_open.value( "check_for_updates_on_open", True, type=bool) - autosplit.actionCheck_for_Updates_on_Open.setChecked(check_for_updates_on_open) - - -def getSaveSettingsValues(autosplit: AutoSplit): - # get values to be able to save settings - autosplit.similarity_threshold = autosplit.similaritythresholdDoubleSpinBox.value() - autosplit.comparison_index = autosplit.comparisonmethodComboBox.currentIndex() - autosplit.pause = autosplit.pauseDoubleSpinBox.value() - autosplit.fps_limit = int(autosplit.fpslimitSpinBox.value()) - autosplit.split_key = autosplit.splitLineEdit.text() - autosplit.reset_key = autosplit.resetLineEdit.text() - autosplit.skip_split_key = autosplit.skipsplitLineEdit.text() - autosplit.undo_split_key = autosplit.undosplitLineEdit.text() - autosplit.pause_key = autosplit.pausehotkeyLineEdit.text() - autosplit.group_dummy_splits_undo_skip_setting = cast( - Literal[0, 1], - int(autosplit.groupDummySplitsCheckBox.isChecked())) - autosplit.loop_setting = cast( - Literal[0, 1], - int(autosplit.loopCheckBox.isChecked())) - autosplit.auto_start_on_reset_setting = cast( - Literal[0, 1], - int(autosplit.autostartonresetCheckBox.isChecked())) - - -def haveSettingsChanged(autosplit: AutoSplit): - getSaveSettingsValues(autosplit) - current_save_settings = [ + autosplit.action_check_for_updates_on_open.setChecked(check_for_updates_on_open) + + +def get_save_settings_values(autosplit: AutoSplit): + return [ autosplit.split_image_directory, - autosplit.similarity_threshold, - autosplit.comparison_index, - autosplit.pause, - autosplit.fps_limit, - autosplit.split_key, - autosplit.reset_key, - autosplit.skip_split_key, - autosplit.undo_split_key, - autosplit.pause_key, - autosplit.xSpinBox.value(), - autosplit.ySpinBox.value(), - autosplit.widthSpinBox.value(), - autosplit.heightSpinBox.value(), - autosplit.hwnd_title, + autosplit.similarity_threshold_spinbox.value(), + autosplit.comparison_method_combobox.currentIndex(), + autosplit.pause_spinbox.value(), + int(autosplit.fps_limit_spinbox.value()), + autosplit.split_input.text(), + autosplit.reset_input.text(), + autosplit.skip_split_input.text(), + autosplit.undo_split_input.text(), + autosplit.pause_hotkey_input.text(), + autosplit.x_spinbox.value(), + autosplit.y_spinbox.value(), + autosplit.width_spinbox.value(), + autosplit.height_spinbox.value(), + win32gui.GetWindowText(autosplit.hwnd), 0, 0, - autosplit.group_dummy_splits_undo_skip_setting, - autosplit.loop_setting, - autosplit.auto_start_on_reset_setting, - autosplit.forcePrintWindowCheckBox.isChecked()] + int(autosplit.group_dummy_splits_checkbox.isChecked()), + int(autosplit.loop_checkbox.isChecked()), + int(autosplit.auto_start_on_reset_checkbox.isChecked()), + autosplit.force_print_window_checkbox.isChecked()] + +def have_settings_changed(autosplit: AutoSplit): # One small caveat in this: if you load a settings file from an old version, but dont change settings, # the current save settings and last load settings will have different # of elements and it will ask # the user to save changes upon closing even though there were none - return current_save_settings not in (autosplit.last_loaded_settings, autosplit.last_saved_settings) + return get_save_settings_values(autosplit) not in (autosplit.last_loaded_settings, autosplit.last_saved_settings) -def saveSettings(autosplit: AutoSplit): +def save_settings(autosplit: AutoSplit): if not autosplit.last_successfully_loaded_settings_file_path: - saveSettingsAs(autosplit) + save_settings_as(autosplit) else: - getSaveSettingsValues(autosplit) - autosplit.last_saved_settings = [ - autosplit.split_image_directory, - autosplit.similarity_threshold, - autosplit.comparison_index, - autosplit.pause, - autosplit.fps_limit, - autosplit.split_key, - autosplit.reset_key, - autosplit.skip_split_key, - autosplit.undo_split_key, - autosplit.pause_key, - autosplit.xSpinBox.value(), - autosplit.ySpinBox.value(), - autosplit.widthSpinBox.value(), - autosplit.heightSpinBox.value(), - autosplit.hwnd_title, - 0, - 0, - autosplit.group_dummy_splits_undo_skip_setting, - autosplit.loop_setting, - autosplit.auto_start_on_reset_setting, - autosplit.forcePrintWindowCheckBox.isChecked()] + autosplit.last_saved_settings = get_save_settings_values(autosplit) # save settings to a .pkl file - with open(autosplit.last_successfully_loaded_settings_file_path, "wb") as f: - pickle.dump(autosplit.last_saved_settings, f) + with open(autosplit.last_successfully_loaded_settings_file_path, "wb") as file: + pickle.dump(autosplit.last_saved_settings, file) -def saveSettingsAs(autosplit: AutoSplit): +def save_settings_as(autosplit: AutoSplit): # User picks save destination autosplit.save_settings_file_path = QtWidgets.QFileDialog.getSaveFileName( autosplit, @@ -132,40 +92,77 @@ def saveSettingsAs(autosplit: AutoSplit): if not autosplit.save_settings_file_path: return - getSaveSettingsValues(autosplit) - autosplit.last_saved_settings = [ - autosplit.split_image_directory, - autosplit.similarity_threshold, - autosplit.comparison_index, - autosplit.pause, - autosplit.fps_limit, - autosplit.split_key, - autosplit.reset_key, - autosplit.skip_split_key, - autosplit.undo_split_key, - autosplit.pause_key, - autosplit.xSpinBox.value(), - autosplit.ySpinBox.value(), - autosplit.widthSpinBox.value(), - autosplit.heightSpinBox.value(), - autosplit.hwnd_title, - 0, - 0, - autosplit.group_dummy_splits_undo_skip_setting, - autosplit.loop_setting, - autosplit.auto_start_on_reset_setting, - autosplit.forcePrintWindowCheckBox.isChecked()] + autosplit.last_saved_settings = get_save_settings_values(autosplit) # save settings to a .pkl file - with open(autosplit.save_settings_file_path, "wb") as f: - pickle.dump(autosplit.last_saved_settings, f) + with open(autosplit.save_settings_file_path, "wb") as file: + pickle.dump(autosplit.last_saved_settings, file) # Wording is kinda off here but this needs to be here for an edge case: # for when a file has never loaded, but you save file as successfully. autosplit.last_successfully_loaded_settings_file_path = autosplit.save_settings_file_path -def loadSettings(autosplit: AutoSplit, load_settings_on_open: bool = False, load_settings_from_livesplit: bool = False): +def __load_settings_from_file(autosplit: AutoSplit): + try: + with open(autosplit.load_settings_file_path, "rb") as file: + settings: list[Any] = RestrictedUnpickler(file).load() + settings_count = len(settings) + if settings_count < 18: + autosplit.show_error_signal.emit(error_messages.old_version_settings_file) + return + # v1.3-1.4 settings. Add default pause_key and auto_start_on_reset_setting + if settings_count == 18: + settings.insert(9, "") + settings.insert(20, 0) + # v1.5 settings + if settings_count == 20: + settings.insert(21, False) + # v1.6.X settings + elif settings_count != 21: + autosplit.show_error_signal.emit(error_messages.invalid_settings) + return + autosplit.last_loaded_settings = settings + except (FileNotFoundError, MemoryError, pickle.UnpicklingError): + autosplit.show_error_signal.emit(error_messages.invalid_settings) + return + + autosplit.split_image_directory = settings[0] + autosplit.split_image_folder_input.setText(settings[0]) + autosplit.similarity_threshold_spinbox.setValue(settings[1]) + autosplit.comparison_method_combobox.setCurrentIndex(settings[2]) + autosplit.pause_spinbox.setValue(settings[3]) + autosplit.fps_limit_spinbox.setValue(settings[4]) + autosplit.split_input.setText(settings[5]) + autosplit.reset_input.setText(settings[6]) + autosplit.skip_split_input.setText(settings[7]) + autosplit.undo_split_input.setText(settings[8]) + autosplit.pause_hotkey_input.setText(settings[9]) + autosplit.x_spinbox.setValue(settings[10]) + autosplit.y_spinbox.setValue(settings[11]) + autosplit.width_spinbox.setValue(settings[12]) + autosplit.height_spinbox.setValue(settings[13]) + # https://github.com/kaluluosi/pywin32-stubs/issues/7 + autosplit.hwnd = win32gui.FindWindow(None, settings[14]) # type: ignore + autosplit.group_dummy_splits_checkbox.setChecked(bool(settings[17])) + autosplit.loop_checkbox.setChecked(bool(settings[18])) + autosplit.auto_start_on_reset_checkbox.setChecked(bool(settings[19])) + autosplit.force_print_window_checkbox.setChecked(settings[20]) + + keyboard.unhook_all() + if not autosplit.is_auto_controlled: + set_split_hotkey(autosplit, settings[5]) + set_reset_hotkey(autosplit, settings[6]) + set_skip_split_hotkey(autosplit, settings[7]) + set_undo_split_hotkey(autosplit, settings[8]) + set_pause_hotkey(autosplit, settings[9]) + + +def load_settings( + autosplit: AutoSplit, + load_settings_on_open: bool = False, + load_settings_from_livesplit: bool = False +): if load_settings_on_open: settings_files = [ file for file @@ -174,155 +171,32 @@ def loadSettings(autosplit: AutoSplit, load_settings_on_open: bool = False, load # find all .pkls in AutoSplit folder, error if there is none or more than 1 if len(settings_files) < 1: - error_messages.noSettingsFileOnOpenError() - autosplit.last_loaded_settings = None + error_messages.no_settings_file_on_open() + autosplit.last_loaded_settings = [] return if len(settings_files) > 1: - error_messages.tooManySettingsFilesOnOpenError() - autosplit.last_loaded_settings = None + error_messages.too_many_settings_files_on_open() + autosplit.last_loaded_settings = [] return autosplit.load_settings_file_path = os.path.join(auto_split_directory, settings_files[0]) elif not load_settings_on_open and not load_settings_from_livesplit: - autosplit.load_settings_file_path = QtWidgets.QFileDialog.getOpenFileName( autosplit, "Load Settings", os.path.join(auto_split_directory, "settings.pkl"), "PKL (*.pkl)")[0] - if not autosplit.load_settings_file_path: return - try: - with open(autosplit.load_settings_file_path, "rb") as f: - settings: list[Any] = RestrictedUnpickler(f).load() - settings_count = len(settings) - if settings_count < 18: - autosplit.showErrorSignal.emit(error_messages.oldVersionSettingsFileError) - return - # v1.3-1.4 settings. Add default pause_key and auto_start_on_reset_setting - if settings_count == 18: - settings.insert(9, "") - settings.insert(20, 0) - # v1.5 settings - if settings_count == 20: - settings.insert(21, False) - # v1.6.X settings - elif settings_count != 21: - if not load_settings_from_livesplit: - autosplit.showErrorSignal.emit(error_messages.invalidSettingsError) - return - autosplit.last_loaded_settings = [ - autosplit.split_image_directory, - autosplit.similarity_threshold, - autosplit.comparison_index, - autosplit.pause, - autosplit.fps_limit, - autosplit.split_key, - autosplit.reset_key, - autosplit.skip_split_key, - autosplit.undo_split_key, - autosplit.pause_key, - region_x, - region_y, - region_width, - region_height, - autosplit.hwnd_title, - _, - _, - autosplit.group_dummy_splits_undo_skip_setting, - autosplit.loop_setting, - autosplit.auto_start_on_reset_setting, - forcePrintWindowCheckBox] = settings - - autosplit.forcePrintWindowCheckBox.setChecked(forcePrintWindowCheckBox) - except (FileNotFoundError, MemoryError, pickle.UnpicklingError) as e: - print(e) - autosplit.showErrorSignal.emit(error_messages.invalidSettingsError) - return - - autosplit.splitimagefolderLineEdit.setText(autosplit.split_image_directory) - autosplit.similaritythresholdDoubleSpinBox.setValue(autosplit.similarity_threshold) - autosplit.pauseDoubleSpinBox.setValue(autosplit.pause) - autosplit.fpslimitSpinBox.setValue(autosplit.fps_limit) - autosplit.xSpinBox.setValue(region_x) - autosplit.ySpinBox.setValue(region_y) - autosplit.widthSpinBox.setValue(region_width) - autosplit.heightSpinBox.setValue(region_height) - autosplit.comparisonmethodComboBox.setCurrentIndex(autosplit.comparison_index) - # https://github.com/kaluluosi/pywin32-stubs/issues/7 - autosplit.hwnd = win32gui.FindWindow(None, autosplit.hwnd_title) # type: ignore - - # set custom checkbox's accordingly - autosplit.groupDummySplitsCheckBox.setChecked(bool(autosplit.group_dummy_splits_undo_skip_setting)) - autosplit.loopCheckBox.setChecked(bool(autosplit.loop_setting)) - autosplit.autostartonresetCheckBox.setChecked(bool(autosplit.auto_start_on_reset_setting)) - autosplit.autostartonresetCheckBox.setChecked(bool(autosplit.auto_start_on_reset_setting)) - - # TODO: Reuse code from hotkeys rather than duplicating here - # try to set hotkeys from when user last closed the window - if autosplit.split_hotkey: - keyboard.unhook_key(autosplit.split_hotkey) - try: - autosplit.splitLineEdit.setText(autosplit.split_key) - if not autosplit.is_auto_controlled: - autosplit.split_hotkey = keyboard.hook_key( - autosplit.split_key, - lambda e: _hotkey_action(e, autosplit.split_key, autosplit.startAutoSplitter)) - except (ValueError, KeyError): - pass - - if autosplit.reset_hotkey: - keyboard.unhook_key(autosplit.reset_hotkey) - try: - autosplit.resetLineEdit.setText(autosplit.reset_key) - if not autosplit.is_auto_controlled: - autosplit.reset_hotkey = keyboard.hook_key( - autosplit.reset_key, - lambda e: _hotkey_action(e, autosplit.reset_key, autosplit.startReset)) - except (ValueError, KeyError): - pass - - if autosplit.skip_split_hotkey: - keyboard.unhook_key(autosplit.skip_split_hotkey) - try: - autosplit.skipsplitLineEdit.setText(autosplit.skip_split_key) - if not autosplit.is_auto_controlled: - autosplit.skip_split_hotkey = keyboard.hook_key( - autosplit.skip_split_key, - lambda e: _hotkey_action(e, autosplit.skip_split_key, autosplit.startSkipSplit)) - except (ValueError, KeyError): - pass - - if autosplit.skip_split_hotkey: - keyboard.unhook_key(autosplit.skip_split_hotkey) - try: - autosplit.undosplitLineEdit.setText(autosplit.undo_split_key) - if not autosplit.is_auto_controlled: - autosplit.undo_split_hotkey = keyboard.hook_key( - autosplit.undo_split_key, - lambda e: _hotkey_action(e, autosplit.undo_split_key, autosplit.startUndoSplit)) - except (ValueError, KeyError): - pass - - if autosplit.pause_hotkey: - keyboard.unhook_key(autosplit.pause_hotkey) - try: - autosplit.pausehotkeyLineEdit.setText(autosplit.pause_key) - if not autosplit.is_auto_controlled: - autosplit.pause_hotkey = keyboard.hook_key( - autosplit.pause_key, - lambda e: _hotkey_action(e, autosplit.pause_key, autosplit.startPause)) - except (ValueError, KeyError): - pass + __load_settings_from_file(autosplit) autosplit.last_successfully_loaded_settings_file_path = autosplit.load_settings_file_path - autosplit.checkLiveImage() - autosplit.loadStartImage() + autosplit.check_live_image() + autosplit.load_start_image() -def load_check_for_updates_on_open(designWindow: design.Ui_MainWindow): +def load_check_for_updates_on_open(design_window: design.Ui_MainWindow): """ Retrieve the "Check For Updates On Open" QSettings and set the checkbox state These are only global settings values. They are not *pkl settings values. @@ -331,15 +205,15 @@ def load_check_for_updates_on_open(designWindow: design.Ui_MainWindow): value = QtCore \ .QSettings("AutoSplit", "Check For Updates On Open") \ .value("check_for_updates_on_open", True, type=bool) - designWindow.actionCheck_for_Updates_on_Open.setChecked(value) + design_window.action_check_for_updates_on_open.setChecked(value) -def set_check_for_updates_on_open(designWindow: design.Ui_MainWindow, value: bool): +def set_check_for_updates_on_open(design_window: design.Ui_MainWindow, value: bool): """ Sets the "Check For Updates On Open" QSettings value and the checkbox state """ - designWindow.actionCheck_for_Updates_on_Open.setChecked(value) + design_window.action_check_for_updates_on_open.setChecked(value) QtCore \ .QSettings("AutoSplit", "Check For Updates On Open") \ .setValue("check_for_updates_on_open", value) diff --git a/src/split_parser.py b/src/split_parser.py index 5e24c78a..feca0803 100644 --- a/src/split_parser.py +++ b/src/split_parser.py @@ -4,17 +4,15 @@ from AutoSplit import AutoSplit import os -import cv2 import error_messages +from AutoSplitImage import AutoSplitImage, ImageType [DUMMY_FLAG, - # Legacy flag. Allows support for {md}, {mp}, or {mb} flags previously required to detect transparency. - MASK_FLAG, BELOW_FLAG, PAUSE_FLAG, - *_] = [1 << i for i in range(31)] + *_] = [1 << i for i in range(31)] # 32 bits of flags def threshold_from_filename(filename: str): @@ -119,15 +117,17 @@ def flags_from_filename(filename: str): flags = 0x00 - for c in flags_str: - if c.upper() == "D": + for character in flags_str: + character = character.upper() + if character == "D": flags |= DUMMY_FLAG - elif c.upper() == "M": - flags |= MASK_FLAG - elif c.upper() == "B": + elif character == "B": flags |= BELOW_FLAG - elif c.upper() == "P": + elif character == "P": flags |= PAUSE_FLAG + # Legacy flags + elif character == "M": + continue else: # An invalid flag was caught, this filename was written incorrectly # return 0. We don't want to interpret any misleading filenames @@ -141,82 +141,57 @@ def flags_from_filename(filename: str): return flags -def is_reset_image(filename: str): - """ - Checks if the image is used for resetting - - @param filename: String containing the file's name - @return: True if its a reset image - """ - return "RESET" in filename.upper() - - -def is_start_auto_splitter_image(filename: str): - """ - Checks if the image is used to start AutoSplit - - @param filename: String containing the file's name - @return: True if its a reset image - """ - return "START_AUTO_SPLITTER" in filename.upper() - +def __pop_image_type(split_image: list[AutoSplitImage], image_type: ImageType): + for image in split_image: + if image.image_type == image_type: + split_image.remove(image) + return image -def removeStartAutoSplitterImage(split_image_filenames: list[str]): - start_auto_splitter_image_file = None - for image in split_image_filenames: - if is_start_auto_splitter_image(image): - start_auto_splitter_image_file = image - break + return None - if start_auto_splitter_image_file is None: - return - split_image_filenames.remove(start_auto_splitter_image_file) +def parse_and_validate_images(autosplit: AutoSplit): + # Get split images + all_images = [ + AutoSplitImage(os.path.join(autosplit.split_image_directory, image_name)) + for image_name + in os.listdir(autosplit.split_image_directory)] + # Find non-split images and then remove them from the list + autosplit.start_image = __pop_image_type(all_images, ImageType.START) + autosplit.reset_image = __pop_image_type(all_images, ImageType.RESET) + autosplit.split_images = all_images -# TODO: When split, reset and start image are all a proper class -# let's also extract reset and start from the list here and return them -def validate_images_before_parsing(autosplit: AutoSplit): - already_found_reset_image = False - already_found_start_image = False # Make sure that each of the images follows the guidelines for correct format # according to all of the settings selected by the user. - for image in autosplit.split_image_filenames: + for image in autosplit.split_images: # Test for image without transparency - if (cv2.imread(os.path.join(autosplit.split_image_directory, image), cv2.IMREAD_COLOR) is None - # Test for image with transparency - and cv2.imread(os.path.join(autosplit.split_image_directory, image), cv2.IMREAD_UNCHANGED) is None): - # Opencv couldn't open this file as an image, this isn't a correct - # file format that is supported - autosplit.guiChangesOnReset() - error_messages.imageTypeError(image) - return + if image.bytes is None: + autosplit.gui_changes_on_reset() + return False # error out if there is a {p} flag but no pause hotkey set and is not auto controlled. - if (not autosplit.pausehotkeyLineEdit.text() - and flags_from_filename(image) & PAUSE_FLAG == PAUSE_FLAG + if (not autosplit.pause_hotkey_input.text() + and image.check_flag(PAUSE_FLAG) and not autosplit.is_auto_controlled): - autosplit.guiChangesOnReset() - error_messages.pauseHotkeyError() - return + autosplit.gui_changes_on_reset() + error_messages.pause_hotkey() + return False # Check that there's only one reset image - if is_reset_image(image): + if image.image_type == ImageType.RESET: # If there is no reset hotkey set but a reset image is present, and is not auto controlled, throw an error. - if not autosplit.resetLineEdit.text() and not autosplit.is_auto_controlled: - autosplit.guiChangesOnReset() - error_messages.resetHotkeyError() - return - if already_found_reset_image: - autosplit.guiChangesOnReset() - error_messages.multipleKeywordImagesError("reset") - return - already_found_reset_image = True - - # Check that there's only one auto_start_autosplitter image - if is_start_auto_splitter_image(image): - if already_found_start_image: - autosplit.guiChangesOnReset() - error_messages.multipleKeywordImagesError("start_auto_splitter") - return - already_found_start_image = True + if not autosplit.reset_input.text() and not autosplit.is_auto_controlled: + autosplit.gui_changes_on_reset() + error_messages.reset_hotkey() + return False + autosplit.gui_changes_on_reset() + error_messages.multiple_keyword_images("reset") + return False + + # Check that there's only one start image + if image.image_type == ImageType.START: + autosplit.gui_changes_on_reset() + error_messages.multiple_keyword_images("start_auto_splitter") + return False + return True diff --git a/typings/PyInstaller/utils/hooks/__init__.pyi b/typings/PyInstaller/utils/hooks/__init__.pyi new file mode 100644 index 00000000..154d59ca --- /dev/null +++ b/typings/PyInstaller/utils/hooks/__init__.pyi @@ -0,0 +1,129 @@ +""" +This type stub file was generated by pyright. +""" +from typing import Any, Literal, Optional, Union +from collections.abc import Callable + +logger = ... +PY_IGNORE_EXTENSIONS: set +hook_variables: dict + + +def exec_statement(statement): + ... + + +def exec_statement_rc(statement): + ... + + +def exec_script(script_filename, *args, env=...): + ... + + +def exec_script_rc(script_filename, *args, env=...): + ... + + +def eval_statement(statement) -> Union[Any, Literal[""]]: + ... + + +def eval_script(scriptfilename, *args, env=...) -> Union[Any, Literal[""]]: + ... + + +def get_pyextension_imports(modname) -> Union[Any, list]: + ... + + +def get_homebrew_path(formula=...) -> Optional[str]: + ... + + +def remove_prefix(string, prefix): + ... + + +def remove_suffix(string, suffix): + ... + + +def remove_file_extension(filename): + ... + + +def can_import_module(module_name): + ... + + +def get_module_attribute(module_name, attr_name): + ... + + +def get_module_file_attribute(package): + ... + + +def is_module_satisfies(requirements, version=..., version_attr=...): + ... + + +def is_package(module_name) -> Literal[False]: + ... + + +def get_package_paths(package) -> tuple[str, str]: + ... + + +def collect_submodules(package: str, filter: Optional[Callable[[str], bool]] = ...) -> list[str]: + ... + + +def is_module_or_submodule(name, mod_or_submod): + ... + + +PY_DYLIB_PATTERNS = [ + "*.dll", + "*.dylib", + "lib*.so", +] + + +def collect_dynamic_libs(package: str, destdir: Optional[str] = ...) -> list[tuple[str, str]]: + ... + + +def collect_data_files(package, include_py_files=..., subdir=..., excludes=..., includes=...) -> list[tuple[str, str]]: + ... + + +def collect_system_data_files(path, destdir=..., include_py_files=...): + ... + + +def copy_metadata(package_name, recursive=...) -> list: + ... + + +def get_installer(module) -> Optional[str]: + ... + + +def requirements_for_package(package_name) -> list: + ... + + +def collect_all(package_name, include_py_files=..., filter_submodules=..., + exclude_datas=..., include_datas=...) -> tuple[list, list, list]: + ... + + +def collect_entry_point(name: str) -> tuple[list, list]: + ... + + +def get_hook_config(hook_api, module_name, key) -> None: + ... diff --git a/typings/imagehash/__init__.pyi b/typings/imagehash/__init__.pyi index 1ead4a32..4f60001d 100644 --- a/typings/imagehash/__init__.pyi +++ b/typings/imagehash/__init__.pyi @@ -2,47 +2,13 @@ This type stub file was generated by pyright. """ from __future__ import absolute_import, division, print_function, annotations -import numpy from PIL import Image, ImageFilter +import numpy -""" -Image hashing library -====================== - -Example: - ->>> from PIL import Image ->>> import imagehash ->>> hash = imagehash.average_hash(Image.open('test.png')) ->>> print(hash) -d879f8f89b1bbf ->>> otherhash = imagehash.average_hash(Image.open('other.bmp')) ->>> print(otherhash) -ffff3720200ffff ->>> print(hash == otherhash) -False ->>> print(hash - otherhash) -36 ->>> for r in range(1, 30, 5): -... rothash = imagehash.average_hash(Image.open('test.png').rotate(r)) -... print('Rotation by %d: %d Hamming difference' % (r, hash - rothash)) -... -Rotation by 1: 2 Hamming difference -Rotation by 6: 11 Hamming difference -Rotation by 11: 13 Hamming difference -Rotation by 16: 17 Hamming difference -Rotation by 21: 19 Hamming difference -Rotation by 26: 21 Hamming difference ->>> -""" __version__ = ... class ImageHash: - """ - Hash encapsulation. Can be used for dictionary keys and comparisons. - """ - def __init__(self, binary_array) -> None: ... @@ -69,16 +35,6 @@ class ImageHash: def hex_to_hash(hexstr) -> ImageHash: - """ - Convert a stored hash (hex, as retrieved from str(Imagehash)) - back to a Imagehash object. - - Notes: - 1. This algorithm assumes all hashes are either - bidimensional arrays with dimensions hash_size * hash_size, - or onedimensional arrays with dimensions binbits * 14. - 2. This algorithm does not work for hash_size < 2. - """ ... @@ -87,118 +43,38 @@ def hex_to_flathash(hexstr, hashsize) -> ImageHash: def old_hex_to_hash(hexstr, hash_size=...) -> ImageHash: - """ - Convert a stored hash (hex, as retrieved from str(Imagehash)) - back to a Imagehash object. This method should be used for - hashes generated by ImageHash up to version 3.7. For hashes - generated by newer versions of ImageHash, hex_to_hash should - be used instead. - """ ... def average_hash(image, hash_size=..., mean=...) -> ImageHash: - """ - Average Hash computation - - Implementation follows http://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html - - Step by step explanation: https://web.archive.org/web/20171112054354/https://www.safaribooksonline.com/blog/2013/11/26/image-hashing-with-python/ - - @image must be a PIL instance. - @mean how to determine the average luminescence. can try numpy.median instead. - """ ... def phash(image: Image.Image, hash_size: int = ..., highfreq_factor: int = ...) -> ImageHash: - """ - Perceptual Hash computation. - - Implementation follows http://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html - - @image must be a PIL instance. - """ ... def phash_simple(image, hash_size=..., highfreq_factor=...) -> ImageHash: - """ - Perceptual Hash computation. - - Implementation follows http://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html - - @image must be a PIL instance. - """ ... def dhash(image, hash_size=...) -> ImageHash: - """ - Difference Hash computation. - - following http://www.hackerfactor.com/blog/index.php?/archives/529-Kind-of-Like-That.html - - computes differences horizontally - - @image must be a PIL instance. - """ ... def dhash_vertical(image, hash_size=...) -> ImageHash: - """ - Difference Hash computation. - - following http://www.hackerfactor.com/blog/index.php?/archives/529-Kind-of-Like-That.html - - computes differences vertically - - @image must be a PIL instance. - """ ... def whash(image, hash_size=..., image_scale=..., mode=..., remove_max_haar_ll=...) -> ImageHash: - """ - Wavelet Hash computation. - - based on https://www.kaggle.com/c/avito-duplicate-ads-detection/ - - @image must be a PIL instance. - @hash_size must be a power of 2 and less than @image_scale. - @image_scale must be power of 2 and less than image size. By default is equal to max - power of 2 for an input image. - @mode (see modes in pywt library): - 'haar' - Haar wavelets, by default - 'db4' - Daubechies wavelets - @remove_max_haar_ll - remove the lowest low level (LL) frequency using Haar wavelet. - """ ... def colorhash(image, binbits=...) -> ImageHash: - """ - Color Hash computation. - - Computes fractions of image in intensity, hue and saturation bins: - - * the first binbits encode the black fraction of the image - * the next binbits encode the gray fraction of the remaining image (low saturation) - * the next 6*binbits encode the fraction in 6 bins of saturation, for highly saturated parts of the remaining image - * the next 6*binbits encode the fraction in 6 bins of saturation, for mildly saturated parts of the remaining image - - @binbits number of bits to use to encode each pixel fractions - """ ... class ImageMultiHash: - """ - This is an image hash containing a list of individual hashes for segments of the image. - The matching logic is implemented as described in Efficient Cropping-Resistant Robust Image Hashing - """ - def __init__(self, hashes) -> None: ... @@ -221,55 +97,20 @@ class ImageMultiHash: ... def hash_diff(self, other_hash, hamming_cutoff=..., bit_error_rate=...) -> tuple[int, Unknown | int]: - """ - Gets the difference between two multi-hashes, as a tuple. The first element of the tuple is the number of - matching segments, and the second element is the sum of the hamming distances of matching hashes. - NOTE: Do not order directly by this tuple, as higher is better for matches, and worse for hamming cutoff. - :param other_hash: The image multi hash to compare against - :param hamming_cutoff: The maximum hamming distance to a region hash in the target hash - :param bit_error_rate: Percentage of bits which can be incorrect, an alternative to the hamming cutoff. The - default of 0.25 means that the segment hashes can be up to 25% different - """ ... def matches(self, other_hash, region_cutoff=..., hamming_cutoff=..., bit_error_rate=...): - """ - Checks whether this hash matches another crop resistant hash, `other_hash`. - :param other_hash: The image multi hash to compare against - :param region_cutoff: The minimum number of regions which must have a matching hash - :param hamming_cutoff: The maximum hamming distance to a region hash in the target hash - :param bit_error_rate: Percentage of bits which can be incorrect, an alternative to the hamming cutoff. The - default of 0.25 means that the segment hashes can be up to 25% different - """ ... def best_match(self, other_hashes, hamming_cutoff=..., bit_error_rate=...): - """ - Returns the hash in a list which is the best match to the current hash - :param other_hashes: A list of image multi hashes to compare against - :param hamming_cutoff: The maximum hamming distance to a region hash in the target hash - :param bit_error_rate: Percentage of bits which can be incorrect, an alternative to the hamming cutoff. - Defaults to 0.25 if unset, which means the hash can be 25% different - """ ... -def crop_resistant_hash(image, hash_func=..., limit_segments=..., segment_threshold=..., min_segment_size=..., segmentation_image_size=...) -> ImageMultiHash: - """ - Creates a CropResistantHash object, by the algorithm described in the paper "Efficient Cropping-Resistant Robust - Image Hashing". DOI 10.1109/ARES.2014.85 - This algorithm partitions the image into bright and dark segments, using a watershed-like algorithm, and then does - an image hash on each segment. This makes the image much more resistant to cropping than other algorithms, with - the paper claiming resistance to up to 50% cropping, while most other algorithms stop at about 5% cropping. - - Note: Slightly different segmentations are produced when using pillow version 6 vs. >=7, due to a change in - rounding in the greyscale conversion. This leads to a slightly different result. - :param image: The image to hash - :param hash_func: The hashing function to use - :param limit_segments: If you have storage requirements, you can limit to hashing only the M largest segments - :param segment_threshold: Brightness threshold between hills and valleys. This should be static, putting it between - peak and trough dynamically breaks the matching - :param min_segment_size: Minimum number of pixels for a hashable segment - :param segmentation_image_size: Size which the image is resized to before segmentation - """ +def crop_resistant_hash( + image, + hash_func=..., + limit_segments=..., + segment_threshold=..., + min_segment_size=..., + segmentation_image_size=...) -> ImageMultiHash: ... diff --git a/typings/simplejson/errors.pyi b/typings/simplejson/errors.pyi deleted file mode 100644 index d98b7495..00000000 --- a/typings/simplejson/errors.pyi +++ /dev/null @@ -1,34 +0,0 @@ -""" -This type stub file was generated by pyright. -""" - -"""Error classes used by simplejson -""" -__all__ = ['JSONDecodeError'] -def linecol(doc, pos): # -> tuple[Unknown, Unknown]: - ... - -def errmsg(msg, doc, pos, end=...): # -> str: - ... - -class JSONDecodeError(ValueError): - """Subclass of ValueError with the following additional properties: - - msg: The unformatted error message - doc: The JSON document being parsed - pos: The start index of doc where parsing failed - end: The end index of doc where parsing failed (may be None) - lineno: The line corresponding to pos - colno: The column corresponding to pos - endlineno: The line corresponding to end (may be None) - endcolno: The column corresponding to end (may be None) - - """ - def __init__(self, msg, doc, pos, end=...) -> None: - ... - - def __reduce__(self): # -> tuple[Type[Self@JSONDecodeError], tuple[Unknown, Unknown, Unknown, Unknown]]: - ... - - -