From d015689a6fe3485c6e94738fd26e62d0b67ae231 Mon Sep 17 00:00:00 2001 From: anima Date: Tue, 21 Apr 2026 18:01:04 +0200 Subject: [PATCH] inital working version --- source/__init__.py | 3 + source/command.py | 66 +++++++++++++++ source/window.py | 197 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 source/__init__.py create mode 100644 source/command.py create mode 100644 source/window.py diff --git a/source/__init__.py b/source/__init__.py new file mode 100644 index 0000000..ad1f6a5 --- /dev/null +++ b/source/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from .window import Window +from .command import Command \ No newline at end of file diff --git a/source/command.py b/source/command.py new file mode 100644 index 0000000..08efa3c --- /dev/null +++ b/source/command.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +from pathlib import Path +import logging + +logging.basicConfig(format='[%(asctime)s] %(process)s %(levelname)s {%(filename)s:%(lineno)d} - %(message)s', level=logging.DEBUG) + +try: + import cv2 +except ImportError as e: + logging.error(f"Missing dependency: {e}") + logging.error("Install with: pip install opencv-python") + sys.exit(1) + + + +class Command: + """Dataclass for commands""" + __AUTHOR__ = 'anima' + __VERSION__ = '1.0.0' + + image_dir: Path = Path('images/') + supported_extensions = {".png", ".jpg", ".jpeg", ".bmp"} + + def __init__(self, image_name: str, commands: list, counter: int = None, threshold: float = 0.95, position: tuple = None): + self.valid = False + self.name = image_name + self.commands = commands + self.threshold = threshold + self.position = position + self.pos_dif = 10 + self.counter = counter + self.count = 0 + self.load_image() + + def load_image(self) -> bool: + """search, load and save image convertet as matchable object + + Returns: + bool: Return True if image load successfull + """ + images = list(self.image_dir.rglob('*' + self.name + '*')) + if len(images) > 0: + for image in images: + logging.debug(f'found image: {image.name} (pattern: {self.name})') + if len(images) > 1: + logging.warning(f'more than one images found for pattern {self.name}, can not load command') + return False + elif len(images) == 1: + logging.info(f'load image for command {self.name}') + else: + logging.warning('no images found for pattern {self.name}') + return False + + if image.suffix.lower() not in self.supported_extensions: + logging.warning(f'image type of {image} is not supported') + return False + + self.image = cv2.imread(str(image), cv2.IMREAD_COLOR) + if image is None: + logging.warning(f'could not load {image}') + else: + self.valid = True + + return self.valid + + def check_position(self, ) \ No newline at end of file diff --git a/source/window.py b/source/window.py new file mode 100644 index 0000000..b38ef12 --- /dev/null +++ b/source/window.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +import subprocess +import sys +import time +import logging + +logging.basicConfig(format='[%(asctime)s] %(process)s %(levelname)s {%(filename)s:%(lineno)d} - %(message)s', level=logging.DEBUG) + +try: + import cv2 + import mss + import numpy + from PIL import Image +except ImportError as e: + logging.error(f"Missing dependency: {e}") + logging.error("Install with: pip install mss Pillow opencv-python numpy") + sys.exit(1) + +from .command import Command + + +class Window: + __AUTHOR__ = 'anima' + __VERSION__ = '1.0.0' + + key_press_duration = 0.1 + supported_keys = ['return', 'Up', 'Down', 'Right', 'Left'] + + @staticmethod + def check_requrements() -> None: + ## xdotool + try: + subprocess.run(["xdotool"], capture_output=True) + except FileNotFoundError: + logging.error("xdotool not found. Install it first.") + sys.exit(1) + + def __init__(self, window_name: str, monitor_id: int = 1, poll_intervall: float = 1): + self.check_requrements() + self.window_name = window_name + self.monitor_id = monitor_id + self.window_id = None + self.poll_intervall = poll_intervall + self.commands = list() + self.dry_run = False + + + def run(self): + if not len(self.commands) > 0: + logging.error(f'no commands to run found') + sys.exit(1) + with mss.mss() as self.sct: + while True: + command = self.match_screen() + # print(self.match_screen()) + if command: + self.activate_window() + for key in command.commands: + if isinstance(key, str): + self.send_key(key) + elif isinstance(key, int) or isinstance(key, float): + time.sleep(key) + time.sleep(self.poll_intervall) + + ## Screen Managament + def grab_screen(self) -> numpy.ndarray: + """Capture screen and return as normalized BGR numpy array. + + Returns: + numpy.ndarray: screenshot converted as matchable + """ + screenshot = self.sct.grab(self.sct.monitors[self.monitor_id]) + screen = numpy.array(screenshot) + screen = cv2.cvtColor(screen, cv2.COLOR_BGRA2BGR) + return screen + + def match_screen(self) -> Command | None: + """match screenshot with all known commands + + Returns: + Command | None: command with hightest screen/image match + """ + best_match = None + best_pos = None + best_value = 0.0 + screen = self.grab_screen() + + screen_gray = cv2.cvtColor(screen, cv2.COLOR_BGR2GRAY) + for command in self.commands: + img_h, img_w = command.image.shape[:2] + screen_h, screen_w = screen.shape[:2] + + if img_h > screen_h or img_w > screen_w: + continue + + img_gray = cv2.cvtColor(command.image, cv2.COLOR_BGR2GRAY) + result = cv2.matchTemplate(screen_gray, img_gray, cv2.TM_CCOEFF_NORMED) + _, match_value, _, match_pos = cv2.minMaxLoc(result) + + if match_value >= command.threshold and match_value > best_value: + logging.debug(f'new best match: {command.name} ({match_value=}%, {match_pos=})') + best_match = command + best_value = match_value + best_pos = match_pos + + if best_match: + return best_match + return None + + ## Window Management + def _get_window_id(self) -> bool: + """Find window ID by name using xdotool. + + Returns: + bool: True if window found and id saved + """ + result = subprocess.run( + ["xdotool", "search", "--name", self.window_name], + capture_output=True, text=True + ) + ids = result.stdout.strip().splitlines() + logging.debug(f'found ids with string {self.window_name=}: {ids=}') + if ids: + # last match is usually the main window + self.window_id = ids[-1] + logging.debug(f'used id: {self.window_id=}') + return True + logging.debug(f'no matching ids found for {self.window_name=}') + return False + + def activate_window(self) -> bool: + """activate given window + + Returns: + bool: True if window found and activate + """ + # TODO: replace search with id ? + if self.dry_run: + logging.info(f'dry-run: activate window {self.window_name=}') + return True + + # TODO: window_id insteat? + result = subprocess.run( + ['xdotool', 'search', '--name', self.window_name, 'windowactivate'], + capture_output=True, text=True + ) + + if result.returncode: return True + return False + + ## Key Management + def send_key(self, key_name: str = None) -> bool: + """Send a key via xdotool, optionally targeting a specific window. + + Args: + key_name (str, optional): xdotool name of key to press. Defaults to None. + + Returns: + bool: True if key send successfully + """ + if not isinstance(key_name, str): + logging.error(f'given key is not a string: {key_name=} ({type(key_name)})') + return False + #=> disabled because no need atm + # if not key_name in self.supported_keys: + # logging.error(f'given key is not a xdotool {key_name=}, supoprtet keys a: {self.supportet_keys}') + # return False + if self.dry_run: + logging.info(f'dry-run: send {key_name=}') + return True + + if self.window_id: + logging.debug(f'press {key_name=} for window {self.window_id=} ({self.window_name=})') + key_down = subprocess.run(["xdotool", "keydown", "--window", self.window_id, key_name]) + time.sleep(self.key_press_duration) + key_up = subprocess.run(["xdotool", "keyup", "--window", self.window_id, key_name]) + if key_down.returncode and key_up.returncode: + return True + else: # TODO: only if window_id known ? + logging.debug(f'press {key_name=}') + key_down = subprocess.run(["xdotool", "keydown", key_name]) + time.sleep(self.key_press_duration) + key_up = subprocess.run(["xdotool", "keyup", key_name]) + if key_down.returncode and key_up.returncode: + return True + return False + +def main(): + check_requrements() + if 1 <= args.verbose <= 3: + logging.getLogger().setLevel(40 - (args.verbose * 10)) + game = Saki('Final Fantasy X') + + + +if __name__ == "__main__": + main()