"""
image_clicker.py
화면에서 특정 이미지를 찾아 클릭하는 간단한 자동화 툴

지원:
- template matching (fast, scale/rotation에 민감)
- ORB keypoint matching (slower, scale/rotation에 강인)

주의:
- Windows에서 DPI 스케일(디스플레이 배율)이 100%가 아니면 좌표 보정이 필요할 수 있습니다.
- 자동 클릭은 시스템/앱에 따라 문제를 일으킬 수 있으니 책임 하에 사용하세요.
"""

import time
import cv2
import numpy as np
import pyautogui
import mss
import keyboard
import sys
from typing import Tuple, Optional

# ----------------- 설정 -----------------
TEMPLATE_PATH = "template.png"   # 찾을 이미지 파일 경로
METHOD = "template"  # "template" or "orb"
THRESHOLD = 0.8      # template: 0.7-0.95 권장, orb: 매칭 키포인트 비율 판단시 사용
CLICK_DELAY = 0.05   # 클릭 후 대기 (초)
LOOP_DELAY = 0.2     # 화면 캡처 반복 주기 (초)
SHOW_PREVIEW = True  # 화면과 매칭 결과를 윈도우로 미리보기
FIND_MULTI = False   # True면 여러 개 찾고 가장 먼저 클릭하지 않고 모두 클릭함
MAX_RESULTS = 5      # template 모드에서 최대 결과수 (NMS 적용)
CLICK_BUTTON = "left"   # 'left' or 'right' or 'middle'
QUIT_HOTKEY = ("ctrl", "shift", "q")  # 누르면 종료
# ---------------------------------------

# Helper: 체크 quit hotkey
def quit_pressed():
    return all(keyboard.is_pressed(k) for k in QUIT_HOTKEY)

# 화면 캡처 (mss) -> numpy BGR 이미지
def grab_screen() -> np.ndarray:
    with mss.mss() as sct:
        monitor = sct.monitors[1]  # 기본 모니터(전체)
        sct_img = sct.grab(monitor)
        img = np.array(sct_img)  # BGRA
        img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
        return img

# Template matching (multi or single)
def find_by_template(screen_bgr: np.ndarray, template_bgr: np.ndarray, threshold=0.8, max_results=5):
    screen_gray = cv2.cvtColor(screen_bgr, cv2.COLOR_BGR2GRAY)
    tmpl_gray = cv2.cvtColor(template_bgr, cv2.COLOR_BGR2GRAY)
    res = cv2.matchTemplate(screen_gray, tmpl_gray, cv2.TM_CCOEFF_NORMED)
    loc = np.where(res >= threshold)
    w, h = tmpl_gray.shape[::-1]
    rects = []
    scores = []

    for pt in zip(*loc[::-1]):
        rects.append([int(pt[0]), int(pt[1]), int(w), int(h)])
        scores.append(res[pt[1], pt[0]])

    # Non-maximum suppression to reduce overlapping boxes
    if len(rects) == 0:
        return []
    boxes = np.array(rects)
    scores = np.array(scores)

    # NMS
    indices = cv2.dnn.NMSBoxes(rects, scores.tolist(), score_threshold=threshold, nms_threshold=0.3)
    found = []
    if len(indices) > 0:
        for i in indices.flatten()[:max_results]:
            x, y, w, h = rects[i]
            found.append((x, y, w, h, float(scores[i])))
    else:
        # fallback: take best matches up to max_results
        order = np.argsort(-scores)[:max_results]
        for i in order:
            x, y, w, h = rects[i]
            found.append((x, y, w, h, float(scores[i])))

    return found

