Загрузка данных


Привет сделай это:
поиск только в верхней части экрана (ROI), а не по всему окну;

поиск по шаблону только при потере коробки, а между кадрами — трекинг;

использование одного канала изображения вместо полного BGR→Gray там, где это возможно;

отключаемая визуализация (SHOW_DEBUG = False), чтобы получить максимальный FPS;

быстрый клик через WinAPI (SendInput) вместо pyautogui;

сглаживание скорости (EMA);

вычисление момента пересечения центра между кадрами, чтобы не пропускать идеальный момент даже при 25–30 FPS;

прогнозирование по времени (distance / velocity), а не только по текущему кадру;

поиск шаблона в отдельном потоке, пока основной поток занимается кликом и логикой;

предварительное выделение памяти и устранение лишних копирований numpy;

оптимизация matchTemplate и уменьшение количества вызовов dilate.

Сам код:
# -*- coding: cp1251 -*-
"""
BOX-BOT VISION (v4 - фикс кулдауна / дрожания на низкой скорости / лишних кликов)
==================================================================================
Бот для мини-игры "башня из коробок" в Telegram WebApp.

Что было в v3: быстрый поиск коробок (локальные максимумы), адаптивный
цикл захвата под целевой FPS, предсказание позиции падающей коробки по
скорости (компенсация задержки между кадром и кликом).

Что добавлено в v4:
  1) Кулдаун после броска увеличен и стал строгим: после клика бот
     ТОЧНО не кликнет снова раньше, чем через CLICK_COOLDOWN_SEC секунд
     (по умолчанию 2.0, как и просили) - "кинул -> подождал -> только
     потом может снова".
  2) Фикс "стало кривее на низкой скорости / без скорости". Причина:
     скорость считалась по разнице координат всего ДВУХ соседних кадров -
     а само определение коробки шаблоном иногда "дрожит" на 1-2 пикселя
     даже когда коробка реально стоит на месте. При маленьком dt между
     кадрами это давало случайный шум вместо реальной скорости, и бот
     "предсказывал" движение там, где его не было. Теперь:
       - скорость сглаживается (VELOCITY_SMOOTHING) по нескольким кадрам,
         а не берётся "as is" с последнего кадра;
       - есть "мёртвая зона" (VELOCITY_DEADBAND_PX_S) - слишком маленькая
         скорость (это дрожание, а не реальное движение) обнуляется и
         вообще не влияет на предсказание.
     При реальном быстром движении это почти не мешает (там скорость
     гораздо больше мёртвой зоны), а на стоячей/медленной коробке
     предсказание теперь не "дёргается".
  3) Фикс ложных кликов / "багов" на статичной картинке (например, когда
     башня уже построена, игра закончилась, и две верхние коробки в
     башне просто стоят рядом без реальной падающей коробки сверху).
     Раньше бот в этом случае мог решить, что верхние 2 найденные коробки
     "падающая + верх башни" и они "выровнены" (они и правда рядом и
     совпадают по X, просто потому что это соседние коробки одной башни) -
     и кликал просто так. Теперь бот дополнительно проверяет ЗАЗОР между
     "падающей" и "верхом башни" по вертикали - если зазора нет (коробки
     визуально соприкасаются, как в самой башне), это считается "нет
     сейчас падающей коробки", и бот не кликает, даже если они совпали
     по X.
  4) Клик теперь требует НЕСКОЛЬКО кадров подряд с подтверждённым
     выравниванием (REQUIRE_CONSECUTIVE_ALIGNED_FRAMES), а не один кадр -
     небольшая, почти бесплатная защита от случайного шумного кадра.

Файл с образцом коробки:
    D:\\codecode\\box.png
  (путь задан в настройках ниже, переменная BOX_TEMPLATE_PATH).
  Желательно, чтобы на картинке была ТОЛЬКО сама коробка, без большого
  отступа по краям - так шаблон будет точнее совпадать.

УСТАНОВКА ЗАВИСИМОСТЕЙ (один раз, в cmd/PowerShell):
    pip install opencv-python numpy mss pyautogui pywin32

ЗАПУСК:
    python box_bot.py

КАЛИБРОВКА (один шаг):
    Появится скриншот экрана - выделите рамкой область игры в браузере
    (просто обведите окно с игрой целиком, от верхнего края, где летает
    коробка, до низа башни) и нажмите ENTER (или SPACE).

КЛАВИШИ ПРИ РАБОТЕ:
    q - выход
    c - калибровка заново (если окно игры подвинулось/изменилось)
    p - пауза/продолжить (бот перестаёт кликать, но продолжает показывать,
        что видит)

ЕСЛИ БОТ ВСЁ ЕЩЁ КИДАЕТ КРИВО:
    - Сначала посмотрите на FPS в окне визуализации. Если он низкий -
      проблема в скорости обработки (закройте лишние программы, уменьшите
      DISPLAY_SCALE до 1.0, уберите лишние масштабы из TEMPLATE_SCALES).
    - Если FPS нормальный (>25-30), но всё равно криво на высокой
      скорости - увеличьте REACTION_LAG_SEC (например, с 0.05 до 0.08-0.10).
      Если, наоборот, бот теперь кидает "с запасом в другую сторону" -
      уменьшите REACTION_LAG_SEC.
    - Если криво именно на низкой/нулевой скорости - увеличьте
      VELOCITY_DEADBAND_PX_S (например, с 25 до 40-50).

ЕСЛИ БОТ ПЛОХО НАХОДИТ КОРОБКУ:
    - Подправьте MATCH_THRESHOLD ниже (меньше значение = находит охотнее,
      но может ловить и похожие предметы; больше значение = строже).
    - Если коробка в игре иногда крупнее/мельче (зум), добавьте в
      TEMPLATE_SCALES больше значений, например [0.9, 0.95, 1.0, 1.05, 1.1].
"""

