Се­год­ня мы с тобой раз­работа­ем бота, спо­соб­ного при помощи машин­ного зре­ния ори­енти­ровать­ся в игро­вом прос­транс­тве. Для при­мера возь­мем Luanti и Minecraft, одна­ко общие прин­ципы и осно­ва, которую мы напишем, подой­дут для любой схо­жей игры.

Мы будем управлять ботом так же, как это дела­ет обыч­ный игрок: отправ­лять ему коман­ды нажатия кла­виш (WASD и дру­гих). Получать информа­цию от бота будем через встро­енную в игру фун­кцию сох­ранения скрин­шотов. Нап­ример, отпра­вили боту коман­ду нажать F12 — получи­ли сни­мок того, что он видит в дан­ный момент. Про­ана­лизи­рова­ли изоб­ражение, отпра­вили сле­дующую коман­ду — и так по кру­гу.

Важ­ный момент: у нас не будет цели делать в игре что‑то кон­крет­ное. Мы прос­то под­готовим осно­ву и заод­но изу­чим прин­ципы работы с машин­ным зре­нием.

Для рас­позна­вания объ­ектов мы будем исполь­зовать модели YOLO (You Only Look Once). Наша задача — обу­чить модель находить булыж­ник и дви­гать­ся по дорож­ке, вымощен­ной им.

Обу­чение будет про­ходить в два эта­па.

  • Пер­вый этап. Обу­чим модель YOLOv8n находить наш объ­ект на сним­ках. Эта модель смо­жет опре­делить, содер­жится ли на изоб­ражении иско­мый объ­ект, и выделить его с помощью огра­ничи­вающе­го пря­моуголь­ника (bounding box).
  • Вто­рой этап. Под­готовим обу­чающий матери­ал для вто­рой модели — YOLOv8-seg, которая смо­жет обво­дить объ­екты по кон­туру. Это даст нам воз­можность ана­лизи­ровать их раз­мер, фор­му и положе­ние. Это имен­но то, что нуж­но для точ­ной ори­ента­ции бота в прос­транс­тве.
 

Что нам понадобится

  1. Обыч­ный компь­ютер. Мы исполь­зуем лег­ковес­ные модели, с которы­ми спра­вит­ся даже сред­ний по мощ­ности ПК. Весь код был написан и про­тес­тирован на машине со сле­дующи­ми харак­терис­тиками:
    • про­цес­сор — вось­миядер­ный AMD Ryzen 7 5700U с кар­той Radeon;
    • опе­ратив­ная память — 8 Гбайт.
  2. Сво­бод­ное мес­то на дис­ке. Пот­ребу­ется око­ло 10 Гбайт:
    • 400–500 Мбайт для хра­нения сним­ков (зависит от их количес­тва);
    • ~400 Мбайт для ска­чива­ния пре­добу­чен­ных моделей;
    • ~8,6 Гбайт для уста­нов­ки биб­лиотек Python;
    • 38,8 Кбайт для нашего кода!
  3. Хо­роший фильм или даже сери­ал. Обу­чение модели занима­ет нес­коль­ко часов, так что сто­ит запас­тись тер­пени­ем и раз­вле­чени­ями.
  4. Опе­раци­онная сис­тема. Я исполь­зовал Ubuntu, но подой­дет любой дис­три­бутив Linux. Нам понадо­бят­ся нес­коль­ко допол­нитель­ных прог­рамм, которые, ско­рее все­го, уже есть в офи­циаль­ных репози­тори­ях тво­его дис­тра.

Ста­вим при­ложе­ния для работы с буфером обме­на:

sudo apt install xclip xsel

И для работы с окна­ми:

sudo apt install wmctrl xdotool

Тес­тировать я буду на игре Luanti (так­же извес­тна под ста­рым наз­вани­ем Minetest) — она поп­роще, чем Minecraft, и есть в стан­дар­тных репози­тори­ях. Одна­ко попут­но дам рекомен­дации для работы с Minecraft — отли­чия будут минималь­ными.

Мы вос­поль­зуем­ся сво­им набором тек­стур для игры, что­бы наш бот зарабо­тал на любом сер­вере. Ска­чать тек­стур­ки мож­но из репози­тория про­екта.

В качес­тве язы­ка возь­мем Python — как один из наибо­лее популяр­ных и име­ющий мно­жес­тво удоб­ных биб­лиотек.

Весь про­цесс мож­но раз­бить на три основных эта­па: под­готов­ка, обу­чение, исполь­зование.

 

Подготовка

У нас будет мно­го раз­ных фай­лов, поэто­му, что­бы ничего не потерять, соз­дадим каталог для про­екта.

Пос­коль­ку нам нуж­но будет ста­вить модули Python, сра­зу сде­лаем вир­туаль­ное окру­жение.

python -m venv minebot

Те­перь поп­робу­ем покоман­довать нашим ботом. Для это­го вос­поль­зуем­ся биб­лиоте­кой PyAutoGUI. С ее помощью мы можем отправ­лять окну в фокусе нажатие кла­виш, дви­жение мыши и нажатие ее кно­пок.

Ак­тивиру­ем наше вир­туаль­ное окру­жение:

source minebot/bin/activate

Ус­тановим биб­лиоте­ки.

pip install pyautogui pyperclip opencv-python numpy ultralytics

Ко­ман­ды управле­ния ботом я соб­рал в одном фай­ле.

luanti/controller.py
import pyautogui
import pyperclip
import time
import math
class LuantiController:
"""Класс для управления персонажем в Luanti с использованием библиотеки PyAutoGUI."""
def __init__(self, window_width, window_height):
"""Инициализирует контроллер, получает размеры экрана."""
self.screen_width = window_width
self.screen_height = window_height
self.fov = 72 # Угол обзора в градусах
def take_screenshot(self):
"""Делает скриншот (нажатие F12)."""
pyautogui.press('f12')
time.sleep(0.1)
def _move(self, key, duration):
"""Вспомогательная функция для перемещения."""
pyautogui.keyDown(key)
time.sleep(duration)
pyautogui.keyUp(key)
def move_forward(self, duration):
"""Перемещает персонажа вперед (нажатие W)."""
self._move('w', duration)
def move_backward(self, duration):
"""Перемещает персонажа назад (нажатие S)."""
self._move('s', duration)
def move_left(self, duration):
"""Перемещает персонажа влево (нажатие A)."""
self._move('a', duration)
def move_right(self, duration):
"""Перемещает персонажа вправо (нажатие D)."""
self._move('d', duration)
def jump(self):
"""Прыжок (нажатие пробела)."""
pyautogui.keyDown('space')
time.sleep(0.1)
pyautogui.keyUp('space')
def run(self, direction, duration):
"""Бег в указанном направлении."""
pyautogui.keyDown('e')
time.sleep(0.1)
if direction == 'forward':
self._move('w', duration)
elif direction == 'backward':
self._move('s', duration)
elif direction == 'left':
self._move('a', duration)
elif direction == 'right':
self._move('d', duration)
pyautogui.keyUp('e')
def crouch_and_move(self, direction, duration):
"""Перемещение при приседании."""
pyautogui.keyDown('shift')
time.sleep(0.1)
if direction == 'forward':
self._move('w', duration)
elif direction == 'backward':
self._move('s', duration)
elif direction == 'left':
self._move('a', duration)
elif direction == 'right':
self._move('d', duration)
pyautogui.keyUp('shift')
def open_inventory(self):
"""Открывает инвентарь (нажатие I)."""
pyautogui.press('i')
def close_inventory(self):
"""Закрывает инвентарь (нажатие Esc)."""
pyautogui.press('esc')
def change_camera(self, clicks):
"""Меняет вид камеры (нажатие C)."""
for _ in range(clicks):
pyautogui.press('c')
time.sleep(0.5)
def select_inventory_slot(self, slot_number):
"""Выбирает слот в инвентаре (нажатие клавиш 1–8)."""
if 1 <= slot_number <= 8:
pyautogui.press(str(slot_number))
else:
print("Недопустимый номер слота: " + str(slot_number))
def place_block(self):
"""Ставит блок (правый клик мыши)."""
pyautogui.click(button='right')
def mine_block(self):
"""Ломает блок (удержание левого клика мыши)."""
pyautogui.mouseDown(button='left')
time.sleep(2) # Удерживаем кнопку мыши 2 секунды
pyautogui.mouseUp(button='left')
def send_chat_message(self, message):
"""Отправляет сообщение в чат с поддержкой кириллицы."""
# Открываем чат
pyautogui.press('t')
time.sleep(0.1)
# Копируем текст в буфер обмена
pyperclip.copy(message)
# Вставляем текст из буфера обмена (Ctrl + V)
pyautogui.hotkey('ctrl', 'v')
# Отправляем сообщение
pyautogui.press('enter')
def _calculate_pixels_for_degrees(self, degrees):
"""Вычисляет количество пикселей для сдвига мыши, необходимое для имитации поворота на заданный угол."""
pixels_per_degree = self.screen_width / self.fov
return int(degrees * pixels_per_degree)
def turn(self, degrees_x, degrees_y):
"""Поворачивает персонажа на указанное количество градусов."""
pixel_offset_x = self._calculate_pixels_for_degrees(degrees_x)
pixel_offset_y = self._calculate_pixels_for_degrees(degrees_y)
pyautogui.moveRel(pixel_offset_x, pixel_offset_y, duration=0.0)
time.sleep(0.05)
def drop_item(self):
"""Выбрасывает предмет (нажатие Q)."""
pyautogui.press('q')
time.sleep(0.1)

