feat: parallel template search, async mouse moves, NPC auto-label

- template_finder.search(): parallel matching via ThreadPoolExecutor (4 workers)
- utils/custom_mouse.py: async_move() for non-blocking mouse movement
- utils/npc_auto_label.py: detect_visible_npcs() scans 16 NPCs in parallel
This commit is contained in:
alex
2026-05-19 22:47:30 +02:00
parent 2d69b246d9
commit 23b0f0f66b
3 changed files with 219 additions and 16 deletions

View File

@@ -1,5 +1,6 @@
import cv2
import threading
import concurrent.futures
from screen import convert_screen_to_monitor, grab
from dataclasses import dataclass
import numpy as np
@@ -125,35 +126,55 @@ def _single_template_match(template: Template, inp_img: np.ndarray = None, roi:
return template_match
def _match_template_worker(template, inp_img, roi, color_match, use_grayscale):
"""Worker for parallel template matching (runs in thread pool)."""
return _single_template_match(template, inp_img, roi, color_match, use_grayscale)
def search(
ref: str | np.ndarray | list[str],
inp_img: np.ndarray,
threshold: float = 0.68,
roi: list[float] = None,
use_grayscale: bool = False,
color_match: list = False,
best_match: bool = False
) -> TemplateMatch:
ref,
inp_img,
threshold=0.68,
roi=None,
use_grayscale=False,
color_match=False,
best_match=False,
max_workers=4
):
"""
Search for a template in an image
Search for a template in an image. Uses parallel matching for list inputs.
:param ref: Either key of a already loaded template, list of such keys, or a image which is used as template
:param inp_img: Image in which the template will be searched
:param threshold: Threshold which determines if a template is found or not
:param roi: Region of Interest of the inp_img to restrict search area. Format [left, top, width, height]
:param use_grayscale: Use grayscale template matching for speed up
:param color_match: Pass a color to be used by misc.color_filter to filter both image of interest and template image (format Config().colors["color"])
:param color_match: Pass a color to be used by misc.color_filter to filter both image of interest and template image
:param best_match: If list input, will search for list of templates by best match. Default behavior is first match.
:param max_workers: Max parallel threads for template matching (default 4)
:return: Returns a TemplateMatch object with a valid flag
"""
templates = _process_template_refs(ref)
# Single template — no benefit to parallelize
if len(templates) == 1:
match = _single_template_match(templates[0], inp_img, roi, color_match, use_grayscale)
return match if match.score >= threshold else TemplateMatch()
# Multiple templates — parallel search
matches = []
for template in templates:
match = _single_template_match(template, inp_img, roi, color_match, use_grayscale)
if match.score >= threshold:
if not best_match:
return match
else:
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [
executor.submit(_match_template_worker, t, inp_img, roi, color_match, use_grayscale)
for t in templates
]
for future in concurrent.futures.as_completed(futures):
match = future.result()
if match.score >= threshold:
if not best_match:
return match
matches.append(match)
if matches:
matches = sorted(matches, key=lambda obj: obj.score, reverse=True)
return matches[0]

View File

@@ -14,6 +14,8 @@ import numpy as np
import random
import math
import time
import threading
from concurrent.futures import Future
import screen
from config import Config
from utils.misc import is_in_roi
@@ -341,6 +343,40 @@ class mouse:
def wheel(delta):
_mouse.wheel(delta)
@staticmethod
def async_move(x, y, absolute=True, randomize=5, delay_factor=[0.4, 0.6]):
"""
Non-blocking mouse move. Returns immediately with a Future-like object.
:return: A dict with:
- 'done()': callable returning bool
- 'wait(timeout=None)': blocks until move completes or timeout
"""
result = {"_done": False, "_lock": threading.Lock()}
def _run():
try:
mouse.move(x, y, absolute=absolute, randomize=randomize, delay_factor=delay_factor)
finally:
with result["_lock"]:
result["_done"] = True
threading.Thread(target=_run, daemon=True).start()
def done():
with result["_lock"]:
return result["_done"]
def wait(timeout=None):
deadline = None if timeout is None else time.monotonic() + timeout
while not done():
if deadline is not None and time.monotonic() >= deadline:
return False
time.sleep(0.01)
return True
return {"done": done, "wait": wait}
if __name__ == "__main__":
import os

146
src/utils/npc_auto_label.py Normal file
View File

