diff --git a/.gitignore b/.gitignore index 53f054f..567dc28 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,6 @@ MANIFEST # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest -*.spec # Installer logs pip-log.txt diff --git a/README.md b/README.md index 4fdc92a..47ab411 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,24 @@ -# NexusDownloadFlow 2022 : Auto clicker script using computer vision +# NexusDownloadFlow: Auto-downloader for Nexus Mods -NexusDownloaderFlow (NDF) 2022 is a script that take screenshots and classify if any template match with the current -screenshot taken. It was made in order to automate process with `Wabbajack modlist installation of Nexus' mods` in which -you have to manually click on `Slow download` button is your NexusMods account is not premium. +NexusDownloadFlow (NDF) is a program that automates the download process with `Wabbajack modlist installation of Nexus +Mods` in which you have to manually click on `Slow download` button if your Nexus Mods account is not premium. -## How to use NDF 2022 ? +## How to use NexusDownloadFlow? -Just execute `NexusDownloadFlow 2022.exe` and open your NexusMods' download page. +### Without Wabbajack -## Auto clicker is not clicking +Execute `NexusDownloadFlow.exe` and open your Nexus Mods download page. -Do not worry, you have to replace the templates files where you installed NDF with the one you will screenshot: -`NexusDownloadFlow 2022/assets/template{x}.png` +### With Wabbajack -+ `template1.png` is the raw `Slow download` button -+ `template2.png` is the `Slow download` button with mouse over -+ `template3.png` is the `Click here` link appearing five seconds after clicking on `Slow download` button +Execute `NexusDownloadFlow.exe` while the mod list is downloading. -## Credits +## Auto-clicker is not clicking + +Open an issue [here](https://github.com/greg-ynx/NexusDownloadFlow/issues/new), and if possible, give the scenario in which you had this issue, which version of NDF you are using +and provide a screenshot of your logs or the contents of your current `{date}_ndf.log` file. -Thanks to @parsiad for inspiring me with his repository named `parsiad/nexus-autodl` -(I could not download his auto clicker). +## Credits -Requirements used for this script are : -+ PyAutoGUI~=0.9.53 -+ opencv-python==4.5.5.64 -+ mss~=6.1.0 \ No newline at end of file +Thanks to [parsiad](https://github.com/parsiad) for inspiring me with his repository named +[`parsiad/nexus-autodl`](https://github.com/parsiad/nexus-autodl). diff --git a/assets/template1.png b/assets/template1.png index 98a699f..30f0e69 100644 Binary files a/assets/template1.png and b/assets/template1.png differ diff --git a/assets/template2.png b/assets/template2.png index 82c7209..5b161f3 100644 Binary files a/assets/template2.png and b/assets/template2.png differ diff --git a/assets/template3.png b/assets/template3.png index 0bbd9a3..b04bd5c 100644 Binary files a/assets/template3.png and b/assets/template3.png differ diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..90aeef6 --- /dev/null +++ b/build.bat @@ -0,0 +1 @@ +pyinstaller build.spec --clean --workpath ./target --distpath ./target/dist \ No newline at end of file diff --git a/build.spec b/build.spec new file mode 100644 index 0000000..c169116 --- /dev/null +++ b/build.spec @@ -0,0 +1,41 @@ +# -*- mode: python ; coding: utf-8 -*- + +extra_files = [ + ('assets/*', 'assets'), + ('pyproject.toml', '.') +] + +a = Analysis( + ['main.py'], + pathex=[], + binaries=[], + datas=extra_files, + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='NexusDownloadFlow', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/config/ascii_art.py b/config/ascii_art.py new file mode 100644 index 0000000..6018489 --- /dev/null +++ b/config/ascii_art.py @@ -0,0 +1,29 @@ +"""Print NexusDownloadFlow ascii art.""" +import sys +from typing import Any + +from config.definitions import PYPROJECT_DATA + +ASCII_COLOR: str = "\033[33m" +ASCII_TEXT: str = """ + _ _ +| \\ | | +| \\| | _____ ___ _ ___ +| . ` |/ _ \\ \\/ / | | / __| +| |\\ | __/> <| |_| \\__ \\ +\\_| \\_/\\___/_/\\_\\__,_|___/ +______ _ _ ______ _ +| _ \\ | | | | | ___| | +| | | |_____ ___ __ | | ___ __ _ __| | | |_ | | _____ __ +| | | / _ \\ \\ /\\ / / '_ \\| |/ _ \\ / _` |/ _` | | _| | |/ _ \\ \\ /\\ / / +| |/ / (_) \\ V V /| | | | | (_) | (_| | (_| | | | | | (_) \\ V V / +|___/ \\___/ \\_/\\_/ |_| |_|_|\\___/ \\__,_|\\__,_| \\_| |_|\\___/ \\_/\\_/\ +""" + +PROJECT_DATA: Any = PYPROJECT_DATA.get("project") +PROJECT_VERSION: str = "v{0}".format(str(PROJECT_DATA.get("version"))) + + +def print_ascii_art() -> None: + """Print NexusDownloadFlow ascii art with the project version.""" + sys.stdout.write(ASCII_COLOR + ASCII_TEXT + PROJECT_VERSION + "\033[0m\n") diff --git a/config/definitions.py b/config/definitions.py index 8bdc1a8..fda781e 100644 --- a/config/definitions.py +++ b/config/definitions.py @@ -1,7 +1,29 @@ +"""Define global constants used in the project.""" import os +import sys +import tomllib +from typing import Any +_TEMP_DIRECTORY: str +_EXE_DIRECTORY: str = os.path.realpath(os.path.join(sys.executable, "..")) +_DEV_DIRECTORY: str = os.path.realpath(os.path.join(os.path.dirname(__file__), "..")) +MAIN_PATH: str +ASSETS_DIRECTORY: str +LOGS_DIRECTORY: str +PYPROJECT_DIRECTORY: str -ROOT_DIR = os.path.realpath(os.path.join(os.path.dirname(__file__), '..')) -main_path = os.path.join(ROOT_DIR, 'main.py') -assets_dir = os.path.join(ROOT_DIR, 'assets') +if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): + _TEMP_DIRECTORY = os.path.join(sys._MEIPASS) + MAIN_PATH = os.path.join(_TEMP_DIRECTORY, "main.py") + ASSETS_DIRECTORY = os.path.join(_TEMP_DIRECTORY, "assets") + LOGS_DIRECTORY = os.path.join(_EXE_DIRECTORY, "logs") + PYPROJECT_DIRECTORY = os.path.join(_TEMP_DIRECTORY, "pyproject.toml") +else: + MAIN_PATH = os.path.join(_DEV_DIRECTORY, "main.py") + ASSETS_DIRECTORY = os.path.join(_DEV_DIRECTORY, "assets") + LOGS_DIRECTORY = os.path.join(_DEV_DIRECTORY, "logs") + PYPROJECT_DIRECTORY = os.path.join(_DEV_DIRECTORY, "pyproject.toml") + +with open(PYPROJECT_DIRECTORY, "rb") as pyproject: + PYPROJECT_DATA: dict[str, Any] = tomllib.load(pyproject) diff --git a/config/ndf_logging.py b/config/ndf_logging.py new file mode 100644 index 0000000..bff3965 --- /dev/null +++ b/config/ndf_logging.py @@ -0,0 +1,78 @@ +"""Logging configuration.""" + +import logging +import os +import sys +import time +from logging import Handler +from typing import Iterable + +from config.definitions import LOGS_DIRECTORY + +_LOG_EXTENSION: str = ".log" +_NDF_STR: str = "ndf" +_LOGFILE_NAME: str = time.strftime("%Y_%m_%d_") + _NDF_STR + _LOG_EXTENSION + + +def _logs_directory_exists() -> bool: + """ + Check if the logs directory exists. + + :return: Bool value indicating if the logs directory exists. + """ + return os.path.exists(LOGS_DIRECTORY) + + +def _setup_logfile_path() -> str: + """ + Set up log file. + + :return: String representing the log file path. + """ + return os.path.join(LOGS_DIRECTORY, _LOGFILE_NAME) + + +def _stop_logging() -> None: + """Shut down the logger.""" + logging.shutdown() + + +def delete_logfile() -> None: + """Delete the log file.""" + logging.debug("Try to delete the current logfile...") + logfile_path: str = get_logfile_path() + _stop_logging() + if os.path.exists(logfile_path): + os.remove(path=logfile_path) + logging.debug("Logfile deleted.") + + +def get_logfile_path() -> str: + """ + Getter for the current log file path. + + :return: Log file path. + """ + return _setup_logfile_path() + + +def logging_report() -> None: + """Log report to open an issue on the project's repository.""" + logging.critical( + "Please report this exception to our repository on GitHub: " + "https://github.com/greg-ynx/NexusDownloadFlow/issues?q=is%3Aissue+is%3Aopen" + ) + + +def setup_logging() -> None: + """Set up logging configuration.""" + if not _logs_directory_exists(): + os.makedirs(LOGS_DIRECTORY) + _handlers: Iterable[Handler] = [logging.FileHandler(_setup_logfile_path()), logging.StreamHandler(sys.stdout)] + logging.basicConfig( + level=logging.INFO, + handlers=_handlers, + format="%(asctime)s | %(levelname)s | %(message)s", + datefmt="%d/%m/%Y - %H:%M:%S", + ) + logging.debug("Logger setup complete.") diff --git a/main.py b/main.py index 87edc61..3cb85e0 100644 --- a/main.py +++ b/main.py @@ -1,44 +1,18 @@ -import os -import time +"""Main executable file of NexusDownloadFlow.""" +import logging -import pyautogui -import cv2 -from mss import mss +from config.ascii_art import print_ascii_art +from config.ndf_logging import setup_logging +from scripts.ndf_run import try_run -from config.definitions import assets_dir -if __name__ == '__main__': - print('NexusDownloadFlow 2022 starting...') - print('Do not forget to replace the assets templates (1, 2 & 3) in order to match with the screenshots ' - 'taken from your monitor!') - try: - templates = [cv2.imread(os.path.join(assets_dir, 'template1.png')), - cv2.imread(os.path.join(assets_dir, 'template2.png')), - cv2.imread(os.path.join(assets_dir, 'template3.png'))] - with mss() as sct: - while True: - for i in range(1, 4): - template = templates[i - 1] - template_gray = cv2.cvtColor(template, cv2.COLOR_BGR2GRAY) - screenshot = cv2.imread(sct.shot()) - screenshot_gray = cv2.cvtColor(screenshot, cv2.COLOR_BGR2GRAY) - res = cv2.matchTemplate(screenshot_gray, template_gray, cv2.TM_SQDIFF) - min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res) - threshold = 3000 - if min_val < threshold: - print('Matching template!') - top_left = min_loc - target = (top_left[0] + template_gray.shape[1] / 2, top_left[1] + template_gray.shape[0] / 2) - pyautogui.leftClick(target) - break - time.sleep(6) - except SystemExit: - print('Exiting the program...') - raise - finally: - time.sleep(5) - if os.path.exists("monitor-1.png"): - os.remove("monitor-1.png") - else: - print("The file does not exist") - print('Program ended') +def main() -> None: + """NexusDownloadFlow main function.""" + setup_logging() + print_ascii_art() + logging.info("NexusDownloadFlow is starting...") + try_run() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..aa681a9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,50 @@ +[project] +name = "NexusDownloadFlow" +version = "2.0.0" +authors = [ + {name = "Gregory Ployart", email = "greg.ynx@gmail.com", alias = "greg-ynx"}, +] +description = "Auto-downloader program to automate Nexus modlist downloads for free." +readme = "README.md" +requires-python = ">=3.11" +release-date = 2023-10-01 + +[python] +version = "3.11.5" + +[github] +owner = "greg-ynx" +repository = "https://github.com/greg-ynx/NexusDownloadFlow" +issues = "https://github.com/greg-ynx/NexusDownloadFlow/issues" + +[tool.mypy] +python_version = "3.11" +disallow_untyped_defs = true +disallow_any_unimported = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = true + +[tool.ruff] +extend-select = [ + "W", + "I", + "N", + "D", + "S", + "T20", + "C4", + "SIM", + "TCH" +] +ignore = [ + "D203", + "D212" +] +fix = true +show-fixes = true +show-source = false +line-length = 120 +target-version = "py311" diff --git a/requirements.txt b/requirements.txt index 276df9a..1c6aba5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -PyAutoGUI~=0.9.53 -opencv-python~=4.5.5.64 -mss~=6.1.0 \ No newline at end of file +PyAutoGUI==0.9.54 +opencv-python==4.8.0.76 +mss==9.0.1 \ No newline at end of file diff --git a/scripts/ndf_params.py b/scripts/ndf_params.py new file mode 100644 index 0000000..a75f9e7 --- /dev/null +++ b/scripts/ndf_params.py @@ -0,0 +1,24 @@ +"""Parameters file.""" +import logging + + +def ask_to_keep_logfile() -> bool: + """ + Ask if the user wants to keep the log file. + + :return: Bool value representing whether to keep the log file or not. + True, if user's answer is "y" or "Y". + False, if user's answer is "n" or "N". + Will repeat if the input value is not valid. + """ + while True: + keep: str = str(input("Would you like to save the logfile? (y/n)\n")) + match keep: + case "y" | "Y": + logging.info("Logfile will be saved.") + return True + case "n" | "N": + logging.info("Logfile will be saved only if an exception/error occurred.") + return False + case _: + continue diff --git a/scripts/ndf_run.py b/scripts/ndf_run.py new file mode 100644 index 0000000..5c869f0 --- /dev/null +++ b/scripts/ndf_run.py @@ -0,0 +1,211 @@ +"""Run file.""" + +import logging +import os +from time import sleep +from typing import Sequence, cast + +from cv2 import COLOR_BGR2GRAY, TM_CCOEFF_NORMED, Canny, cvtColor, imread, matchTemplate, minMaxLoc, resize +from cv2.typing import MatLike +from mss import mss +from pyautogui import FailSafeException, Point, leftClick, moveTo, position + +from config.definitions import ASSETS_DIRECTORY +from config.ndf_logging import delete_logfile, get_logfile_path, logging_report +from scripts.ndf_params import ask_to_keep_logfile + +EDGE_MIN_VALUE: int = 50 +EDGE_MAX_VALUE: int = 200 +SCALES: list[float] = [ + 1.0, + 0.95789474, + 0.91578947, + 0.87368421, + 0.83157895, + 0.78947368, + 0.74736842, + 0.70526316, + 0.66315789, + 0.62105263, + 0.57894737, + 0.53684211, + 0.49473684, + 0.45263158, + 0.41052632, + 0.36842105, + 0.32631579, + 0.28421053, + 0.24210526, + 0.2, +] +SCREENSHOT: str = "screenshot.png" +TEMPLATES: list[MatLike] = [ + imread(os.path.join(ASSETS_DIRECTORY, "template1.png")), + imread(os.path.join(ASSETS_DIRECTORY, "template2.png")), + imread(os.path.join(ASSETS_DIRECTORY, "template3.png")), +] +THRESHOLD: float = 0.65 + + +def click_on_target(target_location: tuple[float, float]) -> None: + """ + Click on the target that has been identified and move the cursor to its previous location. + + :param target_location: Tuple of target coordinates. + """ + original_position: Point | tuple[int, int] = position() + leftClick(target_location) + moveTo(original_position) + + +def get_potential_match(screenshot: MatLike, template: MatLike) -> tuple[float, Sequence[int]]: + """ + Get the potential match value and its location. + + :param screenshot: Source for template matching. + :param template: Template to match. + :return: Tuple of potential match value and location. + """ + matches: MatLike = matchTemplate(screenshot, template, TM_CCOEFF_NORMED) + potential_match: tuple[float, float, Sequence[int], Sequence[int]] = minMaxLoc(matches) + max_value: float = potential_match[1] + max_location: Sequence[int] = potential_match[3] + return max_value, max_location + + +def if_monitors_left_top_present(monitors_size: dict[str, int]) -> tuple[int, int]: + """ + Handle Optional of monitors_left_top (if_present like). + + :param monitors_size: Dictionary containing left and top properties of the system's monitor(s). + :return: If present, tuple representing the left-top pixel's coordinates of the system's monitor(s). + """ + + def error_message(_key: str) -> str: + return f"Monitors' size '{_key}' value is None." + + monitors_left: int | None = monitors_size.get("left") + monitors_top: int | None = monitors_size.get("top") + if monitors_left is None: + raise ValueError(error_message("left")) + if monitors_top is None: + raise ValueError(error_message("top")) + return monitors_left, monitors_top + + +def init_templates() -> list[MatLike]: + """ + Return the list of edged templates. + + :return: List of edged templates. + """ + return [Canny(cvtColor(template, COLOR_BGR2GRAY), EDGE_MIN_VALUE, EDGE_MAX_VALUE) for template in TEMPLATES] + + +def is_match_found(match_value: float) -> bool: + """ + Check if a match is found. + + :param match_value: Value of the match to check. + :return: Bool value indicating whether a match is found or not. + """ + return match_value > THRESHOLD + + +def resize_screenshot(screenshot: MatLike, scale: float) -> MatLike: + """ + Resize the input screenshot. + + :param screenshot: Screenshot to resize. + :param scale: The scale factor to resize the screenshot. + :return: Resized screenshot. + """ + new_width: int = int(screenshot.shape[1] * scale) + new_height: int = int(screenshot.shape[0] * scale) + return cast(MatLike, resize(screenshot, (new_width, new_height))) + + +def multiscale_match_template( + templates: list[MatLike], screenshot: MatLike, left_top_coordinates: tuple[int, int] +) -> None: + """ + Apply multiscale template matching algorithm. + + :param templates: List of edged templates to match. + :param screenshot: Screenshot where the search is running. + :param left_top_coordinates: Left-top pixel of the system monitor(s). + """ + for scale in SCALES: + resized_screenshot: MatLike = resize_screenshot(screenshot, scale) + edged_screenshot: MatLike = Canny(resized_screenshot, 50, 200) + for template in templates: + potential_match: tuple[float, Sequence[int]] = get_potential_match(edged_screenshot, template) + potential_match_value: float = potential_match[0] + potential_match_location: Sequence[int] = potential_match[1] + if is_match_found(potential_match_value): + logging.info("Match found!") + match_location_x: int = potential_match_location[0] + match_location_y: int = potential_match_location[1] + match_left_top_location: tuple[int, int] = ( + match_location_x + left_top_coordinates[0], + match_location_y + left_top_coordinates[1], + ) + template_height: int = template.shape[0] + template_width: int = template.shape[1] + target: tuple[float, float] = ( + match_left_top_location[0] + template_width / 2, + match_left_top_location[1] + template_height / 2, + ) + click_on_target(target) + sleep(6) + return + + +def run() -> None: + """Run the auto-downloader.""" + logging.info("NexusDownloadFlow is running.") + edged_templates: list[MatLike] = init_templates() + with mss() as mss_instance: + while True: + monitors_size: dict[str, int] = mss_instance.monitors[0] + monitors_left_top: tuple[int, int] = if_monitors_left_top_present(monitors_size) + screenshot: MatLike = imread(next(mss_instance.save(mon=-1, output=SCREENSHOT))) + grayscale_screenshot: MatLike = cvtColor(screenshot, COLOR_BGR2GRAY) + multiscale_match_template(edged_templates, grayscale_screenshot, monitors_left_top) + + +def try_run() -> None: + """ + Try to run the auto-downloader. + + :raises KeyboardInterrupt: Raised when the user interrupts the program. + :raises ValueError: Should not be raised (open an issue on GitHub if it happens). + :raises Exception: For currently unknown exceptions (open an issue on GitHub if it happens). + """ + keep_logfile: bool = False + try: + keep_logfile = ask_to_keep_logfile() + run() + except KeyboardInterrupt: + logging.info("Exiting the program...") + except FailSafeException: + logging.error("Fail-safe triggered from mouse moving to a corner of the screen.") + keep_logfile = True + except ValueError as e: + logging.error(e) + logging_report() + keep_logfile = True + except Exception as e: + logging.exception(e) + logging_report() + keep_logfile = True + finally: + if os.path.exists(SCREENSHOT): + os.remove(SCREENSHOT) + else: + logging.warning("The screenshot does not exist.") + logging.info("Program ended.") + if keep_logfile: + logging.info(f"Find logfile at: { get_logfile_path() }") + else: + delete_logfile() diff --git a/test/demo/ndf_1.0.0_template_matching_demo.py b/test/demo/ndf_1.0.0_template_matching_demo.py new file mode 100644 index 0000000..6c42370 --- /dev/null +++ b/test/demo/ndf_1.0.0_template_matching_demo.py @@ -0,0 +1,78 @@ +""" +Test file is used to test NexusDownloadFlow's v1.0.0 algorithm. + +The algorithm used is the grayscale template matching and the TM_SQDIFF comparison method from OpenCV. +""" + +import os +import sys +import time + +import cv2 +import pyautogui +from cv2.typing import MatLike +from mss import mss + +from config.definitions import ASSETS_DIRECTORY + +SCREENSHOT: str = "screenshot.png" +TEST_TEXT: str = "[TEST] [1.0.0] " +THRESHOLD: int = 3000 + + +def _logging_test(text: str) -> None: + sys.stdout.write(TEST_TEXT + text + "\n") + + +def _load_templates() -> list[MatLike]: + return [ + cv2.imread(os.path.join(ASSETS_DIRECTORY, "template1.png")), + cv2.imread(os.path.join(ASSETS_DIRECTORY, "template2.png")), + cv2.imread(os.path.join(ASSETS_DIRECTORY, "template3.png")), + ] + + +def _init_templates() -> list[MatLike]: + return [cv2.cvtColor(template, cv2.COLOR_BGR2GRAY) for template in _load_templates()] + + +def _test_algorithm() -> None: + _logging_test("ndf-1.0.0-grayscale-template-matching.") + _logging_test("Comparaison method: TM_SQDIFF.") + try: + with mss() as mss_instance: + while True: + monitors_size = mss_instance.monitors[0] + monitors_left_top = (monitors_size.get("left"), monitors_size.get("top")) + screenshot = cv2.imread(next(mss_instance.save(mon=-1, output=SCREENSHOT))) + screenshot = cv2.cvtColor(screenshot, cv2.COLOR_BGR2GRAY) + for template in _init_templates(): + match_template = cv2.matchTemplate(screenshot, template, cv2.TM_SQDIFF) + min_value, _, min_location, _ = cv2.minMaxLoc(match_template) + if min_value < THRESHOLD: + _logging_test("Match found!") + match_left_top_location = ( + min_location[0] + monitors_left_top[0], + min_location[1] + monitors_left_top[1], + ) + template_height, template_width = template.shape + target = ( + match_left_top_location[0] + template_width / 2, + match_left_top_location[1] + template_height / 2, + ) + pyautogui.moveTo(target) + time.sleep(6) + break + except SystemExit: + _logging_test("Exiting the program...") + raise + finally: + if os.path.exists(SCREENSHOT): + os.remove(SCREENSHOT) + else: + _logging_test("The file does not exist") + _logging_test("Program ended") + + +if __name__ == "__main__": + _test_algorithm() diff --git a/test/demo/ndf_2.0.0-snapshot_template_matching_demo.py b/test/demo/ndf_2.0.0-snapshot_template_matching_demo.py new file mode 100644 index 0000000..a4064b3 --- /dev/null +++ b/test/demo/ndf_2.0.0-snapshot_template_matching_demo.py @@ -0,0 +1,78 @@ +""" +Test file is used to test NexusDownloadFlow's v2.0.0-SNAPSHOT algorithm. + +The algorithm used is the edges template matching and the TM_CCOEFF_NORMED comparison method from OpenCV. +""" +import os +import sys +import time + +import cv2 +import pyautogui +from cv2.typing import MatLike +from mss import mss + +from config.definitions import ASSETS_DIRECTORY + +SCREENSHOT: str = "screenshot.png" +TEST_TEXT: str = "[TEST] [2.0.0-SNAPSHOT] " +THRESHOLD: float = 0.65 + + +def _logging_test(text: str) -> None: + sys.stdout.write(TEST_TEXT + text + "\n") + + +def _load_templates() -> list[MatLike]: + return [ + cv2.imread(os.path.join(ASSETS_DIRECTORY, "template1.png")), + cv2.imread(os.path.join(ASSETS_DIRECTORY, "template2.png")), + cv2.imread(os.path.join(ASSETS_DIRECTORY, "template3.png")), + ] + + +def _init_templates() -> list[MatLike]: + return [cv2.Canny(cv2.cvtColor(template, cv2.COLOR_BGR2GRAY), 50, 200) for template in _load_templates()] + + +def _test_algorithm() -> None: + _logging_test("ndf-2.0.0-snapshot-edges-template-matching.") + _logging_test("Comparaison method: TM_CCOEFF_NORMED.") + try: + with mss() as mss_instance: + while True: + monitors_size = mss_instance.monitors[0] + monitors_left_top = (monitors_size.get("left"), monitors_size.get("top")) + screenshot = cv2.imread(next(mss_instance.save(mon=-1, output=SCREENSHOT))) + screenshot = cv2.cvtColor(screenshot, cv2.COLOR_BGR2GRAY) + screenshot = cv2.Canny(screenshot, 50, 200) + for template in _init_templates(): + match_template = cv2.matchTemplate(screenshot, template, cv2.TM_CCOEFF_NORMED) + _, max_value, _, max_location = cv2.minMaxLoc(match_template) + if max_value > THRESHOLD: + _logging_test("Match found!") + match_left_top_location = ( + max_location[0] + monitors_left_top[0], + max_location[1] + monitors_left_top[1], + ) + template_height, template_width = template.shape + target = ( + match_left_top_location[0] + template_width / 2, + match_left_top_location[1] + template_height / 2, + ) + pyautogui.moveTo(target) + time.sleep(6) + break + except SystemExit: + _logging_test("Exiting the program...") + raise + finally: + if os.path.exists(SCREENSHOT): + os.remove(SCREENSHOT) + else: + _logging_test("The file does not exist") + _logging_test("Program ended") + + +if __name__ == "__main__": + _test_algorithm() diff --git a/test/demo/ndf_2.0.0_template_matching_demo.py b/test/demo/ndf_2.0.0_template_matching_demo.py new file mode 100644 index 0000000..68020f0 --- /dev/null +++ b/test/demo/ndf_2.0.0_template_matching_demo.py @@ -0,0 +1,141 @@ +""" +Test file is used to test NexusDownloadFlow's v2.0.0 algorithm. + +The algorithm used is the multiscale template matching and the TM_CCOEFF_NORMED comparison method from OpenCV. +""" +import os +import sys +from time import sleep +from typing import Sequence, cast + +from cv2 import COLOR_BGR2GRAY, TM_CCOEFF_NORMED, Canny, cvtColor, imread, matchTemplate, minMaxLoc, resize +from cv2.typing import MatLike +from mss import mss +from pyautogui import moveTo + +from config.definitions import ASSETS_DIRECTORY + +EDGE_MIN_VALUE: int = 50 +EDGE_MAX_VALUE: int = 200 +SCALES: list[float] = [ + 1.0, + 0.95789474, + 0.91578947, + 0.87368421, + 0.83157895, + 0.78947368, + 0.74736842, + 0.70526316, + 0.66315789, + 0.62105263, + 0.57894737, + 0.53684211, + 0.49473684, + 0.45263158, + 0.41052632, + 0.36842105, + 0.32631579, + 0.28421053, + 0.24210526, + 0.2, +] +SCREENSHOT: str = "screenshot.png" +TEMPLATES: list[MatLike] = [ + imread(os.path.join(ASSETS_DIRECTORY, "template1.png")), + imread(os.path.join(ASSETS_DIRECTORY, "template2.png")), + imread(os.path.join(ASSETS_DIRECTORY, "template3.png")), +] +TEST_TEXT: str = "[TEST] [2.0.0] " +THRESHOLD: float = 0.65 + + +def _logging_test(text: str) -> None: + sys.stdout.write(TEST_TEXT + text + "\n") + + +def _init_templates() -> list[MatLike]: + return [Canny(cvtColor(template, COLOR_BGR2GRAY), EDGE_MIN_VALUE, EDGE_MAX_VALUE) for template in TEMPLATES] + + +def _resize_screenshot(screenshot: MatLike, scale: float) -> MatLike: + new_width: int = int(screenshot.shape[1] * scale) + new_height: int = int(screenshot.shape[0] * scale) + return cast(MatLike, resize(screenshot, (new_width, new_height))) + + +def _get_potential_match(screenshot: MatLike, template: MatLike) -> tuple[float, Sequence[int]]: + matches: MatLike = matchTemplate(screenshot, template, TM_CCOEFF_NORMED) + potential_match: tuple[float, float, Sequence[int], Sequence[int]] = minMaxLoc(matches) + max_value: float = potential_match[1] + max_location: Sequence[int] = potential_match[3] + return max_value, max_location + + +def _if_monitors_left_top_present(monitors_size: dict[str, int]) -> tuple[int, int]: + monitors_left: int | None = monitors_size.get("left") + monitors_top: int | None = monitors_size.get("top") + if monitors_left is None: + raise ValueError("monitors_size 'left' value is None") + if monitors_top is None: + raise ValueError("monitors_size 'top' value is None") + return monitors_left, monitors_top + + +def _is_match_found(match_value: float) -> bool: + return match_value > THRESHOLD + + +def _multiscale_match_template( + templates: list[MatLike], screenshot: MatLike, left_top_coordinates: tuple[int, int] +) -> None: + for scale in SCALES: + resized_screenshot: MatLike = _resize_screenshot(screenshot, scale) + edged_screenshot: MatLike = Canny(resized_screenshot, 50, 200) + for template in templates: + potential_match: tuple[float, Sequence[int]] = _get_potential_match(edged_screenshot, template) + potential_match_value: float = potential_match[0] + potential_match_location: Sequence[int] = potential_match[1] + if _is_match_found(potential_match_value): + _logging_test("Match found!") + match_location_x: int = potential_match_location[0] + match_location_y: int = potential_match_location[1] + match_left_top_location: tuple[int, int] = ( + match_location_x + left_top_coordinates[0], + match_location_y + left_top_coordinates[1], + ) + template_height: int = template.shape[0] + template_width: int = template.shape[1] + target: tuple[float, float] = ( + match_left_top_location[0] + template_width / 2, + match_left_top_location[1] + template_height / 2, + ) + moveTo(target) + sleep(6) + return + + +def _test_algorithm() -> None: + _logging_test("ndf-2.0.0-multiscale-template-matching.") + _logging_test("Comparaison method: TM_CCOEFF_NORMED.") + edged_templates: list[MatLike] = _init_templates() + try: + with mss() as mss_instance: + while True: + monitors_size: dict[str, int] = mss_instance.monitors[0] + monitors_left_top: tuple[int, int] = _if_monitors_left_top_present(monitors_size) + screenshot: MatLike = imread(next(mss_instance.save(mon=-1, output=SCREENSHOT))) + grayscale_screenshot: MatLike = cvtColor(screenshot, COLOR_BGR2GRAY) + _multiscale_match_template(edged_templates, grayscale_screenshot, monitors_left_top) + except (SystemExit, KeyboardInterrupt): + _logging_test("Exiting the program...") + sys.exit(0) + finally: + if os.path.exists(SCREENSHOT): + os.remove(SCREENSHOT) + else: + _logging_test("The file does not exist") + _logging_test("Program ended") + + +if __name__ == "__main__": + _test_algorithm()