Загрузка данных
Привет сделай это:
поиск только в верхней части экрана (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()