import sys
import time

import numpy as np
import cv2
import mss
import pyautogui

# pywin32 нужен только для "приклеивания" окна поверх всех окон (Windows),
# чтобы визуализация не пряталась под браузер, когда бот кликает по нему.
try:
    import win32gui
    import win32con
    HAS_WIN32 = True
except ImportError:
    HAS_WIN32 = False


# ========================== НАСТРОЙКИ ==========================

WINDOW_NAME = "BOX-BOT VISION"

# Путь к картинке-образцу коробки.
BOX_TEMPLATE_PATH = r"D:\codecode\box.png"

# Порог совпадения с шаблоном (0..1). Меньше - находит охотнее (но может
# ловить похожие объекты), больше - строже (может пропускать коробку).
MATCH_THRESHOLD = 0.50

# Масштабы шаблона для перебора (на случай небольшого зума/изменения
# размера коробки в игре). 1.0 = размер как в box.png без изменений.
TEMPLATE_SCALES = [1.0]

# Сколько секунд ждать между двумя кликами (защита от повторного клика
# по ОДНОЙ И ТОЙ ЖЕ коробке, пока она ещё не сменилась)
CLICK_COOLDOWN_SEC = 4

# Насколько "не идеально" можно совпадать центрам коробок и всё равно
# считать, что они "по ровной полосе" (в пикселях кадра).
ALIGN_TOLERANCE_PX = 3

# СКОЛЬКО ВРЕМЕНИ (в секундах) "вперёд" предсказывать позицию падающей
# коробки, чтобы компенсировать задержку между снимком экрана и реальным
# кликом. Если на высокой скорости бросок всё равно уходит в сторону -
# увеличьте это число; если коробка стала "перелетать" в другую сторону -
# уменьшите. Подбирается опытным путём, начните с 0.05.
REACTION_LAG_SEC = 0.05

# Масштаб окна визуализации (во сколько раз увеличить картинку для удобства).
# Если комп слабый и FPS в окне низкий - поставьте 1.0, это тоже немного
# ускоряет цикл.
DISPLAY_SCALE = 1.0

# Целевая частота обработки кадров (кадров в секунду). Цикл сам досыпает
# ровно столько, сколько нужно, чтобы выйти на эту частоту - лишней
# задержки сверху уже не накапливается.
TARGET_FPS = 40

# ========================== УТИЛИТЫ ==========================

def grab_frame(sct, region):
    """Снимок экрана region={'left','top','width','height'} -> BGR-картинка."""
    raw = sct.grab(region)
    frame = np.array(raw)[:, :, :3]  # BGRA -> BGR
    return frame


def set_always_on_top(window_name):
    """Закрепляет окно OpenCV поверх всех окон (в т.ч. поверх браузера)."""
    if not HAS_WIN32:
        return
    hwnd = win32gui.FindWindow(None, window_name)
    if hwnd:
        win32gui.SetWindowPos(
            hwnd, win32con.HWND_TOPMOST, 0, 0, 0, 0,
            win32con.SWP_NOMOVE | win32con.SWP_NOSIZE
        )


def select_rect(prompt, screenshot_bgr):
    """Показывает скриншот и просит выделить рамку. Возвращает (x,y,w,h)."""
    print(prompt)
    cv2.namedWindow(WINDOW_NAME, cv2.WINDOW_NORMAL)
    r = cv2.selectROI(WINDOW_NAME, screenshot_bgr, showCrosshair=True, fromCenter=False)
    cv2.destroyWindow(WINDOW_NAME)
    return r  # (x, y, w, h)