Уп­равле­ние в Minecraft будет отли­чать­ся нес­коль­кими кла­виша­ми, код фай­ла мож­но взять в репози­тории про­екта.

Даль­ше — демонс­тра­цион­ный код, который пооче­ред­но отправ­ляет все коман­ды боту, что­бы про­верить, как он их выпол­няет.

Вот как это работа­ет.

Мы под­клю­чаем к нашему фай­лу коман­ды управле­ния ботом из пре­дыду­щего лис­тинга.

Важ­но: при под­клю­чении нуж­но ука­зывать раз­меры окна игры! Если раз­мер невер­ный, то бот будет неп­равиль­но рас­счи­тывать углы для поворо­та. Для упро­щения и сок­ращения кода в этом фай­ле они ука­заны вруч­ную, ты можешь их испра­вить на свои зна­чения. Далее мы будем опре­делять эти парамет­ры авто­мати­чес­ки. Сей­час же нам надо про­верить, что мы можем управлять пер­сонажем.

Зай­ди в игру и най­ди ров­ную повер­хность, запус­ти experiment_1.py в тер­минале и кли­кай по окну с игрой, что­бы оно получи­ло фокус.

Для Minecraft нуж­но будет заком­менти­ровать одну стро­ку и рас­коммен­тировать дру­гую — они помече­ны в коде.

experiment_1.py
import pyautogui
import time
import cv2
import os
import datetime
import re
# Для Luanti
from luanti.controller import LuantiController as Controller
# Для Minecraft
# from minecraft.controller import MinecraftController as Controller
# Лучше сделать размер окна игры такого размера
window_width, window_height = 640, 640
def run_bot_actions(controller):
"""Выполняет все действия бота."""
normal_move_time = 2
run_move_time = 1
methods = [
("устанавливает направление взгляда",
lambda: (
controller.turn(0, 90),
controller.turn(0, -25)
)
),
("идет вперед", lambda: controller.move_forward(normal_move_time)),
("идет назад", lambda: controller.move_backward(normal_move_time)),
("идет влево", lambda: controller.move_left(normal_move_time)),
("идет вправо", lambda: controller.move_right(normal_move_time)),
("прыгает", controller.jump),
("бежит вперед", lambda: controller.run("forward", run_move_time)),
("бежит назад", lambda: controller.run("backward", run_move_time)),
("бежит влево", lambda: controller.run("left", run_move_time)),
("бежит вправо", lambda: controller.run("right", run_move_time)),
("приседает и идет вперед", lambda: controller.crouch_and_move("forward", normal_move_time)),
("приседает и идет назад", lambda: controller.crouch_and_move("backward", normal_move_time)),
("приседает и идет влево", lambda: controller.crouch_and_move("left", normal_move_time)),
("приседает и идет вправо", lambda: controller.crouch_and_move("right", normal_move_time)),
("открывает инвентарь", controller.open_inventory),
("закрывает инвентарь", controller.close_inventory),
("меняет камеру", lambda: controller.change_camera(3)),
("выбирает слот 8 и ставит блок",
lambda: (
controller.select_inventory_slot(8),
controller.place_block()
)
),
("выбирает слот 5 и копает блок",
lambda: (
controller.select_inventory_slot(5),
controller.mine_block()
)
),
("выбирает слот 1 и кушает :)",
lambda: (
controller.select_inventory_slot(1),
time.sleep(0.2),
pyautogui.mouseDown(button='left'),
time.sleep(0.1),
pyautogui.mouseUp(button='left')
)
),
("поворачивается на 10 градусов вправо", lambda: controller.turn(10, 0)),
("поворачивается на 10 градусов влево", lambda: controller.turn(-10, 0)),
("поворачивается на 10 градусов влево",lambda: controller.turn(-10, 0)),
("поворачивается на 10 градусов вправо",lambda: controller.turn(10, 0)),
("Делает скриншот", controller.take_screenshot),
("пишет в чат", lambda: controller.send_chat_message("Тест пройден, мои поздравления!")),
]
for description, method in methods:
print(f"[БОТ] {description}")
if isinstance(method, tuple):
for action in method:
action()
else:
method()
time.sleep(0.2)
if __name__ == "__main__":
print("Начинаем тестирование всех методов...")
print("Пожалуйста, переключите фокус на окно с игрой...")
time.sleep(5)
if window_width is not None and window_height is not None:
controller = Controller(window_width, window_height)
run_bot_actions(controller)
print(f"[БОТ] работу закончил.")

Ес­ли бот зашеве­лил­ся, зна­чит, есть кон­троль над ним и мож­но дви­гать­ся даль­ше.

Что­бы хра­нить все нас­трой­ки в одном мес­те, напишем кон­фигура­цион­ный файл.

import os
# Базовое имя каталога, по умолчанию "minebot"
BASE_DIR_NAME = "minebot"
# Полный путь к базовой директории
BASE_DIR = os.path.join(os.path.expanduser("~"), BASE_DIR_NAME)
# Путь к модели
MODEL_PATH = os.path.join(BASE_DIR, "runs", "segment", "train", "weights", "best.pt")
# Luanti
# Путь к скриншотам для Luanti
SCREENSHOT_PATH = os.path.join(os.path.expanduser("~"), "snap/minetest/current/screenshots/")
# Шаблон имени скриншота для Luanti
SCREENSHOT_PATTERN = r"screenshot_(\d{8}_\d{6})\.png"
# Minecraft
# Путь к скриншотам для Minecraft, исправь путь к твоему майнкрафту
# SCREENSHOT_PATH = os.path.join(os.path.expanduser("~"), "Minecraft_1.20.1/screenshots/")
# Шаблон имени скриншота для Minecraft
# SCREENSHOT_PATTERN = r"^\d{4}-\d{2}-\d{2}_\d{2}\.\d{2}\.\d{2}\.png$"

info

Путь к катало­гу сним­ков ука­зан для вари­анта уста­нов­ки Luanti при исполь­зовании Snap в качес­тве источни­ка.

Опять же, если ты хочешь исполь­зовать бота в Minecraft, заком­менти­руй стро­ки для Luanti и рас­коммен­тируй для Minecraft и ука­жи пра­виль­ный путь к катало­гу, в котором у тебя уста­нов­лен Minecraft.

Ес­ли ты уста­нав­ливал Luanti из источни­ка Snap, то путь к катало­гу скрин­шотов будет такой:

~/snap/minetest/current/screenshots/

Ес­ли уста­нав­ливал из пакета .deb, то такой:

~/.minetest/screenshots/

RAM-диск