# ORB matching
def find_by_orb(screen_bgr: np.ndarray, template_bgr: np.ndarray, min_match_count=8):
    screen_gray = cv2.cvtColor(screen_bgr, cv2.COLOR_BGR2GRAY)
    tmpl_gray = cv2.cvtColor(template_bgr, cv2.COLOR_BGR2GRAY)
    orb = cv2.ORB_create(1000)
    kp1, des1 = orb.detectAndCompute(tmpl_gray, None)
    kp2, des2 = orb.detectAndCompute(screen_gray, None)
    if des1 is None or des2 is None:
        return []

    bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=False)
    matches = bf.knnMatch(des1, des2, k=2)

    # Lowe's ratio test
    good = []
    for m_n in matches:
        if len(m_n) != 2:
            continue
        m, n = m_n
        if m.distance < 0.75 * n.distance:
            good.append(m)

    if len(good) < min_match_count:
        return []

    # Homography to find bounding box
    src_pts = np.float32([kp1[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
    dst_pts = np.float32([kp2[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
    M, mask = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0)
    h, w = tmpl_gray.shape

    if M is None:
        return []

    pts = np.float32([[0, 0], [w, 0], [w, h], [0, h]]).reshape(-1, 1, 2)
    dst = cv2.perspectiveTransform(pts, M)
    # bounding rect
    x_coords = dst[:, 0, 0]
    y_coords = dst[:, 0, 1]
    x, y = int(x_coords.min()), int(y_coords.min())
    ww, hh = int(x_coords.max() - x), int(y_coords.max() - y)
    # confidence: ratio of inliers
    inliers = mask.ravel().sum() if mask is not None else 0
    conf = float(inliers) / len(good) if len(good) > 0 else 0.0

    return [(x, y, ww, hh, conf)]

# 클릭 (중앙 좌표)
def click_box(box: Tuple[int, int, int, int], button="left"):
    x, y, w, h = box
    cx = x + w // 2
    cy = y + h // 2
    # pyautogui uses screen coordinates; ensure failsafe off if you want
    try:
        pyautogui.click(cx, cy, button=button)
    except Exception as e:
        print("Click failed:", e)

def main():
    # load template
    tmpl = cv2.imread(TEMPLATE_PATH)
    if tmpl is None:
        print(f"템플릿 파일을 열 수 없습니다: {TEMPLATE_PATH}")
        sys.exit(1)

    print("템플릿 로드 완료:", TEMPLATE_PATH)
    print("Method:", METHOD)
    print("종료 단축키:", "+".join(QUIT_HOTKEY).upper())
    last_click_time = 0

    while True:
        if quit_pressed():
            print("종료 단축키 눌림. 프로그램 종료.")
            break

        screen = grab_screen()
        found = []
        if METHOD == "template":
            found = find_by_template(screen, tmpl, threshold=THRESHOLD, max_results=MAX_RESULTS)
        elif METHOD == "orb":
            found = find_by_orb(screen, tmpl)
        else:
            print("Unknown method:", METHOD)
            break

        # 클릭 동작
        if found:
            if FIND_MULTI:
                for (x, y, w, h, score) in found:
                    print(f"Found @ {x},{y},{w}x{h} conf={score:.3f} -> clicking")
                    click_box((x, y, w, h), button=CLICK_BUTTON)
                    time.sleep(CLICK_DELAY)
            else:
                x, y, w, h, score = found[0]
                # 클릭 빈도 제어 (안전)
                now = time.time()
                if now - last_click_time > CLICK_DELAY:
                    print(f"Found @ {x},{y} size={w}x{h} conf={score:.3f} -> clicking")
                    click_box((x, y, w, h), button=CLICK_BUTTON)
                    last_click_time = now

        # 보여주기 (디버그용)
        if SHOW_PREVIEW:
            vis = screen.copy()
            for (x, y, w, h, score) in found:
                cv2.rectangle(vis, (x, y), (x + w, y + h), (0, 255, 0), 2)
                cv2.putText(vis, f"{score:.2f}", (x, y - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0,255,0), 2)
            cv2.imshow("Image Clicker Preview (press ESC to close preview, hotkey to quit)", vis)
            # ESC to close preview window only
            if cv2.waitKey(1) & 0xFF == 27:
                cv2.destroyWindow("Image Clicker Preview (press ESC to close preview, hotkey to quit)")
                # continue running headless

        time.sleep(LOOP_DELAY)

if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print("KeyboardInterrupt -> 종료")
    finally:
        cv2.destroyAllWindows()

Embed on website

To embed this project on your website, copy the following code and paste it into your website's HTML: