commit f41285b7a49afc10286afbcd848d14cf209d04be Author: yoshaman Date: Wed Jul 29 21:12:25 2026 +0300 Initial pixel hunter implementation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ea6a4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.venv/ +__pycache__/ +*.py[cod] + +build/ +dist/ +*.spec + +.idea/ +.vscode/ diff --git a/actions.py b/actions.py new file mode 100644 index 0000000..f8639ea --- /dev/null +++ b/actions.py @@ -0,0 +1,10 @@ +import pyautogui + + +def move_to(x, y): + pyautogui.moveTo(x, y, duration=0.15) + pyautogui.keyDown('ctrl') + try: + pyautogui.click() + finally: + pyautogui.keyUp('ctrl') diff --git a/capture.py b/capture.py new file mode 100644 index 0000000..87877bc --- /dev/null +++ b/capture.py @@ -0,0 +1,55 @@ +import cv2 +import numpy as np +import tkinter as tk +from PIL import ImageGrab + + +class CaptureOverlay: + def __init__(self, parent, output_path='template.png'): + self.parent = parent + self.output_path = output_path + self.saved = False + self.root = tk.Toplevel(parent) + self.root.attributes('-fullscreen', True) + self.root.attributes('-alpha', 0.3) + self.root.configure(bg='black') + self.root.wait_visibility() + self.root.attributes('-topmost', True) + + self.start_x = self.start_y = None + self.rect = None + self.canvas = tk.Canvas(self.root, cursor='cross', highlightthickness=0) + self.canvas.pack(fill='both', expand=True) + + self.canvas.bind('', self.on_press) + self.canvas.bind('', self.on_drag) + self.canvas.bind('', self.on_release) + + self.result = None + self.parent.wait_window(self.root) + + def on_press(self, e): + self.start_x, self.start_y = e.x, e.y + self.rect = self.canvas.create_rectangle(e.x, e.y, e.x, e.y, outline='red', width=3) + + def on_drag(self, e): + self.canvas.coords(self.rect, self.start_x, self.start_y, e.x, e.y) + + def on_release(self, e): + x1 = min(self.start_x, e.x) + y1 = min(self.start_y, e.y) + x2 = max(self.start_x, e.x) + y2 = max(self.start_y, e.y) + w, h = x2 - x1, y2 - y1 + + if w < 10 or h < 10: + self.root.destroy() + return + + self.root.withdraw() + self.root.update() + img = ImageGrab.grab(bbox=(x1, y1, x2, y2)) + img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) + self.saved = bool(cv2.imwrite(self.output_path, img)) + + self.root.destroy() diff --git a/config.json b/config.json new file mode 100644 index 0000000..fd5ffbe --- /dev/null +++ b/config.json @@ -0,0 +1,10 @@ +{ + "search_mode": "purple_frame", + "confidence": 0.72, + "scan_interval_ms": 300, + "template_path": "target.png", + "frame_hsv_lower": [125, 60, 120], + "frame_hsv_upper": [150, 255, 255], + "frame_min_size": 40, + "frame_min_rectangularity": 0.8 +} diff --git a/gui.py b/gui.py new file mode 100644 index 0000000..80c2bb4 --- /dev/null +++ b/gui.py @@ -0,0 +1,64 @@ +import tkinter as tk +from datetime import datetime + + +class LogWindow: + def __init__(self): + self.root = tk.Tk() + self.root.title('Screen Clicker') + self.root.geometry('420x220+0+0') + self.root.attributes('-topmost', True) + self.root.protocol('WM_DELETE_WINDOW', self.on_close) + + self.running = True + self.screenshot_requested = False + self.capture_requested = False + + self.text = tk.Text( + self.root, bg='#1a1a2e', fg='#e0e0e0', + font=('Consolas', 10), state='disabled', relief='flat' + ) + self.text.pack(fill='both', expand=True) + + controls = tk.Frame(self.root) + controls.pack(fill='x') + tk.Button( + controls, + text='From screenshot', + command=self.request_screenshot, + ).pack(side='left', padx=4, pady=4) + tk.Button( + controls, + text='Capture screen', + command=self.request_capture, + ).pack(side='left', padx=4, pady=4) + tk.Button( + controls, + text='Stop', + command=self.on_close, + ).pack(side='right', padx=4, pady=4) + + def log(self, msg): + ts = datetime.now().strftime('%H:%M:%S') + self.text.configure(state='normal') + self.text.insert('end', f'[{ts}] {msg}\n') + self.text.see('end') + self.text.configure(state='disabled') + try: + self.root.update() + except: + pass + + def update(self): + if self.running: + self.root.update() + + def request_screenshot(self): + self.screenshot_requested = True + + def request_capture(self): + self.capture_requested = True + + def on_close(self): + self.running = False + self.root.destroy() diff --git a/main.py b/main.py new file mode 100644 index 0000000..fbc739d --- /dev/null +++ b/main.py @@ -0,0 +1,148 @@ +import json +import sys +import os +import time +import cv2 +import tkinter as tk +import ctypes + + +def enable_dpi_awareness(): + """Keep screenshot pixels and mouse coordinates in the same coordinate system.""" + try: + ctypes.windll.shcore.SetProcessDpiAwareness(2) + except Exception: + try: + ctypes.windll.user32.SetProcessDPIAware() + except Exception: + pass + + +enable_dpi_awareness() + +os.chdir(os.path.dirname(sys.executable) + if getattr(sys, 'frozen', False) + else os.path.dirname(__file__)) + +from capture import CaptureOverlay +from scanner import Scanner +from actions import move_to +from gui import LogWindow +from screenshot_selector import select_from_screenshot + + +def main(): + log = None + try: + with open('config.json') as f: + config = json.load(f) + + log = LogWindow() + if not os.path.exists(config['template_path']): + log.log('Choose a screenshot, then mark the area to find...') + if not select_from_screenshot(log.root, config['template_path']): + log.log('Selection cancelled.') + return + + scanner = Scanner(config) + log.log(f'Template loaded: {scanner.tw}x{scanner.th}') + sys.stderr.write(f'[DEBUG] Scanner loaded template {scanner.tw}x{scanner.th}\n') + + recapture_flag = [False] + clicked = False + + try: + import keyboard + keyboard.add_hotkey('ctrl+shift+r', lambda: recapture_flag.__setitem__(0, True)) + keyboard.add_hotkey( + 'ctrl+shift+s', + lambda: setattr(log, 'screenshot_requested', True), + ) + log.log('Ctrl+Shift+S = choose from screenshot') + log.log('Ctrl+Shift+R = capture from screen') + sys.stderr.write('[DEBUG] hotkey registered\n') + except Exception as e: + log.log(f'Hotkey not available (run as admin?): {e}') + sys.stderr.write(f'[DEBUG] hotkey failed: {e}\n') + + log.log('Scanning started') + sys.stderr.write('[DEBUG] main loop started\n') + + cycle = 0 + while log.running: + if log.screenshot_requested: + log.screenshot_requested = False + log.log('Choose a screenshot and mark the exact target...') + if select_from_screenshot(log.root, config['template_path']): + scanner = Scanner(config) + clicked = False + log.log(f'New template loaded: {scanner.tw}x{scanner.th}') + else: + log.log('Selection cancelled; keeping the previous template.') + continue + + if log.capture_requested or recapture_flag[0]: + log.capture_requested = False + recapture_flag[0] = False + log.log('Recapture: select new area...') + capture = CaptureOverlay(log.root, config['template_path']) + if not capture.saved: + log.log('Recapture cancelled; keeping the previous template.') + continue + scanner.template = cv2.imread(config['template_path']) + if scanner.template is None: + log.log('Error: new template not saved') + else: + scanner.th, scanner.tw = scanner.template.shape[:2] + log.log('New template saved!') + + screen = scanner.grab_screen() + result = scanner.find(screen) + cycle += 1 + + if result and not clicked: + x, y = result + log.log(f'Match: ({x}, {y}) score={scanner.last_score:.3f}') + sys.stderr.write(f'[MATCH] score={scanner.last_score:.3f}\n') + move_to(x, y) + log.log(f'Ctrl+clicked at ({x}, {y})') + clicked = True + elif not result: + clicked = False + if cycle % 10 == 0: + sys.stderr.write(f'[SCAN] score={scanner.last_score:.3f} (need {config["confidence"]})\n') + if scanner.last_score > 0.05: + log.log(f'Best score: {scanner.last_score:.3f} (need {config["confidence"]})') + + if not log.running: + break + log.update() + time.sleep(config['scan_interval_ms'] / 1000) + + except (KeyboardInterrupt, tk.TclError): + pass + except Exception as e: + msg = f'[FATAL] {e}' + sys.stderr.write(msg + '\n') + if log is not None: + try: + log.log(msg) + except Exception: + pass + raise + finally: + try: + import keyboard + keyboard.unhook_all_hotkeys() + except Exception: + pass + + if log is not None and log.running: + try: + log.log('Stopped') + except Exception: + pass + + +if __name__ == '__main__': + main() diff --git a/reference.png b/reference.png new file mode 100644 index 0000000..58ffb45 Binary files /dev/null and b/reference.png differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ff94be3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +opencv-python +pyautogui +numpy +keyboard +Pillow +pyinstaller diff --git a/run_debug.bat b/run_debug.bat new file mode 100644 index 0000000..67ff92d --- /dev/null +++ b/run_debug.bat @@ -0,0 +1,14 @@ +@echo off +cd /d "%~dp0" +if not exist ".venv\Scripts\python.exe" ( + echo [ERROR] Local Python environment not found: .venv + echo Recreate it and install requirements before starting. + goto end +) + +".venv\Scripts\python.exe" main.py + +:end +echo. +echo Press any key to exit... +pause >nul diff --git a/scanner.py b/scanner.py new file mode 100644 index 0000000..2e423bb --- /dev/null +++ b/scanner.py @@ -0,0 +1,85 @@ +import cv2 +import numpy as np +from PIL import ImageGrab + + +class Scanner: + def __init__(self, config): + self.config = config + self.template = cv2.imread(config['template_path']) + if self.template is None: + raise FileNotFoundError(f"Template not found: {config['template_path']}") + self.th, self.tw = self.template.shape[:2] + self.last_score = 0.0 + self.last_box = None + + def find(self, screen): + if self.config.get('search_mode') == 'purple_frame': + return self.find_purple_frame(screen) + + screen_h, screen_w = screen.shape[:2] + if self.tw > screen_w or self.th > screen_h: + self.last_score = 0.0 + self.last_box = None + return None + + res = cv2.matchTemplate(screen, self.template, cv2.TM_CCOEFF_NORMED) + _, max_val, _, max_loc = cv2.minMaxLoc(res) + self.last_score = max_val + if max_val >= self.config['confidence']: + left, top = max_loc + self.last_box = (left, top, self.tw, self.th) + cx = left + self.tw // 2 + cy = top + self.th // 2 + return (cx, cy) + self.last_box = None + return None + + def find_purple_frame(self, screen): + """Find the rectangular purple highlight, ignoring everything inside it.""" + hsv = cv2.cvtColor(screen, cv2.COLOR_BGR2HSV) + lower = np.array( + self.config.get('frame_hsv_lower', [125, 60, 120]), + dtype=np.uint8, + ) + upper = np.array( + self.config.get('frame_hsv_upper', [150, 255, 255]), + dtype=np.uint8, + ) + mask = cv2.inRange(hsv, lower, upper) + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) + mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) + + contours, _ = cv2.findContours( + mask, + cv2.RETR_EXTERNAL, + cv2.CHAIN_APPROX_SIMPLE, + ) + min_size = int(self.config.get('frame_min_size', 40)) + min_rectangularity = float( + self.config.get('frame_min_rectangularity', 0.80) + ) + candidates = [] + + for contour in contours: + x, y, w, h = cv2.boundingRect(contour) + if w < min_size or h < min_size: + continue + rectangularity = cv2.contourArea(contour) / float(w * h) + if rectangularity < min_rectangularity: + continue + candidates.append((w * h, rectangularity, x, y, w, h)) + + if not candidates: + self.last_score = 0.0 + self.last_box = None + return None + + _, rectangularity, x, y, w, h = max(candidates) + self.last_score = rectangularity + self.last_box = (x, y, w, h) + return (x + w // 2, y + h // 2) + + def grab_screen(self): + img = ImageGrab.grab() + return cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) diff --git a/screenshot_selector.py b/screenshot_selector.py new file mode 100644 index 0000000..55346d6 --- /dev/null +++ b/screenshot_selector.py @@ -0,0 +1,109 @@ +import tkinter as tk +from tkinter import filedialog +from PIL import Image, ImageTk + + +class ScreenshotSelector: + def __init__(self, parent, output_path, initial_path=None): + self.parent = parent + self.output_path = output_path + self.saved = False + self.start_x = None + self.start_y = None + self.rect = None + + path = initial_path + if not path: + path = filedialog.askopenfilename( + parent=parent, + title='Choose a reference screenshot', + filetypes=[ + ('Images', '*.png *.jpg *.jpeg *.bmp *.webp'), + ('All files', '*.*'), + ], + ) + if not path: + return + + self.image = Image.open(path).convert('RGB') + max_w = max(300, parent.winfo_screenwidth() - 100) + max_h = max(300, parent.winfo_screenheight() - 160) + self.scale = min(1.0, max_w / self.image.width, max_h / self.image.height) + shown_size = ( + max(1, round(self.image.width * self.scale)), + max(1, round(self.image.height * self.scale)), + ) + self.shown = self.image.resize(shown_size, Image.Resampling.LANCZOS) + + self.root = tk.Toplevel(parent) + self.root.title('Select the object or area to find') + self.root.attributes('-topmost', True) + self.root.protocol('WM_DELETE_WINDOW', self.root.destroy) + + label = tk.Label( + self.root, + text='Drag a rectangle around the exact object/area to find', + padx=8, + pady=6, + ) + label.pack(fill='x') + + self.canvas = tk.Canvas( + self.root, + width=shown_size[0], + height=shown_size[1], + cursor='cross', + highlightthickness=0, + ) + self.canvas.pack() + self.photo = ImageTk.PhotoImage(self.shown) + self.canvas.create_image(0, 0, image=self.photo, anchor='nw') + self.canvas.bind('', self.on_press) + self.canvas.bind('', self.on_drag) + self.canvas.bind('', self.on_release) + + self.root.grab_set() + self.parent.wait_window(self.root) + + def on_press(self, event): + self.start_x, self.start_y = event.x, event.y + if self.rect is not None: + self.canvas.delete(self.rect) + self.rect = self.canvas.create_rectangle( + event.x, + event.y, + event.x, + event.y, + outline='#ff00ff', + width=3, + ) + + def on_drag(self, event): + if self.rect is not None: + self.canvas.coords( + self.rect, + self.start_x, + self.start_y, + event.x, + event.y, + ) + + def on_release(self, event): + x1, x2 = sorted((self.start_x, event.x)) + y1, y2 = sorted((self.start_y, event.y)) + if x2 - x1 < 10 or y2 - y1 < 10: + return + + box = ( + max(0, round(x1 / self.scale)), + max(0, round(y1 / self.scale)), + min(self.image.width, round(x2 / self.scale)), + min(self.image.height, round(y2 / self.scale)), + ) + self.image.crop(box).save(self.output_path) + self.saved = True + self.root.destroy() + + +def select_from_screenshot(parent, output_path, initial_path=None): + return ScreenshotSelector(parent, output_path, initial_path).saved diff --git a/target.png b/target.png new file mode 100644 index 0000000..e2f259d Binary files /dev/null and b/target.png differ diff --git a/template.png b/template.png new file mode 100644 index 0000000..58ffb45 Binary files /dev/null and b/template.png differ