Час­тая запись сним­ков на диск — ресур­соем­кий про­цесс. К тому же скри­ны нам нуж­ны для ана­лиза, а не для хра­нения. Поэто­му, если ты будешь раз­вивать сво­его бота, рекомен­дую записы­вать сним­ки на вир­туаль­ный диск в опе­ратив­ной памяти. Вот как это мож­но сде­лать.

Сна­чала соз­даем RAM-диск с помощью tmpfs.

Де­лаем дирек­торию для него:

sudo mkdir /mnt/ramdisk

Мон­тиру­ем tmpfs в эту дирек­торию:

sudo mount -t tmpfs -o size=56M tmpfs /mnt/ramdisk

Здесь size=56M — это раз­мер RAM-дис­ка. Ты можешь изме­нить его в зависи­мос­ти от объ­ема дос­тупной опе­ратив­ной памяти. Мы сти­раем фай­лы пос­ле исполь­зования, поэто­му 56 Мбайт — это даже очень мно­го.

Те­перь соз­дадим сим­воличес­кую ссыл­ку.

Мо­жешь перемес­тить текущий каталог сним­ков в дру­гое мес­то (если нуж­но сох­ранить сущес­тву­ющие сним­ки):

mv ~/minecraft/screenshots ~/minecraft/screenshots_backup

И соз­дадим сим­воличес­кую ссыл­ку из ~/minecraft/screenshots на RAM-диск:

ln -s /mnt/ramdisk ~/minecraft/screenshots

Те­перь, ког­да игра будет сох­ранять сним­ки в ~/minecraft/screenshots, они фак­тичес­ки будут записы­вать­ся в опе­ратив­ную память (в /mnt/ramdisk).

Вмес­то ~/minecraft/screenshots исполь­зуй пра­виль­ный каталог сним­ков тво­ей игры.

Ес­ли ты уста­нав­ливал Minetest через Snap, то наш кас­томный набор тек­стур нуж­но положить вот в этот каталог:

~/snap/minetest/current/textures/minebot

Ес­ли ста­вил из пакета deb, то вот сюда:

~.minetest/textures/minebot

У меня — пер­вый вари­ант. В этом катало­ге лежит файл, который нам нужен для рас­позна­вания бло­ков булыж­ника: default_cobble.png

В Minetest исполь­зование собс­твен­ных тек­стур вклю­чает­ся на вклад­ке «Кон­тент», нап­ротив выб­ранных тек­стур дол­жно появить­ся сло­во «Вклю­чено».

В Minecraft пакеты тек­стур хра­нят­ся в катало­ге ~/minecraft/resourcepacks/, если игра уста­нов­лена в ~/minecraft.

Под­готов­ленный набор (из целой одной тек­сту­ры!) мож­но ска­чать из ре­пози­тория про­екта. Имен­но на ее рас­позна­вание нат­рениро­ван бот.

Те­перь нуж­но нас­тро­ить игру на нуж­ное раз­решение. Луч­ше, что­бы раз­меры сним­ков для обу­чения и раз­меры сним­ков, которые мы потом будем рас­позна­вать в игре, были оди­нако­выми — это повысит качес­тво рас­позна­вания. При этом раз­мер сним­ков пря­мо вли­яет на тре­буемые для рас­позна­вания ресур­сы и на ско­рость обра­бот­ки сним­ков.

Я для себя решил, что мой бот будет играть сам, авто­ном­но, поэто­му я выб­рал раз­решение пониже — 640 на 640 пик­селей. Нас­тро­ить раз­решение мож­но в нас­трой­ках, в раз­деле «Гра­фика». Нуж­но задать ширину и высоту экра­на, и не забудь нажать кноп­ку «Наз­начить» для сох­ранения изме­нений.

На той же стра­нице нас­тро­ек ниже есть параметр «Угол обзо­ра». Про­верь, что его зна­чение сов­пада­ет со зна­чени­ем self.fov в фай­ле controller.py (по умол­чанию 72). Заод­но мож­но отклю­чить все тени и все лиш­ние пре­лес­ти осве­щения и сгла­жива­ния, что­бы тек­сту­ры мень­ше иска­жались и рас­позна­вание обра­зов работа­ло луч­ше. К тому же игра будет пот­реблять мень­ше ресур­сов.

В общем, вык­люча­ем вооб­ще все, что вык­люча­ется!

Для Minecraft нужен чуть более слож­ный под­ход. При каж­дом запус­ке игры надо будет вруч­ную из тер­минала менять раз­решение окна на нуж­ное нам.

Вы­пол­ним коман­ду

wmctrl -l

Она выведет спи­сок всех откры­тых окон. Ищи стро­ку с наз­вани­ем Minecraft (нап­ример, Minecraft 1.20.1).

Те­перь исполь­зуй xdotool, что­бы изме­нить раз­мер окна. Нап­ример:

xdotool search --name "Minecraft Forge*" windowsize 640 640

Здесь Minecraft Forge* — это часть наз­вания окна, которое ты нашел с помощью wmctrl -l.

Так­же для Minecraft допол­нитель­но нуж­но вык­лючить нас­трой­ку «Необ­работан­ный ввод» в нас­трой­ках мыши. Если это­го не сде­лать, то при попыт­ке изме­нить нап­равле­ние взгля­да бота пер­сонаж будет опус­кать взгляд в зем­лю.

С нас­трой­кой самой игры на этом все. Мож­но перехо­дить к съем­кам набора для обу­чения модели.

 

Обучение

Для обу­чения нам пот­ребу­ется поряд­ка 150–200 сним­ков. Я делал их в двух локаци­ях: одну соз­дал спе­циаль­но и пос­тро­ил там дорогу шириной три бло­ка. Непода­леку за хол­мом пос­тавил столб и арку — для раз­нооб­разия. Вто­рой локаци­ей был город, в котором уже есть вымощен­ные булыж­ником дорож­ки.

Пе­ред выбором мес­та хорошо его осмотри, что­бы ниг­де неожи­дан­но не тор­чал булыж­ник. При раз­метке сним­ков тебе надо будет отме­тить все сов­падения.

Осо­бых рекомен­даций по тех­нике съем­ки нет, глав­ное — раз­нооб­разие. И учи­тывай, что потом на каж­дом сним­ке нуж­но будет выделить пря­моуголь­ником всю замощен­ную булыж­ником повер­хность. Если дорога будет идти по диаго­нали через весь экран, при­дет­ся дол­го и уто­митель­но раз­мечать ее лесен­кой. Я в таких слу­чаях шел от самого боль­шого учас­тка, потом сле­дующий по величи­не и даль­ше все мень­шие и мень­шие кусоч­ки.

Выделяем булыжник
Вы­деля­ем булыж­ник

На­пом­ню, сни­мок дела­ется кноп­кой F12 (или F2 в Minecraft), нам надо поряд­ка 150–200. Впе­ред!

Впро­чем, сле­дующий этап — еще более уто­митель­ный. Запасай­ся тер­пени­ем! Нам нуж­но на каж­дом сним­ке обвести булыж­ник пря­моуголь­ником. Сде­лать это надо точ­но и акку­рат­но, не зах­ватывая лиш­него и не оставляя час­ти булыж­ника нераз­мечен­ными. Качес­тво раз­метки очень силь­но ска­жет­ся на качес­тве обу­чения модели.

Вот нес­коль­ко прос­тых инс­тру­мен­тов, которые подой­дут для анно­тации:

  • Он­лай­новый сер­вис — прос­той и понят­ный. Из минусов: фай­лы с анно­таци­ями он выда­ет в фор­мате CSV. Это не наш фор­мат, но кон­верти­ровать нет­рудно.
  • YOLO-Annotation-Tool — набор из фай­лов JavaScript, работа­ющий в бра­узе­ре локаль­но. В поле Classes заг­ружа­ется тек­сто­вый файл с име­нами всех клас­сов. В нашем слу­чае — файл, содер­жащий стро­ку 0 cobblestone.
  • Label Studio — прог­рамма, которая работа­ет локаль­но и под­нима­ет свой веб‑интерфейс. Из минусов — нуж­но ста­вить мно­го зависи­мос­тей, из плю­сов — есть воз­можность сов­мес­тной работы над анно­таци­ей фай­лов.