def load_template(path):
    tpl = cv2.imread(path)
    if tpl is None:
        print("ОШИБКА: не удалось загрузить шаблон коробки: %s" % path)
        print("Проверьте путь BOX_TEMPLATE_PATH в начале файла.")
        sys.exit(1)
    return cv2.cvtColor(tpl, cv2.COLOR_BGR2GRAY)


def match_all_boxes(gray_frame, template_gray, scales, threshold, min_distance):
    """
    Ищет коробки на кадре по шаблону. В отличие от "наивного" подхода
    (взять ВСЕ пиксели с похожестью выше порога - их может быть тысячи
    на одну и ту же коробку, и это сильно лагает), здесь сразу ищутся
    только ЛОКАЛЬНЫЕ МАКСИМУМЫ карты схожести - то есть по одной точке
    на каждую реально найденную коробку. Это и есть главный фикс лагов.

    min_distance - минимальное расстояние (в пикселях) между двумя
    разными коробками, чтобы их не "слили" в одну.

    Возвращает список найденных коробок, отсортированный СВЕРХУ ВНИЗ
    (по Y на экране):
        [(cx, cy, x1, y1, x2, y2, score), ...]
    где (cx, cy) - центр коробки, (x1,y1)-(x2,y2) - её рамка.
    """
    raw_points = []
    base_h, base_w = template_gray.shape
    kernel_size = max(3, int(min_distance) | 1)  # должен быть нечётным
    kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8)

    for scale in scales:
        w = max(4, int(base_w * scale))
        h = max(4, int(base_h * scale))
        if w >= gray_frame.shape[1] or h >= gray_frame.shape[0]:
            continue
        tpl = template_gray if scale == 1.0 else cv2.resize(template_gray, (w, h))
        res = cv2.matchTemplate(gray_frame, tpl, cv2.TM_CCOEFF_NORMED)

        # локальный максимум-фильтр: для каждой точки берём максимум по
        # соседям и оставляем только те точки, что сами и есть этот максимум
        dilated = cv2.dilate(res, kernel)
        peaks_mask = (res >= threshold) & (res == dilated)
        ys, xs = np.where(peaks_mask)

        for x, y in zip(xs, ys):
            score = float(res[y, x])
            cx, cy = x + w / 2.0, y + h / 2.0
            raw_points.append((cx, cy, int(x), int(y), int(x + w), int(y + h), score))

    # точек теперь мало (по одной на коробку на каждый масштаб), так что
    # финальная отбраковка близких дублей (между масштабами) почти бесплатна
    raw_points.sort(key=lambda c: c[6], reverse=True)
    selected = []
    for c in raw_points:
        cx, cy = c[0], c[1]
        too_close = False
        for s in selected:
            if abs(cx - s[0]) < min_distance and abs(cy - s[1]) < min_distance:
                too_close = True
                break
        if not too_close:
            selected.append(c)

    # финально сортируем сверху экрана вниз - так matches[0] это самая
    # верхняя коробка (падающая), matches[1] - следующая по высоте
    # (верх уже построенной башни), и так далее
    selected.sort(key=lambda c: c[1])
    return selected


# ========================== КАЛИБРОВКА (только область экрана) =========

