Initial pixel hunter implementation

This commit is contained in:
yoshaman 2026-07-29 21:12:25 +03:00
commit f41285b7a4
13 changed files with 511 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
.venv/
__pycache__/
*.py[cod]
build/
dist/
*.spec
.idea/
.vscode/

10
actions.py Normal file
View File

@ -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')

55
capture.py Normal file
View File

@ -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('<ButtonPress-1>', self.on_press)
self.canvas.bind('<B1-Motion>', self.on_drag)
self.canvas.bind('<ButtonRelease-1>', 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()

10
config.json Normal file
View File

@ -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
}

64
gui.py Normal file
View File

@ -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()

148
main.py Normal file
View File

@ -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()

BIN
reference.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

6
requirements.txt Normal file
View File

@ -0,0 +1,6 @@
opencv-python
pyautogui
numpy
keyboard
Pillow
pyinstaller

14
run_debug.bat Normal file
View File

@ -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

85
scanner.py Normal file
View File

@ -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)

109
screenshot_selector.py Normal file
View File

@ -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('<ButtonPress-1>', self.on_press)
self.canvas.bind('<B1-Motion>', self.on_drag)
self.canvas.bind('<ButtonRelease-1>', 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

BIN
target.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1023 KiB

BIN
template.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB