Initial pixel hunter implementation
This commit is contained in:
commit
f41285b7a4
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
build/
|
||||
dist/
|
||||
*.spec
|
||||
|
||||
.idea/
|
||||
.vscode/
|
||||
10
actions.py
Normal file
10
actions.py
Normal 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
55
capture.py
Normal 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
10
config.json
Normal 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
64
gui.py
Normal 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
148
main.py
Normal 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
BIN
reference.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@ -0,0 +1,6 @@
|
||||
opencv-python
|
||||
pyautogui
|
||||
numpy
|
||||
keyboard
|
||||
Pillow
|
||||
pyinstaller
|
||||
14
run_debug.bat
Normal file
14
run_debug.bat
Normal 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
85
scanner.py
Normal 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
109
screenshot_selector.py
Normal 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
BIN
target.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1023 KiB |
BIN
template.png
Normal file
BIN
template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Loading…
Reference in New Issue
Block a user