def calibrate(sct):
    monitor = sct.monitors[0]  # весь экран (все мониторы вместе)
    full = grab_frame(sct, monitor)

    rx, ry, rw, rh = select_rect(
        "Выделите рамкой ОКНО ИГРЫ в браузере и нажмите ENTER (или SPACE). "
        "Esc - отмена.",
        full,
    )
    if rw == 0 or rh == 0:
        print("Область не выбрана, выходим.")
        sys.exit(1)

    game_region = {
        "left": monitor["left"] + rx,
        "top": monitor["top"] + ry,
        "width": rw,
        "height": rh,
    }
    click_abs = (monitor["left"] + rx + rw // 2, monitor["top"] + ry + rh // 2)

    print("Калибровка завершена. Клавиши: q - выход, c - калибровка заново, "
          "p - пауза/продолжить.")
    return {"region": game_region, "click_abs": click_abs}


# ========================== ОСНОВНОЙ ЦИКЛ ==========================

def main():
    pyautogui.FAILSAFE = True  # увести мышь в угол экрана = аварийный стоп
    pyautogui.PAUSE = 0  # убираем встроенную паузу ~0.1с после каждого клика

    template_gray = load_template(BOX_TEMPLATE_PATH)
    # минимальная дистанция между двумя разными коробками - чуть меньше
    # высоты/ширины самой коробки, чтобы не путать соседние коробки в башне
    min_distance = max(8, int(0.55 * min(template_gray.shape)))

    target_dt = 1.0 / TARGET_FPS

    with mss.mss() as sct:
        cfg = calibrate(sct)
        cv2.namedWindow(WINDOW_NAME, cv2.WINDOW_NORMAL)

        paused = False
        last_click_time = 0.0
        was_aligned = False
        topmost_done = False

        prev_falling = None  # (x, время_кадра) - для расчёта скорости
        fps_smooth = None

        while True:
            loop_start = time.time()

            frame = grab_frame(sct, cfg["region"])
            gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

            boxes = match_all_boxes(gray, template_gray, TEMPLATE_SCALES,
                                     MATCH_THRESHOLD, min_distance)

            disp = frame.copy()
            h, w = disp.shape[:2]

            falling = boxes[0] if len(boxes) >= 1 else None
            tower_top = boxes[1] if len(boxes) >= 2 else None

            # остальные коробки в башне обводим тонким серым - просто
            # для наглядности, что бот их тоже "видит", в решении не участвуют
            for b in boxes[2:]:
                cv2.rectangle(disp, (b[2], b[3]), (b[4], b[5]), (130, 130, 130), 1)

            now = time.time()

            # --- предсказание позиции падающей коробки на момент клика ---
            predicted_x = None
            if falling is not None:
                raw_x = falling[0]
                if prev_falling is not None:
                    dt = now - prev_falling[1]
                    velocity = (raw_x - prev_falling[0]) / dt if dt > 0 else 0.0
                else:
                    velocity = 0.0
                predicted_x = raw_x + velocity * REACTION_LAG_SEC
                prev_falling = (raw_x, now)

                # тонкая жёлтая линия - "сырая" измеренная позиция (без
                # компенсации лага), чтобы видеть, насколько её разводит
                # с предсказанной
                cv2.line(disp, (int(raw_x), 0), (int(raw_x), h), (0, 220, 255), 1)
            else:
                prev_falling = None

            aligned = False
            diff_px = None

            if predicted_x is not None and tower_top is not None:
                diff_px = abs(predicted_x - tower_top[0])
                aligned = diff_px <= ALIGN_TOLERANCE_PX
                status = "VYROVNENO" if aligned else "ZHDU"
            elif falling is not None and tower_top is None:
                # башни ещё нет (самая первая коробка) - кидаем без проверки
                aligned = True
                status = "PERVAYA KOROBKA"
            else:
                status = "NE VIZHU KOROBKU"

            color = (0, 220, 0) if aligned else (0, 0, 230)

            if falling is not None:
                cv2.rectangle(disp, (falling[2], falling[3]), (falling[4], falling[5]), color, 2)
            if predicted_x is not None:
                cv2.line(disp, (int(predicted_x), 0), (int(predicted_x), h), color, 2)
            if tower_top is not None:
                cv2.rectangle(disp, (tower_top[2], tower_top[3]), (tower_top[4], tower_top[5]), color, 2)
                cv2.line(disp, (int(tower_top[0]), 0), (int(tower_top[0]), h), color, 2)

            cv2.putText(disp, status, (8, 22), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2)
            if diff_px is not None:
                cv2.putText(disp, "diff=%dpx" % int(diff_px), (8, 46),
                            cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 1)
            if fps_smooth is not None:
                cv2.putText(disp, "FPS=%d" % int(fps_smooth), (8, 68),
                            cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 1)
            if paused:
                cv2.putText(disp, "PAUSE (p)", (8, h - 12),
                            cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 200, 255), 2)

            if DISPLAY_SCALE != 1.0:
                disp = cv2.resize(disp, (int(w * DISPLAY_SCALE), int(h * DISPLAY_SCALE)))

            cv2.imshow(WINDOW_NAME, disp)
            if not topmost_done:
                set_always_on_top(WINDOW_NAME)
                topmost_done = True

            if (aligned and not was_aligned and not paused
                    and (now - last_click_time) > CLICK_COOLDOWN_SEC):
                pyautogui.click(cfg["click_abs"][0], cfg["click_abs"][1])
                last_click_time = now

            was_aligned = aligned

            key = cv2.waitKey(1) & 0xFF
            if key == ord("q"):
                break
            elif key == ord("c"):
                cv2.destroyWindow(WINDOW_NAME)
                cfg = calibrate(sct)
                cv2.namedWindow(WINDOW_NAME, cv2.WINDOW_NORMAL)
                topmost_done = False
                prev_falling = None
            elif key == ord("p"):
                paused = not paused

            # --- адаптивная пауза: досыпаем РОВНО до целевого FPS, а не
            # добавляем фиксированную задержку сверху уже потраченного
            # на обработку времени (это и копило лаг в v1/v2) ---
            elapsed = time.time() - loop_start
            fps_now = 1.0 / elapsed if elapsed > 0 else 0.0
            fps_smooth = fps_now if fps_smooth is None else (0.9 * fps_smooth + 0.1 * fps_now)

            remaining = target_dt - elapsed
            if remaining > 0:
                time.sleep(remaining)

        cv2.destroyAllWindows()


if __name__ == "__main__":
    main()