Пос­ле раз­метки при помощи YOLO-Annotation-Tool у меня на руках появил­ся каталог со сним­ками в фор­мате PNG и фай­лы с анно­таци­ями в фор­мате YOLO. Обра­зец мож­но пос­мотреть в ре­пози­тории про­екта.

Формат аннотаций YOLO

Каж­дая стро­ка фай­ла содер­жит информа­цию об одном объ­екте на изоб­ражении. Есть тип border box — это ког­да обоз­нача­ется область, в которой находит­ся объ­ект. Эта информа­ция исполь­зует­ся для детек­та моделей.

При­мер:

<class_id> <x_center> <y_center> <width> <height>

Здесь

  • class_id — целое чис­ло, пред­став­ляющее номер клас­са объ­екта. Нумера­ция клас­сов начина­ется с 0. Нап­ример, если у тебя есть два клас­са — «дорога» и «пре­пятс­твие», то класс «дорога» может быть пред­став­лен как 0, а «пре­пятс­твие» как 1;
  • x_center и y_center — коор­динаты цен­тра объ­екта, нор­мализо­ван­ные отно­ситель­но ширины и высоты изоб­ражения. Эти зна­чения лежат в диапа­зоне от 0 до 1;
  • width и height — схо­жие зна­чения для ширины и высоты изоб­ражения.

Наш class_id во всех фай­лах равен нулю, потому что у нас толь­ко один класс — булыж­ник. Если на сним­ках будут объ­екты раз­ных клас­сов, то для каж­дого клас­са будет свой id. Нап­ример:

0 cactus
1 cobblestone
2 sky
3 sun

На вся­кий слу­чай мы сде­лаем про­вер­ку наших фай­лов анно­тации (это не обя­затель­но, но инте­рес­но).

Да­вай прос­то наложим на наши изоб­ражения мас­ки, записан­ные в фай­лах с анно­таци­ями. Для это­го мы вос­поль­зуем­ся неболь­шим скрип­том.

visualize_bbox_mask.py
import cv2
import os
import numpy as np
import re
# Импортируем переменные из config.py
from config import BASE_DIR
def visualize_bbox(image_path, labels_path, output_path):
"""Визуализирует bounding box маски на изображении."""
img = cv2.imread(image_path)
if img is None:
print(f"Ошибка: Не удалось загрузить изображение {image_path}")
return
height, width, _ = img.shape
# Создаем пустую маску
mask_img = np.zeros_like(img, dtype=np.uint8)
if not os.path.exists(labels_path):
print(f"Предупреждение: Файл меток {labels_path} не найден.")
return
with open(labels_path, "r") as f:
for line in f:
parts = line.strip().split()
# Проверяем, что есть ровно пять элементов
if len(parts) != 5:
print(f"Предупреждение: Неверное кол-во элементов в файле {labels_path}: {line}")
continue
try:
class_id = int(parts[0])
x_center, y_center, box_width, box_height = map(float, parts[1:])
# Преобразуем координаты в пиксели
x1 = int((x_center - box_width / 2) * width)
y1 = int((y_center - box_height / 2) * height)
x2 = int((x_center + box_width / 2) * width)
y2 = int((y_center + box_height / 2) * height)
# Рисуем прямоугольник (bounding box) на маске
cv2.rectangle(mask_img, (x1, y1), (x2, y2), (0, 255, 0), -1)
except ValueError as e:
print(f"Ошибка при обработке строки в файле {labels_path}: {line}, ошибка: {e}")
# Объединяем исходное изображение и маску
overlay = cv2.addWeighted(img, 0.7, mask_img, 0.3, 0)
cv2.imwrite(output_path, overlay)
print(f"Визуализация сохранена в {output_path}")
if __name__ == "__main__":
# Директория с изображениями
images_dir = os.path.join(BASE_DIR, "dataset", "check_mask")
# Директория с метками (в том же месте, что и изображения)
labels_dir = images_dir
if not os.path.exists(images_dir):
print(f"Ошибка: Папка '{images_dir}' не найдена.")
exit()
# Регулярное выражение для проверки имени файла
screenshot_regex = re.compile(r"(\w+)\.png")
for filename in os.listdir(images_dir):
match = screenshot_regex.match(filename)
if match:
image_path = os.path.join(images_dir, filename)
# Формируем имя файла меток
labels_name = os.path.splitext(filename)[0] + ".txt"
# Путь к файлу меток
labels_path = os.path.join(labels_dir, labels_name)
output_name = os.path.splitext(filename)[0] + "_masked.png"
output_path = os.path.join(images_dir, output_name)
visualize_bbox(image_path, labels_path, output_path)

Ре­зуль­тат дол­жен выг­лядеть при­мер­но как на скрин­шотах.

Соз­дадим такую струк­туру катало­гов:

/dataset
├──/ train_images
├──/ test_images
├──/ val_images
└─── dataset.yaml

Сним­ки надо рас­пре­делить по пап­кам в таком соот­ношении: в катало­ги test_images и val_images по 10–15% от обще­го количес­тва сним­ков, осталь­ные — в каталог train_images. Это основной набор сним­ков для обу­чения.

Наз­вания фай­лов могут быть любыми, но на вся­кий слу­чай луч­ше не исполь­зовать кирил­лицу.

Фай­лы с анно­таци­ями дол­жны быть в том же катало­ге, что и сним­ки. У каж­дого сним­ка дол­жен быть свой файл с анно­таци­ей, с тем же наз­вани­ем, но с рас­ширени­ем .txt. В катало­ге test_images фай­лы анно­таций не нуж­ны, он необ­ходим для про­вер­ки того, как модель обу­чает­ся. Ког­да будем делать авто­мати­чес­кую раз­метку для вто­рой модели, советую ски­нуть сюда фай­лы с самой труд­ной или пло­хой раз­меткой, если такие най­дут­ся.

Еще нам нужен кон­фиг для опи­сания пути к нашим катало­гам со сним­ками.

dataset/dataset.yaml
# train и val пути к изображениям
train: /home/ksandr/minebot/dataset/train_images
val: /home/ksandr/minebot/dataset/val_images
test: /home/ksandr/minebot/dataset/test_images
# Количество классов
nc: 1
# Имена классов
names: ['cobblestone']

В фай­ле ука­зыва­ются абсо­лют­ные пути, так что исправь их на свои.

Итак, у нас все готово для запус­ка обу­чения пер­вой модели YOLO detect.

info

YOLO в наз­вании модели озна­чает You Only Look Once — «Ты смот­ришь толь­ко один раз». Более ран­ние методы детек­тирова­ния объ­ектов делали по нес­коль­ко про­ходов, но потом уда­лось обой­тись одним.

Для обу­чения модели запус­ти в тер­минале файл train_detect_model.py. И можешь идти завари­вать чай, выгули­вать собаку, зво­нить самому бол­тли­вому при­яте­лю или смот­реть сери­ал — кон­крет­ный выбор зависит от того, нас­коль­ко мощ­ное у тебя железо.

train_detect_model.py
import os
from ultralytics import YOLO
# Импортируем переменные из config.py
from config import BASE_DIR
# 1. Загрузка модели
# Загрузка предобученной модели YOLOv8n
model = YOLO("yolov8n.pt")
# Путь к файлу dataset.yaml
data_path = os.path.join(BASE_DIR, "dataset", "dataset.yaml")
# 2. Обучение модели
model.train(
data = data_path,
# Количество эпох обучения
epochs = 100,
# Размер пакета
batch = 16,
# Размер изображений
imgsz = 640,
)
Результат
Ре­зуль­тат

Ре­зуль­татом выпол­нения скрип­та выше будет запуск обу­чения модели: она будет тре­ниро­вать­ся рас­позна­вать на сним­ках объ­екты с нашей тек­сту­рой «булыж­ник». Точ­нее, сна­чала ска­чает­ся пре­добу­чен­ная модель yolov8n.pt, а затем она дообу­чит­ся на нашем наборе сним­ков.

