Перейти к содержанию
Публикация AiManual

End-to-End беспилотник на VLM: когда дрон сам понимает, куда лететь

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

Коротко

Что будет в материале

  1. 01

    Зачем дрону языковая модель? (Потому что классическое CV уже не катит)

  2. 02

    Что получится в итоге (без прикрас)

  3. 03

    Железо: что покупать и почему именно это

  4. 04

    Софтверный стек: от прошивки до нейросети

Зачем дрону языковая модель? (Потому что классическое CV уже не катит)

Пять лет назад автономный дрон означал кучу кода на OpenCV, детекторы ArUco маркеров и попытки заставить его не врезаться в стену. Сегодня все проще и сложнее одновременно. Vision-Language Models (VLM) - это не просто "распознавание объектов". Это понимание сцены. Дрон видит не "квадратный объект коричневого цвета", а "коричневая дверь, приоткрытая на 30 градусов". Разница колоссальная.

Важно: на момент написания (январь 2026) VLM-модели переживают взрывной рост. Если в 2024 году мы радовались BLIP-2, то сейчас уже есть десятки специализированных моделей для робототехники. В этом гайде я буду использовать LLaVA-NeXT-Video - потому что она умеет работать с видеопотоком, а не с одиночными кадрами.

Что получится в итоге (без прикрас)

Дрон, который:

  • Понимает голосовые команды: "облети квартиру по периметру и найди черную кошку"
  • Сам строит маршрут, избегая препятствий
  • Умеет импровизировать: если на пути внезапно появился человек, дрон остановится и спросит (текстом), можно ли продолжить
  • Работает полностью локально - никаких облаков, никаких подписок
  • Стоит в 3-4 раза дешевле коммерческих решений с аналогичными возможностями

Звучит как фантастика? Это потому что вы еще не видели, как современные VLM справляются с пространственным reasoning. Они реально понимают "слева от", "над", "между". И это меняет все.

Железо: что покупать и почему именно это

Здесь главная ошибка - пытаться запихнуть на дрон полноценную RTX 4090. Не надо. Наша архитектура распределенная:

Компонент Модель Цена (руб) Зачем
Дрон DJI Tello (б/у) 8 000-10 000 Дешевый, программируемый, стабильный в полете
Одноплатник на дроне Jetson Nano 4GB 15 000 Запускает легкую модель для экстренных решений
Носимый компьютер Intel NUC 13 Pro 45 000 Основная VLM, коммуникация с дроном по Wi-Fi
Камера доп. Raspberry Pi HQ Camera 6 000 Высокое разрешение для детального анализа

Почему такая схема? Потому что latency. Если вся обработка будет на NUC, а дрон только передает видео, мы получим задержку 200-300 мс. Критично для полета в помещении. Поэтому на Jetson Nano стоит крошечная модель (например, MobileVLM 1.7B), которая занимается только одним: "впереди препятствие? Да/Нет". Основная же VLM на NUC получает видео с задержкой, но зато может планировать сложные маршруты.

💡
Если у вас уже есть мощный домашний компьютер с GPU, можно использовать его вместо NUC. В статье про локальную LLM-инфраструктуру я подробно разбирал настройку. Главное - стабильный Wi-Fi 6 между дроном и компьютером.

Софтверный стек: от прошивки до нейросети

Собираем по слоям, как бутерброд:

1 Базовый слой: прошивка дрона

DJI Tello работает на проприетарной прошивке, но есть хаки. Качаем DJITelloPy - это Python библиотека, которая дает низкоуровневый доступ к контроллерам полета. Без этого дальше не сдвинемся.

# Устанавливаем на Jetson Nano
sudo apt update
sudo apt install python3-pip
pip3 install djitellopy opencv-python

Тестовая программа для проверки связи:

from djitellopy import Tello
import cv2

# Подключаемся к дрону
tello = Tello()
tello.connect()

# Включаем видеопоток
tello.streamon()
frame_read = tello.get_frame_read()

# Просто взлетаем и садимся
print(f"Батарея: {tello.get_battery()}%")
tello.takeoff()
tello.land()

2 Средний слой: коммуникация

Jetson Nano (на дроне) и NUC (на земле) общаются через ZeroMQ. Почему не ROS2? Потому что ROS2 для такой простой связки - это как пулемет для охоты на мух. К тому же, в моем гайде про мобильного робота-манипулятора я уже показывал, как ROS2 может усложнить жизнь.

# На Jetson Nano (паблишер видео)
import zmq
import cv2

context = zmq.Context()
socket = context.socket(zmq.PUB)
socket.bind("tcp://*:5555")