@@ -0,0 +1,146 @@
"""
Auto-label NPCs visible on screen.
Scans for all known NPCs using their template groups (in parallel via
template_finder.search) and returns a dict of {npc_name: {center, score}}.
Can be called from any game loop tick without blocking the main thread.
Usage:
from utils.npc_auto_label import detect_visible_npcs
npcs_on_screen = detect_visible_npcs(img)
# npcs_on_screen = {"AKARA": {"center": (620, 300), "score": 0.82}, ...}
"""
import concurrent.futures
import template_finder
from screen import grab
# NPCs that exist per act — detected via their template groups in npc_manager
NPC_TEMPLATES = {
# Act 1
"AKARA": ["AKARA_FRONT", "AKARA_BACK", "AKARA_SIDE", "AKARA_SIDE_2"],
"CHARSI": ["CHARSI_FRONT", "CHARSI_BACK", "CHARSI_SIDE", "CHARSI_SIDE_2", "CHARSI_SIDE_3"],
"KASHYA": ["KASHYA_FRONT", "KASHYA_BACK", "KASHYA_SIDE", "KASHYA_SIDE_2"],
"CAIN": ["CAIN_0", "CAIN_1", "CAIN_2", "CAIN_3"],
# Act 2
"FARA": ["FARA_LIGHT_1", "FARA_LIGHT_3", "FARA_MEDIUM_1", "FARA_DARK_1"],
"DROGNAN": ["DROGNAN_FRONT", "DROGNAN_LEFT", "DROGNAN_RIGHT_SIDE"],
"LYSANDER": ["LYSANDER_FRONT", "LYSANDER_BACK", "LYSANDER_SIDE", "LYSANDER_SIDE_2"],
# Act 3
"ORMUS": ["ORMUS_0", "ORMUS_2", "ORMUS_4"],
# Act 4
"TYRAEL": ["TYRAEL_1", "TYRAEL_2"],
"JAMELLA": ["JAMELLA_FRONT", "JAMELLA_BACK", "JAMELLA_SIDE"],
"HALBU": ["HALBU_FRONT", "HALBU_BACK", "HALBU_SIDE", "HALBU_SIDE_2"],
# Act 5
"QUAL_KEHK": ["QUAL_0", "QUAL_45", "QUAL_180", "QUAL_270"],
"MALAH": ["MALAH_FRONT", "MALAH_BACK", "MALAH_45", "MALAH_SIDE"],
"LARZUK": ["LARZUK_FRONT", "LARZUK_BACK", "LARZUK_SIDE"],
"ANYA": ["ANYA_FRONT", "ANYA_BACK", "ANYA_SIDE"],
}
# How many NPCs to search in parallel (limit thread pool)
MAX_WORKERS = 8
# Search ROI — skip the bottom skill bar area to reduce false positives
SEARCH_ROI = [0, 0, 1280, 480]
def _search_npc(npc_name, template_keys, img):
"""Search for one NPC using all its template variants. Returns match or None."""
best = template_finder.search(
template_keys,
img,
threshold=0.55,
roi=SEARCH_ROI,
best_match=True,
)
if best.valid:
return {
"center": best.center,
"center_monitor": best.center_monitor,
"score": best.score,
}
return None
def detect_visible_npcs(img=None):
"""
Scan the screen for all known NPCs.
:param img: Screenshot (BGR numpy array). If None, grabs one.
:return: dict {npc_name: {"center": (x,y), "center_monitor": (x,y), "score": float}}
"""
if img is None:
img = grab()
results = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = {
executor.submit(_search_npc, name, templates, img): name
for name, templates in NPC_TEMPLATES.items()
}
for future in concurrent.futures.as_completed(futures):
npc_name = futures[future]
try:
match = future.result()
if match is not None:
results[npc_name] = match
except Exception:
pass
return results
# ─── Optional: persistent cache that expires after N seconds ───
_cache = {}
_cache_time = 0.0
def detect_visible_npcs_cached(img=None, ttl=5.0):
"""
Like detect_visible_npcs() but caches results for ttl seconds.
Use this when scanning every tick but only need fresh data periodically.
"""
import time
now = time.time()
if now - _cache_time < ttl:
return _cache
_cache.clear()
_cache.update(detect_visible_npcs(img))
global _cache_time
_cache_time = now
return _cache
if __name__ == "__main__":
import cv2
import keyboard
from screen import start_detecting_window
start_detecting_window()
print("Press F12 to exit. Scanning for NPCs...")
while True:
keyboard.pause(0.05, True)
if keyboard.is_pressed("f12"):
break
img = grab()
found = detect_visible_npcs(img)
display = img.copy()
for name, info in found.items():
x, y = info["center"]
cv2.putText(display, name, (x - 30, y - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2, cv2.LINE_AA)
cv2.circle(display, (x, y), 5, (0, 255, 0), -1)
if found:
npc_strs = [f"{n}({info['score']:.2f})" for n, info in found.items()]
print(f"NPCs on screen: {', '.join(npc_strs)}")
cv2.imshow("NPC Auto-Label", display)
cv2.waitKey(1)