В резуль­тате выпол­нения скрип­та будут соз­даны катало­ги ~/minebot/runs/detect/train/weights/ (воз­можно, train2, train3 и так далее — если обу­чение запус­калось нес­коль­ко раз). В нем будут две готовые к исполь­зованию модели. Далее мы ста­нем исполь­зовать модель ~/minebot/runs/detect/train/weights/best.pt.

Ко­неч­но, сра­зу хочет­ся про­верить, как это работа­ет. Давай поп­робу­ем най­ти наши булыж­ники в вир­туаль­ной сре­де. Будем делать сни­мок каж­дые пять секунд, рас­смат­ривать его и выводить в тер­минале сооб­щение о том, есть булыж­ник или нет. А потом поп­робу­ем повер­нуть взгляд бота в его сто­рону.

За­пус­тим игру. В тер­минале запус­ти скрипт experiment_2.py и переве­ди фокус на окно с игрой. Если у тебя Minecraft, то заком­менти­руй в начале фай­ла стро­ку для Luanti и рас­коммен­тируй вари­ант для Minecraft. То же сде­лай в начале метода def get_window_size().

experiment_2.py
import pyautogui
import time
import cv2
import numpy as np
import os
import re
from ultralytics import YOLO
# Импортируем переменные из config.py
from config import SCREENSHOT_PATH, SCREENSHOT_PATTERN, MODEL_PATH
# Для Luanti
from luanti.controller import LuantiController as Controller
# Для Minecraft
# from minecraft.controller import MinecraftController as Controller
def get_window_size():
"""Получает размер окна игры."""
# Делаем снимок, чтобы получить размер окна
# Для Luanti
pyautogui.press('f12')
# Pyautogui.press('F2') # Для Minecraft
time.sleep(1)
# Находим снимок
files = os.listdir(SCREENSHOT_PATH)
screenshot_file = None
for file in files:
if re.match(SCREENSHOT_PATTERN, file):
screenshot_file = file
break
if not screenshot_file:
print(f"Файл скриншота не найден по пути: {SCREENSHOT_PATH}")
return None, None
full_screenshot_path = os.path.join(SCREENSHOT_PATH, screenshot_file)
# Узнаем его размер
screenshot = cv2.imread(full_screenshot_path)
if screenshot is None:
print(f"Не удалось прочитать изображение по пути: {full_screenshot_path}")
return None, None
window_height, window_width, _ = screenshot.shape
print(f"Размер окна игры: {window_width}x{window_height}")
return window_width, window_height
def get_screenshot_path():
"""Находит путь к последнему скриншоту."""
files = os.listdir(SCREENSHOT_PATH)
screenshot_files = [f for f in files if re.match(SCREENSHOT_PATTERN, f)]
if screenshot_files:
# Сортируем по времени (последний файл первым)
screenshot_files.sort(reverse=True)
return os.path.join(SCREENSHOT_PATH, screenshot_files[0])
else:
return None
def detect_cobblestone(image_path, model, confidence_threshold=0.3):
"""Обнаруживает булыжники на изображении с помощью YOLO."""
img = cv2.imread(image_path)
if img is None:
print(f"Ошибка: не удалось прочитать изображение по пути: {image_path}")
# Возвращаем пустой список и None в случае ошибки
return [], None
# Создаем копию изображения для рисования
img_copy = img.copy()
detections = []
results = model(image_path)
# Проверяем, что результаты есть
if results is None or not results:
return detections, img_copy
# Итерируемся по результатам
for result in results:
# Получаем boxes
boxes = result.boxes
# Если boxes нет, то переходим к следующему результату
if boxes is None or not boxes :
continue
for box in boxes:
# Пропускаем пустой box
if box is None:
continue
# Получаем id класса
class_id = int(box.cls)
# Получаем уверенность
confidence = float(box.conf)
# Получаем координаты bounding box
x1, y1, x2, y2 = map(int, box.xyxy[0])
# Проверяем, является ли это булыжником с достаточной уверенностью
if class_id == 1 and confidence >= confidence_threshold:
# Добавляем в detections
detections.append([x1, y1, x2, y2, confidence])
# Рисуем прямоугольник
cv2.rectangle(img_copy, (x1, y1), (x2, y2), (0, 255, 0), 2)
return detections, img_copy
def find_largest_cobblestone(detections):
"""Находит наибольший булыжник из списка обнаружений."""
if not detections:
return None
# Площадь прямоугольника
largest_cobblestone = max(detections, key=lambda x: (x[2] - x[0]) * (x[3] - x[1]))
return largest_cobblestone
def turn_to_cobblestone(controller, cobblestone_bbox, img):
"""Поворачивает игрока к булыжнику."""
if cobblestone_bbox is None or img is None:
return
# Расчет поворота
center_x = controller.screen_width / 2
center_y = controller.screen_height / 2
bbox_center_x = (cobblestone_bbox[0] + cobblestone_bbox[2]) / 2
bbox_center_y = (cobblestone_bbox[1] + cobblestone_bbox[3]) / 2
degrees_x = (bbox_center_x - center_x) * (controller.fov / controller.screen_width)
degrees_y = (bbox_center_y - center_y) * (controller.fov / controller.screen_height)
# Рисуем точку
# Красная точка
cv2.circle(img, (int(bbox_center_x), int(bbox_center_y)), 5, (0, 0, 255), -1)
controller.turn(degrees_x, degrees_y)
def show_image(image):
"""Сохраняет и отображает изображение."""
cv2.imshow("Detection", image)
# Отображаем изображение (необходима пауза, чтобы окно успело отрисоваться)
cv2.waitKey(1)
if __name__ == "__main__":
print("Скрипт запущен. Для завершения нажмите Ctrl+C.")
# Загрузка модели YOLO
model = YOLO(MODEL_PATH)
# Получение размеров окна игры
window_width, window_height = get_window_size()
if window_width is None or window_height is None:
print("Не удалось получить размеры окна. Завершение работы.")
exit()
# Инициализация контроллера Luanti
controller = Controller(window_width, window_height)
try:
while True:
# Делаем скриншот
controller.take_screenshot()
# Ожидаем сохранения скриншота
time.sleep(1)
screenshot_path = get_screenshot_path()
if screenshot_path is None:
print("Скриншот не найден.")
continue
try:
# Обнаружение булыжников
detections, img_with_mask = detect_cobblestone(screenshot_path, model)
print(f"Найдено булыжников: {len(detections)}")
for detection in detections:
x1, y1, x2, y2, confidence = detection
print(f"Булыжник: ({x1}, {y1}, {x2}, {y2}), уверенность = {confidence:.2f}")
largest_cobblestone = find_largest_cobblestone(detections)
if largest_cobblestone is not None:
print(f"Поворот к наибольшему булыжнику: {largest_cobblestone}")
turn_to_cobblestone(controller, largest_cobblestone, img_with_mask)
show_image(img_with_mask)
except Exception as e:
print(f"Ошибка: {e}")
# Пауза между итерациями
time.sleep(5)
except KeyboardInterrupt:
print("\nСкрипт завершен пользователем.")

Нем­ного побегав и поняв, что модель, в прин­ципе, работа­ет, перей­дем к сле­дующе­му эта­пу обу­чения.

Для обу­чения вто­рой модели будем исполь­зовать те же сним­ки, но анно­тацию сде­лаем не типа border box, а сег­мента­цион­ную. То есть объ­екты будут выделе­ны не пря­моуголь­никами, а точ­но по кон­туру. Для это­го есть метод авто­мати­чес­кой анно­тации.

Соз­дай новый каталог dataset_seg со струк­турой, иден­тичной струк­туре катало­га dataset. Внут­ри соз­дай каталог img со сним­ками, которые мы будем раз­мечать. Теперь мож­но сде­лать еще сот­ню сним­ков — чем боль­ше раз­нооб­разно­го матери­ала, тем луч­ше будет обу­чение модели.