cap = cv2.VideoCapture(0)  # Камера дрона
while True:
    ret, frame = cap.read()
    if ret:
        # Ресайзим для экономии трафика
        frame_small = cv2.resize(frame, (640, 480))
        socket.send(frame_small.tobytes())
# На NUC (сабскрайбер)
import zmq
import cv2
import numpy as np

context = zmq.Context()
socket = context.socket(zmq.SUB)
socket.connect("tcp://[IP_JETSON]:5555")
socket.setsockopt_string(zmq.SUBSCRIBE, '')

while True:
    frame_bytes = socket.recv()
    frame = np.frombuffer(frame_bytes, dtype=np.uint8)
    frame = frame.reshape((480, 640, 3))
    # Теперь frame готов для обработки VLM

3 Верхний слой: VLM и принятие решений

Вот здесь начинается магия. Устанавливаем LLaVA-NeXT-Video - на январь 2026 это одна из лучших open-source моделей для видеоанализа.

Внимание: LLaVA-NeXT-Video требует минимум 16GB VRAM для комфортной работы в 7B параметров. Если GPU слабее - используйте Qwen2-VL-2B, она менее точная, но работает на 8GB.

# На NUC с NVIDIA GPU
git clone https://github.com/haotian-liu/LLaVA-NeXT
cd LLaVA-NeXT
pip install -e .

# Скачиваем веса модели (7B параметров)
huggingface-cli download llava-hf/llava-v1.6-vicuna-7b --local-dir ./weights

Базовый скрипт для анализа сцены:

from llava.model.builder import load_pretrained_model
from llava.mm_utils import process_images, tokenizer_image_token
from llava.constants import IMAGE_TOKEN_INDEX
import torch

# Загружаем модель
model_name = "./weights"
tokenizer, model, image_processor, context_len = load_pretrained_model(
    model_name=model_name,
    model_base=None,
    tokenizer_name=model_name
)
model = model.cuda()  # На GPU

# Функция для анализа кадра
def analyze_scene(image_frame, question):
    """
    image_frame: numpy array (H, W, 3) от камеры
    question: строка, например "Что находится в центре кадра?"
    """
    # Подготовка изображения
    image_tensor = process_images([image_frame], image_processor, model.config)
    image_tensor = [t.cuda() for t in image_tensor]
    
    # Формируем промпт
    prompt = f"USER: \n{question}\nASSISTANT:"
    
    # Токенизация
    input_ids = tokenizer_image_token(
        prompt, tokenizer, IMAGE_TOKEN_INDEX, return_tensors='pt'
    ).unsqueeze(0).cuda()
    
    # Генерация ответа
    with torch.inference_mode():
        output_ids = model.generate(
            input_ids,
            images=image_tensor,
            do_sample=True,
            temperature=0.2,
            max_new_tokens=100
        )
    
    # Декодируем ответ
    answer = tokenizer.batch_decode(output_ids, skip_special_tokens=True)[0]
    return answer

Архитектура принятия решений: как дрон думает

Самая интересная часть. У нас не одна нейросеть, а целый конвейер:

  1. Экстренный контроллер (на Jetson Nano): MobileVLM 1.7B, вопрос всегда один: "Есть ли прямо по курсу препятствие на расстоянии менее 2 метров? Ответ только YES или NO." Задержка: 15-20 мс.
  2. Планировщик маршрута (на NUC): LLaVA-NeXT 7B, получает стоп-кадр раз в секунду, анализирует: "Какие пути свободны для движения? Опиши их относительно текущего направления."
  3. Интерпретатор команд (тоже на NUC): та же LLaVA, но для текста. Превращает "найди кошку" в последовательность действий: "1. Осмотреть комнату по секторам 2. Искать небольшие движущиеся объекты 3. Приблизиться для идентификации"
💡
Ключевой трюк: мы НЕ используем VLM для непосредственного управления моторами. VLM выдает высокоуровневые команды ("поверни налево на 30 градусов"), которые потом преобразуются в низкоуровневые команды для DJITelloPy. Так надежнее.

Fine-tuning на своих данных: когда стандартной модели недостаточно