За­пус­ти в тер­минале скрипт auto_annotate.py для авто­анно­тации сним­ков, рас­положен­ных в катало­ге ~/minebot/dataset_seg/img.

auto_annotate.py
import os
from ultralytics.data.annotator import auto_annotate
from pathlib import Path
# Импортируем переменные из config.py
from config import BASE_DIR, MODEL_PATH
def annotate_images(images_dir, output_dir):
image_files = []
for filename in os.listdir(images_dir):
if filename.endswith('.png'):
image_files.append(os.path.join(images_dir, filename))
for image_path in image_files:
auto_annotate(
data = Path(image_path),
output_dir = output_dir,
det_model = MODEL_PATH,
sam_model = 'sam_b.pt'
)
if __name__ == "__main__":
# Директория со снимками
images_dir = os.path.join(BASE_DIR, "dataset_seg", "img")
output_dir = images_dir
if not os.path.exists(images_dir):
print(f"Ошибка: Папка '{images_dir}' не найдена.")
exit()
print(f"Начинаю автоматическую аннотацию изображений в '{images_dir}'.")
annotate_images(images_dir, output_dir)
print(f"Автоматическая аннотация завершена. Файлы меток сохранены в '{output_dir}'.")

При запус­ке это­го скрип­та ска­чива­ется и исполь­зует­ся пре­добу­чен­ная модель sam_b.pt, которая ищет внут­ри border box объ­ект и обво­дит его по кон­туру. Сам border box на сним­ке ищет обу­чен­ная нами модель detect.

Ре­зуль­татом работы будут фай­лы анно­тации для всех изоб­ражений в том же катало­ге, что и сами кар­тинки. Сним­ки раз­ложи по катало­гам в такой же про­пор­ции, что и для пер­вой модели.

Для наг­ляднос­ти наложим мас­ки анно­таций на фай­лы и пос­мотрим, что выдели­ли наши модели. Для это­го ско­пируй сним­ки и фай­лы с анно­таци­ями в каталог ~/minebot/dataset_seg/check_mask и выпол­ни скрипт visualize_seg_mask.py.

visualize_seg_mask.py
import cv2
import os
import numpy as np
import re
# Импортируем переменные из config.py
from config import BASE_DIR
def visualize_segmentation(image_path, labels_path, output_path):
"""Визуализирует маски сегментации на изображении."""
img = cv2.imread(image_path)
if img is None:
print(f"Ошибка: Не удалось загрузить изображение {image_path}")
return
height, width, _ = img.shape
# Создаем пустую маску
mask_img = np.zeros_like(img, dtype=np.uint8)
if not os.path.exists(labels_path):
print(f"Предупреждение: Файл меток {labels_path} не найден.")
return
with open(labels_path, "r") as f:
for line in f:
parts = line.strip().split()
if len(parts) < 4:
print(f"Предупреждение: Неверное кол-во элементов в файле {labels_path}: {line}")
continue
try:
# 1-е значение в строке класс
class_id = int(parts[0])
# Все остальные координаты
coords = [float(x) for x in parts[1:]]
# Преобразуем координаты в пиксели
coords = np.array(coords).reshape(-1, 2) * np.array([width, height])
coords = coords.astype(np.int32)
# Заполняем маску цветом для отображения
cv2.fillPoly(mask_img, [coords], (0, 255, 0))
except ValueError as e:
print(f"Ошибка при обработке строки в файле {labels_path}: {line}, ошибка: {e}")
except Exception as e:
print(f"Неизвестная ошибка при обработке строки в файле {labels_path}: {line}, ошибка: {e}")
# Объединяем исходное изображение и маску
overlay = cv2.addWeighted(img, 0.7, mask_img, 0.3, 0)
cv2.imwrite(output_path, overlay)
print(f"Визуализация сохранена в {output_path}")
if __name__ == "__main__":
# Директория с изображениями
images_dir = os.path.join(BASE_DIR, "dataset_seg", "check_mask")
labels_dir = images_dir
if not os.path.exists(images_dir):
print(f"Ошибка: Папка '{images_dir}' не найдена.")
exit()
# Регулярное выражение для проверки имени файла
screenshot_regex = re.compile(r"(\w+)\.png")
for filename in os.listdir(images_dir):
match = screenshot_regex.match(filename)
if match:
image_path = os.path.join(images_dir, filename)
# Формируем имя файла меток
labels_name = os.path.splitext(filename)[0] + ".txt"
# Путь к файлу меток
labels_path = os.path.join(labels_dir, labels_name)
output_name = os.path.splitext(filename)[0] + "_masked.png"
output_path = os.path.join(images_dir, output_name)
visualize_segmentation(image_path, labels_path, output_path)

Ре­зуль­тат дол­жен выг­лядеть так, как на скрин­шотах ниже.

Сним­ки с самыми неудач­ными мас­ками помес­тим в каталог ~/minebot/dataset_seg/test_images.

Прос­мотри каж­дый файл: нет ли таких, где мас­ка наложи­лась не на весь объ­ект целиком или помети­ла области, в которых нет булыж­ника? Если такое попадет­ся, то счи­тай, что это пло­хая раз­метка. Такие фай­лы нель­зя исполь­зовать для обу­чения модели, но они при­годят­ся для тес­тирова­ния. Перемес­ти такие фай­лы в каталог ~/minebot/dataset_seg/test_images.

Пос­ле обу­чения сег­мента­цион­ной модели будет инте­рес­но срав­нить, как изме­нилось качес­тво обна­руже­ния. Для это­го надо будет в фай­ле авто­анно­тации auto_annotate.py заменить стро­ку sam_model='sam_b.pt' стро­кой sam_model='Путь к твоей сегментационной модели'.

Те­перь напишем кон­фигура­цион­ный файл ~/minebot/dataset_seg/dataset.yaml.

Лис­тинг dataset.yaml
# train и val пути к изображениям
train: /home/ksandr/minebot/dataset_seg/train_images
val: /home/ksandr/minebot/dataset_seg/val_images
test: /home/ksandr/minebot/dataset_seg/test_images
# Количество классов
nc: 1
# Имена классов
names: ['cobblestone']

Все прос­то, поменя­лись толь­ко пути к катало­гам.

Скрипт для запус­ка обу­чения сег­мента­цион­ной модели — train_segment_model.py.

train_model_seg.py
import os
from ultralytics import YOLO
# Импортируем переменные из config.py
from config import BASE_DIR
# 1. Загрузка модели
# Загрузка предобученной модели yolov8n-seg
model = YOLO("yolov8n-seg.pt")
# Путь к файлу dataset.yaml
data_path = os.path.join(BASE_DIR, "dataset_seg", "dataset.yaml")
# 2. Обучение модели
model.train(
data = data_path,
# Количество эпох обучения
epochs = 150,
# Размер пакета
batch = 16,
# Размер изображений
imgsz = 640,
)
Результат
Ре­зуль­тат

От­личие от пре­дыду­щего фай­ла в том, что мы ска­чива­ем сег­мента­цион­ную пре­добу­чен­ную модель, исполь­зуем дру­гой тип анно­тации сним­ков и уве­личи­ваем количес­тво эпох. Если пер­вая детек­цион­ная модель нам была нуж­на толь­ко для раз­метки фай­лов анно­таций для обу­чения вто­рой модели, то к обу­чению этой модели сто­ит отнестись со всей серь­езностью, так как имен­но она и будет ана­лизи­ровать наши сним­ки.

Обу­чение этой модели зай­мет в нес­коль­ко раз боль­ше вре­мени, чем обу­чение пре­дыду­щей. Так что опять идем смот­реть сери­ал.

Пос­ле окон­чания обу­чения (хорошо, если не сери­ала) у нас появит­ся каталог ~/minebot/runs/segment/train/weights/, содер­жащий две модели.

Для начала мы опять про­верим, как модель «видит» булыж­ник. Запус­ти игру, запус­ти скрипт experiment_3.py в тер­минале, перек­лючи фокус на окно с игрой. Если у тебя Minecraft, сно­ва рас­коммен­тируй нуж­ную и заком­менти­руй ненуж­ную стро­ки в начале скрип­та и в начале метода def get_window_size().

experiment_3.py
import pyautogui
import time
import cv2
import numpy as np
import os
import re
import math
from ultralytics import YOLO
# Импортируем переменные из config.py
from config import SCREENSHOT_PATH, SCREENSHOT_PATTERN, MODEL_PATH
# Для Luanti
from luanti.controller import LuantiController as Controller
# Для Minecraft
# from minecraft.controller import MinecraftController as Controller
def get_window_size():
"""Получает размер окна игры."""
# Делаем снимок, чтобы получить размер окна
# Для Luanti
pyautogui.press('f12')
# Pyautogui.press('F2') # Для Minecraft
time.sleep(1)
# Ищем файл снимка
files = os.listdir(SCREENSHOT_PATH)
screenshot_file = None
for file in files:
if re.match(SCREENSHOT_PATTERN, file):
screenshot_file = file
break
if not screenshot_file:
print(f"Предупреждение: Файл скриншота не найден по пути: {SCREENSHOT_PATH}")
return None, None
full_screenshot_path = os.path.join(SCREENSHOT_PATH, screenshot_file)
# Получаем размер окна
screenshot = cv2.imread(full_screenshot_path)
if screenshot is None:
print(f"Предупреждение: Не удалось прочитать изображение по пути: {full_screenshot_path}")
return None, None
window_height, window_width, _ = screenshot.shape
print(f"Размер окна игры: {window_width}x{window_height}")
return window_width, window_height
def get_screenshot_path():
"""Находит путь к последнему скриншоту."""
files = os.listdir(SCREENSHOT_PATH)
screenshot_files = [f for f in files if re.match(SCREENSHOT_PATTERN, f)]
if screenshot_files:
# Сортируем по времени (последний файл первым)
screenshot_files.sort(reverse=True)
return os.path.join(SCREENSHOT_PATH, screenshot_files[0])
else:
return None
def _prepare_mask(mask):
"""Подготавливает маску для обработки."""
mask = mask.cpu().numpy()
# Делаем маску черно-белой
mask = (mask * 255).astype(np.uint8)
# Находим все ненулевые точки в маске
coords = cv2.findNonZero(mask)
return mask, coords
def _process_mask(mask, width, height):
"""Обрабатывает маску, вычисляет центр и угол поворота."""
mask, coords = _prepare_mask(mask)
# Проверяем, что точки найдены
if coords is None:
return None, None
# Получаем координаты X точек
x_coords = coords[:, :, 0]
# Получаем координаты Y точек
y_coords = coords[:, :, 1]
# Вычисляем центр маски
center_x = np.mean(x_coords)
center_y = np.mean(y_coords)
# Вычисляем угол поворота
# Фиксированная точка у ног бота (середина внизу изображения)
start_x = width // 2
# Фиксированная точка у ног бота (середина внизу изображения)
start_y = height
dx = center_x - start_x
dy = center_y - start_y
angle = math.degrees(math.atan2(dx, -dy))
return angle, (center_x, center_y)
def _overlay_mask(image, mask, center):
"""Отрисовывает маску на изображении."""
mask, coords = _prepare_mask(mask)
# Проверяем, что точки найдены
if coords is None:
return image
# Приводим координаты в нужный формат
coords = coords.reshape(-1, 2).astype(np.int32)
# Создаем пустую маску
mask_img = np.zeros_like(image, dtype=np.uint8)
# Заполняем маску цветом для отображения
cv2.fillPoly(mask_img, [coords], (0, 255, 0))
# Рисуем центр маски
cv2.circle(mask_img, (int(center[0]), int(center[1])), 5, (0, 0, 255), -1)
# Объединяем изображения
overlay = cv2.addWeighted(image, 0.7, mask_img, 0.3, 0)
return overlay
def detect_cobblestone(image_path, model):
"""Обнаруживает булыжники на изображении и возвращает угол поворота."""
# Проверяем существование файла
if not os.path.exists(image_path):
print(f"Предупреждение: Файл изображения '{image_path}' не существует.")
return None, None, False
# Загружаем изображение
image = cv2.imread(image_path)
# Проверяем, что изображение загрузилось
if image is None:
print(f"Предупреждение: Не удалось прочитать изображение '{image_path}'.")
return None, None, False
# Сохраняем высоту и ширину
height, width = image.shape[:2]
angles = []
processed_image = image.copy()
# Получаем результаты сегментации
results = model(image)
# Проверяем, что объекты найдены
if not results or not results[0].masks:
print(f"Предупреждение: Объекты не найдены на изображении '{image_path}'.")
# Возвращаем None для угла и False для обнаружения
return image, None, False
# Обходим все найденные маски
for mask_data in results[0].masks.data:
try:
angle, center = _process_mask(mask_data, width, height)
if angle is not None:
angles.append(angle)
processed_image = _overlay_mask(processed_image, mask_data, center)
except Exception as e:
print(f"Ошибка: {e}")
if len(angles) > 0:
# Возвращаем изображение, углы и True
return processed_image, angles, True
else:
# Возвращаем изображение, None и False
return processed_image, None, False
def turn_to_cobblestone(controller, angles):
"""Поворачивает игрока к ближайшему булыжнику."""
if angles is None:
return
# Выбираем угол с минимальным абсолютным значением для ближайшего булыжника
best_angle = min(angles, key=abs)
# Поворачиваем игрока
controller.turn(best_angle, 0)
def show_image(image):
"""Сохраняет и отображает изображение."""
cv2.imshow("Detection", image)
# Отображаем изображение (необходима пауза, чтобы окно успело отрисоваться)
cv2.waitKey(1)
if __name__ == "__main__":
print("Скрипт запущен. Для завершения нажмите Ctrl+C.")
# Загрузка модели YOLO
model = YOLO(MODEL_PATH)
# Получение размеров окна игры
window_width, window_height = get_window_size()
if window_width is None or window_height is None:
print("Не удалось получить размеры окна. Завершение работы.")
exit()
# Инициализация контроллера игры
controller = Controller(window_width, window_height)
try:
while True:
# Делаем скриншот
controller.take_screenshot()
# Ожидаем сохранения скриншота
time.sleep(1)
screenshot_path = get_screenshot_path()
if screenshot_path is None:
print("Скриншот не найден.")
continue
try:
# Обнаружение булыжников
img_with_mask, angles, is_detected = detect_cobblestone(screenshot_path, model)
show_image(img_with_mask)
if is_detected:
print(f"Углы поворота: {[f'{angle:.2f}' for angle in angles]}")
turn_to_cobblestone(controller, angles)
else:
print("Булыжник не обнаружен")
except Exception as e:
print(f"Ошибка: {e}")
# Пауза между итерациями
time.sleep(5)
except KeyboardInterrupt:
print("\nСкрипт завершен пользователем.")

За­пус­тив скрипт и подой­дя к булыж­нику, мы дол­жны уви­деть новое окно, на котором мас­кой будет выделен рас­познан­ный объ­ект.

 

Использование

И вот пос­ле всех под­готовок мы доб­рались до самой инте­рес­ной час­ти. Давай поп­робу­ем научить бота перед­вигать­ся по дорож­кам из тек­стур рас­познан­ного нами булыж­ника.

На­ша стра­тегия будет такой:

  • Ищем вер­хнюю гра­ницу кон­тура самого боль­шого объ­екта, который наш­ла наша модель.
  • Стро­им век­тор из середи­ны ниж­ней гра­ницы нашего сним­ка в середи­ну «вер­хней» гра­ницы кон­тура. Это будет то нап­равле­ние, куда дол­жен повер­нуть­ся наш бот.
  • Выс­читыва­ем угол меж­ду век­тором и вер­тикалью, а потом перево­дим его из гра­дусов в пик­сели по фор­муле, опи­сан­ной в методе _calculate_pixels_for_degrees() в фай­ле controller.py.
  • Для поворо­та исполь­зуем метод turn нашего кон­трол­лера, управля­юще­го ботом.