LLaVA-NeXT тренировалась на общих данных. Но ваша квартира - особенная. Нужно научить модель:

  • Узнавать вашу мебель ("это диван Ивановых, а не просто диван")
  • Понимать планировку ("коридор ведет в спальню, а не в стену")
  • Избегать опасных зон ("рядом с аквариумом не летать"

Делаем так:

# 1. Собираем датасет
import json
from pathlib import Path

# Структура для supervised fine-tuning
dataset = []

# Для каждого помещения делаем 10-15 снимков
for room in ["kitchen", "living_room", "bedroom"]:
    for i in range(15):
        # Запускаем дрон, делаем снимок
        # Вручную размечаем (или используем автоматическую разметку через GPT-4)
        item = {
            "id": f"{room}_{i}",
            "image": f"images/{room}_{i}.jpg",
            "conversations": [
                {
                    "from": "human",
                    "value": "Опиши, что ты видишь. Где можно пролететь?"
                },
                {
                    "from": "gpt",
                    "value": "Я вижу кухню. Слева - стол, справа - окно. Свободное пространство в центре комнаты. Можно лететь прямо или немного влево."
                }
            ]
        }
        dataset.append(item)

# Сохраняем
with open("drone_dataset.json", "w", encoding="utf-8") as f:
    json.dump(dataset, f, ensure_ascii=False, indent=2)
# 2. Fine-tuning с LoRA (чтобы не перетренить всю модель)
python llava/train/train_mem.py \
    --model_name_or_path ./weights \
    --version v1 \
    --data_path drone_dataset.json \
    --image_folder images/ \
    --vision_tower openai/clip-vit-large-patch14-336 \
    --mm_projector_type mlp2x_gelu \
    --tune_mm_mlp_adapter True \
    --bf16 True \
    --output_dir ./checkpoints \
    --num_train_epochs 3 \
    --per_device_train_batch_size 4 \
    --gradient_accumulation_steps 4 \
    --save_steps 100 \
    --save_total_limit 3 \
    --learning_rate 2e-4 \
    --weight_decay 0. \
    --warmup_ratio 0.03 \
    --lr_scheduler_type cosine \
    --logging_steps 1 \
    --model_max_length 2048 \
    --gradient_checkpointing True \
    --lazy_preprocess True

После 3 эпох (примерно 6 часов на RTX 4070) модель уже будет знать вашу квартиру лучше, чем вы сами. Шутка. Но ориентироваться будет точно лучше.

Интеграция всего: код, который все связывает

Теперь собираем все компоненты в одну систему. Главный скрипт на NUC:

import threading
import queue
import time
from collections import deque

class AutonomousDrone:
    def __init__(self):
        # Очереди для межпоточного обмена
        self.video_queue = queue.Queue(maxsize=10)
        self.command_queue = queue.Queue()
        
        # История кадров для видеоанализа (последние 5 кадров)
        self.frame_buffer = deque(maxlen=5)
        
        # Состояние дрона
        self.state = {
            "battery": 100,
            "position": (0, 0, 0),  # x, y, z
            "obstacle_near": False
        }
        
    def video_receiver_thread(self):
        """Получает видео от Jetson Nano"""
        while True:
            frame = receive_frame_from_zmq()  # Функция из примера выше
            if frame is not None:
                try:
                    self.video_queue.put_nowait(frame)
                except queue.Full:
                    pass  # Пропускаем кадр, если очередь переполнена
    
    def emergency_check_thread(self):
        """Быстрая проверка препятствий (на основе MobileVLM на Jetson)"""
        while True:
            if not self.video_queue.empty():
                frame = self.video_queue.get()
                
                # Отправляем на Jetson для быстрого анализа
                # (реализация через REST API к Jetson)
                response = requests.post(
                    "http://jetson.local:8080/check_obstacle",
                    json={"frame": frame.tolist()}
                )
                
                if response.json().get("obstacle"):
                    self.state["obstacle_near"] = True
                    # Немедленная команда стоп
                    self.send_emergency_stop()
                else:
                    self.state["obstacle_near"] = False
    
    def planning_thread(self):
        """Планирование маршрута с помощью LLaVA"""
        while True:
            time.sleep(1)  # Планируем раз в секунду
            
            if not self.video_queue.empty():
                # Берем последний кадр
                frames = list(self.video_queue.queue)
                latest_frame = frames[-1] if frames else None
                
                if latest_frame is not None:
                    # Анализируем сцену
                    prompt = """
                    Ты - пилот дрона. Проанализируй сцену:
                    1. Какие препятствия видишь?
                    2. Какие свободные пути для движения?
                    3. Если бы тебе нужно было лететь вперед, насколько это безопасно?
                    Ответ дай в формате JSON.
                    """
                    
                    analysis = self.llava_analyze(latest_frame, prompt)
                    
                    # Парсим ответ и планируем
                    free_paths = analysis.get("free_paths", [])
                    
                    if free_paths:
                        # Выбираем лучший путь
                        best_path = self.choose_best_path(free_paths)
                        # Конвертируем в команды дрону
                        commands = self.path_to_commands(best_path)
                        
                        for cmd in commands:
                            self.command_queue.put(cmd)
    
    def command_executor_thread(self):
        """Исполнитель команд"""
        while True:
            if not self.command_queue.empty():
                cmd = self.command_queue.get()
                
                # Проверяем, нет ли экстренной ситуации
                if not self.state["obstacle_near"]:
                    self.execute_command(cmd)
                
                time.sleep(0.1)  # 10 команд в секунду максимум
    
    def voice_command_listener(self):
        """Слушатель голосовых команд"""
        # Используем whisper.cpp для распознавания
        # или более легкую модель для локальной работы
        while True:
            command = listen_for_voice_command()
            
            if command:
                # Интерпретируем команду через VLM
                prompt = f"""
                Пользователь сказал: "{command}"
                
                Ты - система управления дроном. Преобразуй эту команду в последовательность действий.
                Формат ответа:
                {
                    "goal": "цель команды",
                    "steps": ["шаг1", "шаг2", ...],
                    "constraints": ["ограничение1", ...]
                }
                """
                
                plan = self.llava_text_only(prompt)
                
                # Добавляем шаги в очередь команд
                for step in plan["steps"]:
                    self.command_queue.put(step)
    
    def run(self):
        """Запуск всех потоков"""
        threads = [
            threading.Thread(target=self.video_receiver_thread),
            threading.Thread(target=self.emergency_check_thread),
            threading.Thread(target=self.planning_thread),
            threading.Thread(target=self.command_executor_thread),
            threading.Thread(target=self.voice_command_listener)
        ]
        
        for thread in threads:
            thread.daemon = True
            thread.start()
        
        # Главный цикл
        try:
            while True:
                time.sleep(1)
                # Мониторинг состояния
                self.log_state()
        except KeyboardInterrupt:
            print("\nЗавершение работы...")
            self.land_and_disconnect()

Типичные грабли (чтобы вы не наступили)

Грабли №1: Wi-Fi лагает
Решение: используйте Wi-Fi 6 роутер в одном помещении с дроном. Или лучше - выделенную точку доступа на NUC. В статье про локальный умный дом я показывал, как настроить стабильную локальную сеть.

Грабли №2: VLM глючит на однородных текстурах
Белые стены, однотонный ковер - модель теряется. Решение: добавляем в fine-tuning данные с такими сценами, учим модель говорить "не вижу ориентиров, нужна осторожность" вместо случайных ответов.

Грабли №3: Батарея садится за 10 минут
Дополнительная камера и Jetson Nano жрут энергию. Решение: внешний power bank на 10000 mAh, примотанный скотчем к дрону. Неэлегантно, но работает.

Что дальше? (Когда базовый вариант уже работает)

Самые интересные апгрейды:

  • Мультиагентность: несколько дронов, которые координируются через общую VLM. Один летает, второй страхует, третий снимает видео.
  • Обучение с подкреплением: вместо fine-tuning на статических данных, даем дрону возможность учиться на своих ошибках. Ударился о стену - запомнил, что так делать не надо.
  • Интеграция с домашней автоматизацией: дрон не просто летает, а взаимодействует с умным домом. "Найди кошку и включи свет в той комнате, где она сидит" - вот это уже уровень.
  • Лонгриды на борту: сохранять не просто логи, а полноценные отчеты о полете с анализом сцен. Потом можно анализировать, как менялась обстановка в квартире за день.
💡
Самый безумный эксперимент, который у меня получился: дрон, который учится у другого дрона. Один налетал 10 часов, собрал датасет, второй fine-tun'ится на этих данных. Получается что-то вроде коллективного разума. Если интересно - напишите в комментариях, сделаю отдельный гайд.

Финальный совет (который сэкономит вам неделю)

Не пытайтесь сделать все идеально с первого раза. Сначала добейтесь, чтобы дрон просто взлетел и передавал видео. Потом добавьте экстренную остановку при препятствиях. Потом - простой планировщик маршрута. И только потом - полноценную VLM.

Каждый этап тестируйте отдельно. VLM без дрона (просто анализируйте сохраненные видео). Дрон без VLM (ручное управление). Иначе вы никогда не поймете, где именно проблема: в железе, в коде или в модели.

И последнее: ваш дрон будет глупым. Первые недели он будет врезаться в стены, путать дверь с окном и садиться на кошку. Это нормально. Главное - чтобы вы понимали, ПОЧЕМУ он так делает. А с VLM это понимание приходит гораздо быстрее, чем с классическим CV. Потому что вместо "ошибка в строке 243" вы получаете "я думал, что эта тень - это проход, но оказалось, что это просто тень". И это - огромный прогресс.