За­пус­ти игру, запус­ти скрипт detect_and_turn.py в тер­минале, перек­лючи фокус на окно с игрой.

В коде сно­ва есть стро­ки, которые раз­лича­ются для Minecraft и Luanti: в начале фай­ла и в начале метода get_window_size().

detect_and_turn.py
import pyautogui
import time
import cv2
import numpy as np
import os
import re
import math
from ultralytics import YOLO
from config import SCREENSHOT_PATH, SCREENSHOT_PATTERN, MODEL_PATH
# Для Luanti
from luanti.controller import LuantiController as Controller
# Для Minecraft
# from minecraft.controller import MinecraftController as Controller
def get_window_size():
"""Получает размер окна игры."""
# Делаем снимок, чтобы получить размер окна
# Для Luanti
pyautogui.press('f12')
# Pyautogui.press('F2') # Для Minecraft
time.sleep(1)
files = os.listdir(SCREENSHOT_PATH)
screenshot_file = next((f for f in files if re.match(SCREENSHOT_PATTERN, f)), None)
if not screenshot_file:
print(f"Предупреждение: Файл скриншота не найден по пути: {SCREENSHOT_PATH}")
return None, None
full_screenshot_path = os.path.join(SCREENSHOT_PATH, screenshot_file)
screenshot = cv2.imread(full_screenshot_path)
if screenshot is None:
print(f"Предупреждение: Не удалось прочитать изображение по пути: {full_screenshot_path}")
return None, None
window_height, window_width, _ = screenshot.shape
print(f"Размер окна игры: {window_width}x{window_height}")
return window_width, window_height
def get_screenshot_path():
"""Находит путь к последнему скриншоту."""
files = os.listdir(SCREENSHOT_PATH)
screenshot_files = [f for f in files if re.match(SCREENSHOT_PATTERN, f)]
if screenshot_files:
screenshot_files.sort(reverse=True)
return os.path.join(SCREENSHOT_PATH, screenshot_files[0])
return None
def _delete_png_files(directory):
"""Удаляет все PNG-файлы из указанной директории."""
for filename in os.listdir(directory):
if filename.lower().endswith(".png"):
file_path = os.path.join(directory, filename)
try:
os.remove(file_path)
except Exception as e:
print(f"Предупреждение: Не удалось удалить файл '{filename}': {e}")
def _find_and_draw_contours(image, mask, width, height, draw_vector=False, vector_target=None):
"""Находит и отрисовывает контуры на изображении."""
mask = mask.cpu().numpy()
mask = (mask * 255).astype(np.uint8)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
print("Отладочная информация: Контуры не найдены.")
return None, image, None
largest_contour = max(contours, key=cv2.contourArea)
min_y = min(point[0][1] for point in largest_contour)
top_points = [(x, y) for x, y in largest_contour[:, 0] if y == min_y]
if not top_points:
print("Отладочная информация: Верхние точки контура не найдены.")
return largest_contour, image, None
mid_x = sum(x for x, _ in top_points) // len(top_points)
top_point = (mid_x, min_y)
start_x, start_y = width // 2, height
cv2.drawContours(image, [largest_contour], -1, (255, 0, 0), 2)
if top_points:
cv2.polylines(image, [np.array(top_points)], isClosed=False, color=(0, 255, 0), thickness=3)
if draw_vector and vector_target:
if 0 <= vector_target[0] < width and 0 <= vector_target[1] < height:
cv2.arrowedLine(image, (start_x, start_y), vector_target, (0, 255, 0), 2, tipLength=0.2)
else:
print(f"Отладочная информация: Недопустимые координаты vector_target: {vector_target}")
return largest_contour, image, top_point
def _calculate_turn_angle(target_point, width, height):
"""Вычисляет угол поворота к целевой точке."""
start_x, start_y = width // 2, height
dx = target_point[0] - start_x
dy = target_point[1] - start_y
return math.degrees(math.atan2(dx, -dy))
def _process_detection(image, model, width, height):
"""Обрабатывает обнаружение булыжника на изображении."""
processed_image = image.copy()
largest_contour_area = 0
largest_contour_top_point = None
results = model(image)
if not results or not results[0].masks:
print("Предупреждение: Объекты не найдены на изображении.")
return processed_image, None, False
for mask_data in results[0].masks.data:
try:
contour, processed_image, top_point = _find_and_draw_contours(processed_image, mask_data, width, height)
if contour is None or top_point is None:
print("Отладочная информация: Контур или верхняя точка не найдены.")
continue
contour_area = cv2.contourArea(contour)
if contour_area > largest_contour_area:
largest_contour_area = contour_area
largest_contour_top_point = top_point
except Exception as e:
print(f"Ошибка при обработке контура: {e}")
if largest_contour_top_point:
angle = _calculate_turn_angle(largest_contour_top_point, width, height)
print(f"Угол поворота: {angle:.2f}")
_find_and_draw_contours(processed_image, results[0].masks.data[0], width, height, True, largest_contour_top_point)
return processed_image, angle, True
print("Отладочная информация: Ни один контур не был обработан.")
return processed_image, None, False
def detect_cobblestone(image_path, model, width, height):
"""Обнаруживает булыжники на изображении и возвращает угол поворота."""
if not os.path.exists(image_path):
print(f"Предупреждение: Файл изображения '{image_path}' не существует.")
return None, None, False
image = cv2.imread(image_path)
if image is None:
print(f"Предупреждение: Не удалось прочитать изображение '{image_path}'.")
return None, None, False
return _process_detection(image, model, width, height)
def turn_to_cobblestone(controller, angle):
"""Поворачивает игрока к ближайшему булыжнику."""
if angle is not None:
controller.turn(angle, 0)
def show_image(image):
"""Сохраняет и отображает изображение."""
cv2.imshow("Detection", image)
cv2.waitKey(1)
if __name__ == "__main__":
print("Скрипт запущен. Для завершения нажмите Ctrl+C.")
model = YOLO(MODEL_PATH)
window_width, window_height = get_window_size()
if window_width is None or window_height is None:
print("Не удалось получить размеры окна. Завершение работы.")
exit()
controller = Controller(window_width, window_height)
turn_counter = 0
_delete_png_files(SCREENSHOT_PATH)
try:
while True:
controller.take_screenshot()
# Ожидаем сохранения скриншота
time.sleep(0.1)
screenshot_path = get_screenshot_path()
if screenshot_path is None:
print("Скриншот не найден. Окно с игрой должно быть в фокусе.")
# Ожидаем сохранения скриншота
time.sleep(2)
continue
try:
img_with_mask, angle, is_detected = detect_cobblestone(screenshot_path, model, window_width, window_height)
show_image(img_with_mask)
if is_detected:
turn_counter = 0
print(f"Угол поворота: {angle:.2f}")
controller.move_forward(1)
turn_to_cobblestone(controller, angle)
else:
turn_counter += 1
controller.turn(45, 0)
print("Булыжник не обнаружен, поворот на 45 градусов.")
time.sleep(2)
if turn_counter > 7:
controller.send_chat_message("Я потерялся, мне нужна помощь!")
time.sleep(60)
except Exception as e:
print(f"Ошибка: {e}")
finally:
_delete_png_files(SCREENSHOT_PATH)
except KeyboardInterrupt:
print("\nСкрипт завершен пользователем.")

Пос­трой зам­кну­тую дорож­ку шириной в три бло­ка, со сто­роной ~15 бло­ков. Пос­тавь бота на любую ее часть так, что­бы он мог видеть дорож­ку. Запус­ти скрипт, и бот дол­жен идти по тво­ей дорож­ке.

 

Выводы

Мы научи­лись базовым прин­ципам, которые помогут соз­давать алго­рит­мы на осно­ве машин­ного зре­ния. При­мене­ния могут быть не толь­ко в Minecraft, да и не толь­ко в играх — исполь­зуя тот же метод, мож­но зап­рограм­мировать и нас­тояще­го робота, который будет ори­енти­ровать­ся по мар­керам или QR-кодам.

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    0 комментариев
    Межтекстовые Отзывы
    Посмотреть все комментарии