Веб-камера — глаза робота: пишу веб-приложение на FastApi для управления DIY-проектом. Часть 1

от автора

Эта статья открывает цикл публикаций о создании open-source веб-приложения для стриминга видео с веб-камеры и управления роботом. Приложение позволит транслировать видео с камеры в реальном времени и отправлять команды управления роботом через интерфейс. Думаю, статья будет интересна веб-программистам, интересующимся работой с видеостримингом и FastAPI, а также робототехникам и энтузиастам DIY-проектов.

Идея проекта возникла из моего интереса к робототехнике и веб-программированию. Ранее в статье DIY-проект: гусеничная платформа с ИК-управлением на Arduino я создал гусеничную платформу на базе Iscra mini, управляемую ИК-пультом, и захотел развить эту платформу.

В качестве камеры я планирую использовать экшн-камеру, которая может работать как веб-камера. Если она окажется несовместимой с Linux, её можно будет заменить обычной веб-камерой. Основная цель проекта — создать гибкое решение, которое будет полезным для разных DIY-проектов.

Стриминг видео с веб-камеры

Я начал эксперименты со стримингом видео, используя универсальные UVC-драйверы в Linux Debian. UVC (USB Video Class) — это стандарт, разработанный USB Implementers Forum (USB-IF), который определяет, как веб-камеры, цифровые видеокамеры или другие видеоустройства должны передавать видеопоток через USB. Универсальные драйверы добавят удобства определения в системе разных веб-камер.

Проще говоря, это программное обеспечение в операционной системе (в моём случае в Linux), которое обеспечивает взаимодействие между ядром и UVC-совместимыми устройствами. В Linux этот драйвер называется uvcvideo и встроен в ядро как часть подсистемы Video4Linux2 (V4L2).

Для стриминга видео с камеры я планирую использовать программу MJPG-Streamer — готовое решение для трансляции видео по HTTP. Само веб-приложение будет подключаться к нему по URL. Использование этой программы сократит время на разработку, экономя его на механизме трансляции видеопотока.

Теперь я установлю MJPG-Streamer и все необходимые зависимости на целевое устройство. Сейчас я тестирую на ПК с Debian, однако этот процесс почти идентичен установке для Raspbian или Arbian, которые работают на платах Raspberry Pi и Orange Pi (в будущем я буду использовать такие платы для постройки робота в статьях DIY).

Шаг 1: Обновление системы

Обновляю список пакетов и устанавливаю их:

sudo apt-get update sudo apt-get upgrade -y

Эти команды нужны для предотвращения проблем с совместимостью пакетов.

Шаг 2: Установка зависимостей

Теперь устанавливаю зависимости:

sudo apt-get install -y build-essential cmake libjpeg-dev libv4l-dev ffmpeg

Разбор команды:

  • build-essential включает gcc и make, необходимые для сборки программ:

    • gcc — компилятор для языков C/C++;

    • make — инструмент для автоматизации сборки программ;

  • libjpeg-dev — библиотека для обработки JPEG;

  • libv4l-dev — библиотека для работы с Video4Linux2 (V4L2), которая используется для обработки видео с веб-камер.

  • Cmake — это кроссплатформенная система управления сборкой, которая автоматически генерирует файлы проектов и Makefile для упрощения процесса компиляции программ;

  • ffmpeg — инструмент для записи, конвертации, обработки и потоковой передачи аудио- и видеофайлов, поддерживающий множество форматов и кодеков.

Эти зависимости помогут правильно собрать программу для видеострима MJPG-Streamer и обрабатывать видео с веб-камеры.

Для дополнительного тестирования камеры я установлю утилиту v4l-utils:

sudo apt-get install -y v4l-utils

Утилита v4l-utils позволит мне узнать информацию о режимах камеры для её настройки и проверки.

Шаг 3: Установка MJPG-Streamer

Программа MJPG-Streamer позволяет получать видеопоток с веб-камеры, обращаясь к нему через URL-адрес. Я выбрал её, так как это простое готовое решение для видеострима.

Устанавливаю программу для видеострима:

git clone https://github.com/jacksonliam/mjpg-streamer.git cd mjpg-streamer/mjpg-streamer-experimental make && sudo make install export LD_LIBRARY_PATH=/usr/local/lib

Разбор команд:

  • git clone https://github.com/jacksonliam/mjpg-streamer.git — клонирование репозитория MJPG-Streamer;

  • cd mjpg-streamer/mjpg-streamer-experimental — переход в директорию для сборки;

  • make && sudo make install — компиляция и установка;

  • export LDLIBRARYPATH=/usr/local/lib — задаёт переменную окружения для зависимостей.

Шаг 4: Проверка устройства

Теперь мне нужно понять, как обращаться к веб-камере для работы с ней. Для этого я подключаю веб-камеру к устройству через USB. Устройством может быть ПК, ноутбук или плата типа Orange pi.

Проверяю наличие подключенных видеоустройств:

ls /dev/video*

У меня два виртуальных устройства, связанных с моей веб-камерой:

/dev/video0  /dev/video1

Далее я хочу проверить, какие форматы, разрешения экрана и количество fps доступны для моей камеры.

Проверяю видеоформаты:

v4l2-ctl --list-formats-ext

Результат команды (пример):

ioctl: VIDIOC_ENUM_FMT          Type: Video Capture           [0]: 'MJPG' (Motion-JPEG, compressed)                  Size: Discrete 1280x720                          Interval: Discrete 0.033s (30.000 fps)                  Size: Discrete 800x600                          Interval: Discrete 0.033s (30.000 fps)                  Size: Discrete 640x480                          Interval: Discrete 0.033s (30.000 fps)                  Size: Discrete 320x240                          Interval: Discrete 0.033s (30.000 fps)          [1]: 'YUYV' (YUYV 4:2:2)                  Size: Discrete 1280x720                          Interval: Discrete 0.100s (10.000 fps)                  Size: Discrete 800x600                          Interval: Discrete 0.050s (20.000 fps)                  Size: Discrete 640x480                          Interval: Discrete 0.033s (30.000 fps)                  Size: Discrete 320x240                          Interval: Discrete 0.033s (30.000 fps)

Разбор информации:

  • Формат MJPG (Motion-JPEG, сжатый) — обеспечивает более высокую частоту кадров (30 fps) для всех разрешений;

  • Формат YUYV — передаёт данные в «сыром» виде, без использования алгоритмов сжатия (видео занимает больше места) и имеет ограниченную частоту кадров для высоких разрешений;

  • Discrete 1280×720, Interval: Discrete 0.033s (30.000 fps) — пример разрешения экрана и интервала, который соответствует количеству кадров в секунду (указан в скобках) для данного разрешения.

Теперь, зная характеристики камеры, мне проще подобрать оптимальный режим работы для видеострима.

Проверяю получение изображения с веб-камеры:

ffplay -f v4l2 -video_size 1280x720 -i /dev/video0
Проверка работы камеры

Проверка работы камеры

Разбор команды:

  • -f v4l2 — указывает формат захвата видео (в данном случае Video4Linux2);

  • -video_size 1280x720 — задаёт разрешение экрана (например, 1280×720);

  • -i /dev/video0 — указывает устройство, с которого будет захватываться видео.

Из информации в терминале видно, что для формата YUYV разрешение экрана соответствует 10 fps. Это подтверждается данными команды v4l2-ctl --list-formats-ext для указанного разрешения. Также появилось изображение с камеры, на котором видна моя гусеничная DIY-платформа. Изображение обновляется в реальном времени. Камера работает корректно, поэтому можно приступать к проверке видеострима.

Шаг 5: Запуск видеострима в MJPG-Streamer

Запускаю видеострим:

mjpg_streamer -i "input_uvc.so -d /dev/video0 -r 640x480 -f 15 -q 80" -o "output_http.so -p 8093 -w /usr/local/share/mjpg-streamer/www" &

Разбор команды:

  • nput_uvc.so — модуль для обработки UVC-видео;

  • -d /dev/video0 — указывает устройство камеры;

  • -r 640x480 и -f 15 — задают разрешение и частоту кадров;

  • -q 80 — качество JPEG;

  • output_http.so — модуль для трансляции через HTTP;

  • -p 8093 — порт для HTTP-сервера;

  • & — запускает процесс в фоне.

Видеострим запущен, осталось проверить его корректную работу.

Шаг 6: Проверка видеопотока

Открываю в браузере URL: http://localhost:8093/?action=stream:

Проверка видеопотока

Проверка видеопотока

На экране отображается изображение с камеры с частотой 15 fps, при которой видеопоток работает приемлемо. Для слабых устройств (например, Orange Pi Zero H2+) 15 fps достаточно. Более точные настройки для видео подберу на конкретном устройстве в ходе проверки. На мощных устройствах я могу установить до 30 fps для своей веб-камеры.

Стек технологий

Я подобрал следующий стек технологий для веб-приложения:

  • Python 3.12.0 — язык программирования, выбранный для разработки;

  • MJPG-Streamer — инструмент для стриминга видео через HTTP;

  • FastAPI — высокопроизводительный веб-фреймворк с поддержкой асинхронности и Websocket;

  • Uvicorn — лёгкий и быстрый сервер ASGI для Python, предназначенный для запуска современных веб-приложений и поддерживающий асинхронные операции;

  • Poetry — инструмент для управления зависимостями и сборки проектов на Python;

  • Pyenv — утилита для управления версиями Python, которая позволяет устанавливать, переключать и администрировать несколько версий Python на одной системе.

Перед началом написания кода я установил всё перечисленное:

  • Ссылка на установку Python;

  • Ссылка на документацию с процессом установки Poetry;

  • Ссылка на документацию с процессом установки Pyenv.

Виртуальное окружение и установка библиотек

Я создал виртуальное окружение следующими командами:

pyenv install 3.12.0 pyenv virtualenv 3.12.0 web_robot_control pyenv versions source ~/.bash_profile pyenv shell web_robot_control

Разбор команд:

  • pyenv install 3.12.0 — установка версии Python 3.12.0 с использованием Pyenv;

  • pyenv virtualenv 3.12.0 web_robot_control — создание виртуального окружения с указанной версией Python и названием;

  • pyenv versions — отображение списка доступных виртуальных окружений и установленных версий Python;

  • source ~/.bash_profile — активация новых настроек для использования Pyenv;

  • pyenv shell web_robot_control — активация виртуального окружения в терминале.

Теперь у проекта есть виртуальная среда, в которую будут установлены все библиотеки и зависимости.

Устанавливаю Poetry в активированное виртуальное окружение web_robot_control:

pip install poetry

Устанавливаю фреймворк FastApi:

poetry add "fastapi[all]"

При установке fastapi[all] автоматически включаются uvicorn и другие важные библиотеки. Теперь, когда установлены все библиотеки и зависимости для них, я могу приступать к работе над самим веб-приложением.

Структура проекта

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

Создаю новый проект:

poetry new web-robot-control

Команда создала проект со следующей структурой:

web-robot-control ├── pyproject.toml ├── README.md ├── src │   └── web_robot_control │       └── __init__.py └── tests     └── __init__.py

Разбор структуры проекта:

  • pyproject.toml — файл, который описывает конфигурацию проекта. В нем можно указать зависимости, инструменты для сборки и настройки (используется в poetry, pip, setuptools);

  • README.md — текстовый файл с описанием проекта. В нем обычно содержатся инструкции по установке, использованию и дополнительной информации;

  • src/ — папка с исходным кодом приложения:

    • web_robot_control/ — основная директория кода проекта;

    • init.py — файл, который делает эту директорию пакетом Python. Он может быть пустым или содержать код для инициализации пакета.

  • tests/ — папка с тестами для проверки работоспособности кода.

Содержимое pyproject.toml:

[project] name = "web-robot-control" version = "0.1.0" description = "Web-robot-control - open source веб-приложение для управлением роботом и трансляции видео с веб-камеры." authors = [     {name = "Arduinum628",email = "message.chaos628@gmail.com"} ] license = {text = "MIT"} readme = "README.md" requires-python = ">=3.12" dependencies = [     "fastapi[all] (>=0.115.12,<0.116.0)" ]  [tool.poetry] packages = [{include = "web_robot_control", from = "src"}]  [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api"

Разбор содержимого:

  • name = «web-robot-control» — название проекта;

  • version = «0.1.0» — текущая версия проекта;

  • description = «Web-robot-control — open source веб-приложение для управлением роботом и трансляции видео с веб-камеры.» — краткое описание проекта;

  • authors = [{name = «Arduinum628»,email = «message.chaos628@gmail.com»}] — имя автора и его электронный адрес;

  • license = {text = «MIT»} — тип лицензии, в данном случае MIT (открытое ПО);

  • readme = «README.md» — путь к README-файлу проекта;

  • requires-python = «>=3.12» — требуемая версия Python (не ниже 3.12);

  • dependencies = [«fastapi[all] (>=0.115.12,<0.116.0)» — список зависимостей, включая FastAPI;

  • packages = [{include = «name_project_packet», from = «src»}] — указание пакетов проекта и их пути;

  • requires = [«poetry-core>=2.0.0,<3.0.0»] — зависимости, необходимые для сборки проекта;

  • build-backend = «poetry.core.masonry.api» — механизм сборки для создания пакета.

Теперь у проекта есть настроенный конфигурационный файл. Подробнее о файле pyproject.toml можно узнать из документации Poetry.

Написание кода и документации веб-приложения

Теперь, когда настроено виртуальное окружение, установлены все зависимости и создана структура проекта, я перехожу к написанию кода и документации.

Документация

Перед началом разработки важно установить правила оформления веток и коммитов. Поскольку это open-source проект, другие разработчики, которые захотят дополнить приложение, должны иметь чёткие рекомендации по работе с ветками и коммитами, а также описание проекта. Это помогает организовать процесс разработки, избежать хаоса и понимать разработчикам и пользователям для чего приложение написано. Документация хранится в файле README.md, который уже был ранее создан командой poetry new web-robot-control. Осталось его наполнить содержимым.

Содержимое README.md:

# Web-robot-control  **Web-robot-control** - open source веб-приложение для управлением роботом и трансляции видео с веб-камеры.  ## Запуск приложения  **Запуск для локальной разработки (бекенд)**: `poetry run uvicorn web_robot_control.main:app --host server_ip --port port_number`   **Todo:** создать Python-функцию для запуска веб-приложения и добавить её в скрипты Poetry  <details>     <summary>         <strong>             Как оформлять ветки и коммиты         </strong>     </summary>      Пример ветки `user_name/name_task`      - **user_name** (имя пользователя);     - **name_task** (название задачи).      Пример коммита `refactor: renaming a variable`      - **feat:** (новая функционал кода, БЕЗ учёта функционала для сборок);     - **devops:** (функционал для сборки, - добавление, удаление и исправление);     - **fix:** (исправление ошибок функционального кода);     - **docs:** (изменения в документации);     - **style:** (форматирование, отсутствующие точки с запятой и т.п., без изменения производственного кода);     - **refactor:** (рефакторинг производственного кода, например, переименование переменной);     - **test:** (добавление недостающих тестов, рефакторинг тестов; без изменения производственного кода);     - **chore:** (обновление рутинных задач и т. д.; без изменения производственного кода).      Оформление основано на https://www.conventionalcommits.org/en/v1.0.0/ </details>

Правила оформления веток и коммитов основаны на спецификации Conventional Commits. Это помогает структурировать изменения, чтобы они были понятными, единообразными и легко воспринимаемыми.

Кроме того, я добавил описание проекта, чтобы ясно объяснить, что это за приложение и для каких целей оно создано. Также включён пример команды для локального запуска приложения:

poetry run uvicorn web_robot_control.main:app --host server_ip --port port_number

.env.example

Я создал файл .env.example, который служит для описания переменных окружения.

STREAM_URL="url-адрес видеопотока"

В реальном .env файле я добавлю url-адрес видеопотока:

STREAM_URL=http://localhost:8093/?action=stream

settings.py

Я создал файл settings.py для хранения настроек проекта, прочитанных из файла .env, и валидации типов переменных окружения. Для этих целей используется библиотека pydantic_settings. Она предоставляет готовые классы для валидации, чтения .env файла и других задач.

Содержимое settings.py:

from pydantic_settings import BaseSettings, SettingsConfigDict   class Settings(BaseSettings):     """Класс для данных конфига"""          model_config = SettingsConfigDict(         env_file = '.env',          env_file_encoding='utf-8',         extra='ignore'     )      stream_url: str   settings = Settings() 

Пояснения к коду settings.py:

  • class Settings(BaseSettings) — класс, который сохраняет переменные окружения, загруженные из .env файла, для настройки проекта;

  • model_config = SettingsConfigDict(...) — переменная для задания конфигурации модели:

  • env_file='.env' — указывает, что настройки загружаются из файла .env;

  • env_file_encoding='utf-8' — задаёт кодировку файла .env;

  • extra='ignore' — игнорирует переменные, которые не описаны в классе;

  • stream_url: str — переменная для хранения URL-видеострима;

  • settings = Settings() — создание экземпляра класса с настройками.

views.py

Теперь я приступаю к созданию самого веб-приложения, начиная с файла views.py. Этот файл обрабатывает запросы и отправляет ответы клиенту.

FastAPI поддерживает WebSocket, который я использую для получения команд с фронтенда в реальном времени. Это решение не мешает видеостримингу.  Более подробно ознакомиться с WebSocket можно узнать на странице документации.

Содержимое views.py:

from fastapi import APIRouter, WebSocket from fastapi.requests import Request from fastapi.responses import HTMLResponse, Response from fastapi.templating import Jinja2Templates  from starlette.websockets import WebSocketDisconnect import httpx  from web_robot_control.settings import settings   # Создаем объект роутера router = APIRouter()  # Создаём объект для рендеринга html-шаблонов templates = Jinja2Templates(directory='static')  @router.get('/', response_class=HTMLResponse) async def index(request: Request) -> Response:     """     Асинхронная функция для получения главной страницы приложения.     """      return templates.TemplateResponse(         request=request,         name='index.html',         context={'title': 'Web-robot-control - Главная', 'name_robot': 'Bot1'}     )   @router.get('/config') async def get_config() -> dict:     """Aсинхронная функция для получения stream_url."""      return {'stream_url': settings.stream_url}   @router.websocket('/ws') async def websocket_endpoint(websocket: WebSocket) -> None:     # Установка содединения по веб-сокету     await websocket.accept()      try:         async with httpx.AsyncClient() as client:             while True:                 # Получение команды от клиента (с веб-сокета)                 command = await websocket.receive_text()                 print(f'Получена команда: {command}')                  # Todo: здесь будет логика валидации команд                  # Todo: здесь будет логика обработки команды      except WebSocketDisconnect:         print('WebSocket отключен')  # Todo: для вывода ошибок будет настроен logger     # Todo: вместо Exception будут добавлена ловля других ошибок     # (после того как функция будет полностью дописана)     except Exception as err:         err_text = f'Ошибка: {str(err)}'         await websocket.send_text(err_text)         print(err_text)

Импорты и инициализация объектов:

  • from fastapi import APIRouter, WebSocket — модули для работы с маршрутами HTTP и WebSocket;

  • from fastapi.responses import HTMLResponse — класс для возврата HTML-контента в HTTP-ответе;

  • from starlette.websockets import WebSocketDisconnect — исключение для обработки отключения WebSocket соединения;

  • import httpx — библиотека для выполнения HTTP-запросов асинхронно;

  • from web_robot_control.settings import settings — настройки приложения (например, переменные из .env);

  • router = APIRouter() — объект роутера;

  • templates = Jinja2Templates(directory='static') — объект для рендеринга HTML-шаблонов.

index() — функция для отображения главной страницы:

  • @router.get('/', response_class=HTMLResponse) — связывает функцию с URL ‘/’ для GET-запроса и задаёт тип ответа HTMLResponse;

  • return templates.TemplateResponse(...) — возвращает HTML-шаблон с контекстом:

    • request=request — передаёт запрос в шаблонизатор;

    • name=’index.html’ — указывает имя HTML-шаблона;

    • context={‘title’: …, ‘name_robot’: …} — передаёт данные для использования в шаблоне.

get_config() — функция для получения настроек:

  • @router.get('/config') — связывает функцию с URL /config для GET-запроса;

  • return {'stream_url': settings.stream_url} — возвращает словарь с URL видеострима в виде JSON-ответа.

websocket_endpoint() — функция для обработки команд через WebSocket:

  • @router.websocket('/ws') — связывает функцию с маршрутом /ws для обработки WebSocket-запросов;

  • await websocket.accept() — устанавливает соединение между сервером и клиентом;

  • async with httpx.AsyncClient() as client: — создаёт асинхронного HTTP-клиента (пока заготовка);

  • command = await websocket.receive_text() — получает команду от клиента через WebSocket;

  • print(f'Получена команда: {command}') — вывод команды в консоль (для тестирования);

  • except WebSocketDisconnect: — ловит разрыв соединения и выводит сообщение;

  • except Exception as err: — ловит общие ошибки и отправляет их клиенту через WebSocket.

Теперь у бекенда есть возможность получать команды от клиента (фронтенда), передавать данные конфига на фронтенд через WebSocket и получать html главной страницы.

main.py

Файл main.py — это главный файл приложения, который отвечает за его создание и запуск. В этом файле подключаются маршруты, статические файлы и другие компоненты приложения.

Содержимое main.py:

from fastapi import FastAPI from starlette.staticfiles import StaticFiles  from web_robot_control.views import router   # создаем экземпляр FastAPI app = FastAPI()  # подключаем статические файлы app.mount('/static', StaticFiles(directory='static'), name='static')  # подключаем роутер app.include_router(router)

Импорты:

  • from fastapi import FastAPI — импорт класса FastAPI, который используется для создания основного приложения;

  • from starlette.staticfiles import StaticFiles — импорт класса StaticFiles, который отвечает за обслуживание статических файлов (например, CSS, JavaScript, изображения);

  • from web_robot_control.views import router — импорт маршрутизатора router, в котором определены обработчики запросов (созданные ранее в views.py).

Приложение в FastApi:

  • app = FastAPI() — создание экземпляра основного приложения;

  • app.mount('/static', StaticFiles(directory='static'), name='static'):

    • ‘/static’ — URL, по которому будут доступны статические файлы;

    • StaticFiles(directory=’static’) — указывает путь к папке с статическими файлами;

    • name=’static’ — имя маршрута, позволяющее ссылаться на него в других частях приложения;

  • app.include_router(router) — подключение маршрутизатора, который добавляет маршруты из views.py в приложение.

Бекендная часть готова, теперь осталось написать фронтенд для веб-приложения.

index.html

Я создаю простой фронтенд, который будет выполнять роль клиента. Сначала я разработал файл index.html, предназначенный для отображения кнопок управления и видеострима. Я буду использовать Bootstrap для ускорения и упрощения разработки фронтенда. Bootstrap  — это популярный фреймворк для создания адаптивных веб-интерфейсов, который включает готовые стили, компоненты и JavaScript-инструменты для ускорения разработки.

Содержимое index.html:

<!DOCTYPE html> <html lang="ru"> <head>     <meta charset="UTF-8">     <meta name="viewport" content="width=device-width, initial-scale=1.0">     <title>{{ title }}</title>     <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">     <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">     <link rel="stylesheet" href="/static/style.css">     <script src="/static/command.js"></script> </head> <body>     <div class="container mt-5">         <h1 class="text-center mb-4">Управление роботом {{ name_robot }}</h1>          <!-- Видеопоток -->         <div class="row justify-content-center">             <div class="col-md-7 px-0">                 <div class="card">                     <div class="card-body text-center">                         <img src="" class="img-fluid" id="video-stream" alt="Видеопоток">                     </div>                      <div class="line"></div>                      <!-- Кнопки -->                     <div class="col-md-12 d-flex align-items-center px-0">                         <div class="card-command card d-flex flex-column justify-content-center align-items-center">                             <!-- Вверх -->                             <button class="btn btn-warning m-1" id="forward-button">                                 <i class="bi bi-arrow-up"></i>                             </button>                             <div class="d-flex">                                 <!-- Влево -->                                 <button class="btn btn-warning m-1" id="left-button">                                     <i class="bi bi-arrow-left"></i>                                 </button>                                 <!-- Вниз -->                                 <button class="btn btn-warning m-1" id="backward-button">                                     <i class="bi bi-arrow-down"></i>                                 </button>                                 <!-- Вправо -->                                 <button class="btn btn-warning m-1" id="right-button">                                     <i class="bi bi-arrow-right"></i>                                 </button>                             </div>                         </div>                     </div>                 </div>             </div>         </div>     </div>      <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> </body> </html>

Главные особенности index.html:

  • <title>{{ title }}</title> — шаблонное выражение для динамического указания заголовка страницы (его передали в context на бэкенде);

  • href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"> — подключение CSS-файла из CDN Bootstrap для применения стилей для приложения;

  • <link rel="stylesheet" href="/static/style.css"> — подключение локального CSS-файла (style.css), где находятся пользовательские стили;

  • <script src="/static/command.js"></script> — подключение локального JavaScript-файла (command.js), который управляет командами (например, отправка команд WebSocket’у).

  • <h1 class="text-center mb-4">Управление роботом {{ name_robot }}</h1> — Шаблонное выражение для вывода имени робота и красивое оформление заголовка (выравнивание по центру).

  • <img src="" class="img-fluid" id="video-stream" alt="Видеопоток"> — Элемент для отображения видеопотока от робота, где src будет динамически обновляться через JavaScript;

  • <!-- Кнопки --> — cекция с кнопками управления роботом (вперёд, назад, влево, вправо).

style.css

Я добавил пользовательские стили, чтобы слегка кастомизировать стандартные стили Bootstrap.

Содержимое style.css:

/* Задает общий фон страницы / body {     background-color: #f8f9fa; }  / Создает тень для карточки, добавляя глубину и визуальную привлекательность / .card {     box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } / Устанавливает черный цвет фона для элементов с классами card-command и card-body / .card-command, .card-body {     background-color: black; } / Устанавливает стили для блока card-command / .card-command {     width: 100%; / Задает ширину в 100% /     border-top-left-radius: 0; / Убирает закругление верхнего левого угла /     border-top-right-radius: 0; / Убирает закругление верхнего правого угла / }  / Определяет стили для горизонтальной линии между элементами / .line {     background-color: #ffc107; / Задает желтый цвет линии /     height: 6px; / Высота линии /     width: 100%; / Линия занимает всю ширину блока / }  / Определяет максимальную ширину изображения и делает его адаптивным / img {     max-width: 100%; / Ограничивает ширину изображения, чтобы оно не выходило за пределы контейнера /     height: auto; / Автоматически изменяет высоту изображения пропорционально ширине */ }  

command.js

Теперь, когда файлы index.html и style.css для главной страницы готовы, я добавил логику для работы клиента. Для этого я создал файл command.js, который отвечает за обновление видеострима, вывод сообщений в консоль для отладки, а также управление WebSocket и передачу команд бекенду.

Содержимое command.js:

// Ждём, пока загрузится весь контент DOM document.addEventListener("DOMContentLoaded", () => {     // Создаём WebSocket-соединение с сервером     const ws = new WebSocket(ws://${window.location.host}/ws);      // Делаем запрос к серверу на эндпоинт "/config"     fetch('/config')         .then(response => response.json()) // Преобразуем ответ в JSON         .then(data => {             // Получаем значение stream_url из ответа             const streamUrl = data.stream_url;             const videoElement = document.getElementById("video-stream");             // Устанавливаем URL видеопотока в элемент <video>             videoElement.src = streamUrl;         });      // Обработчик события открытия WebSocket-соединения     ws.onopen = function() {         console.log("WebSocket подключен");     };      // Обработчик события получения сообщения по WebSocket     ws.onmessage = function(event) {         console.log("Получено:", event.data); // Выводим полученные данные     };      // Обработчик события закрытия WebSocket-соединения     ws.onclose = function() {         console.log("WebSocket закрыт");     };      // Обработчик ошибок WebSocket     ws. {         console.log("WebSocket ошибка:", error); // Логируем ошибку     };      let commandInterval;      // Функция для начала отправки команды с заданным интервалом     function startSendingCommand(command) {         // Отправляем команду сразу         sendCommand(command);          // Запускаем интервал для повторной отправки команды         commandInterval = setInterval(() => {             sendCommand(command); // Повторяем отправку команды         }, 10); // Интервал отправки — каждые 10 мс     }      // Функция для остановки отправки команд     function stopSendingCommand() {         // Останавливаем интервал         clearInterval(commandInterval);     }      // Функция для отправки команды через WebSocket     function sendCommand(command) {         if (ws.readyState === WebSocket.OPEN) {             ws.send(command); // Отправляем команду через WebSocket             console.log("Команда:", command); // Логируем отправленную команду         } else {             console.log("WebSocket не подключён"); // Если WebSocket не открыт         }     }      // Назначение обработчиков событий для кнопки "Вперёд"     const forwardButton = document.getElementById("forward-button");     forwardButton.addEventListener("mousedown", () => startSendingCommand("forward")); // Начало отправки команды     forwardButton.addEventListener("mouseup", stopSendingCommand); // Остановка отправки при отпускании кнопки     forwardButton.addEventListener("mouseleave", stopSendingCommand); // Остановка отправки, если курсор уходит с кнопки      // Назначение обработчиков событий для кнопки "Влево"     const leftButton = document.getElementById("left-button");     leftButton.addEventListener("mousedown", () => startSendingCommand("left"));     leftButton.addEventListener("mouseup", stopSendingCommand);     leftButton.addEventListener("mouseleave", stopSendingCommand);      // Назначение обработчиков событий для кнопки "Вправо"     const rightButton = document.getElementById("right-button");     rightButton.addEventListener("mousedown", () => startSendingCommand("right"));     rightButton.addEventListener("mouseup", stopSendingCommand);     rightButton.addEventListener("mouseleave", stopSendingCommand);      // Назначение обработчиков событий для кнопки "Назад"     const backwardButton = document.getElementById("backward-button");     backwardButton.addEventListener("mousedown", () => startSendingCommand("backward"));     backwardButton.addEventListener("mouseup", stopSendingCommand);     backwardButton.addEventListener("mouseleave", stopSendingCommand); });

Особенность моего управления в том, что команда отправляется каждые 10 мс, пока пользователь удерживает кнопку. Задержку уточним экспериментально на реальном роботе. Отправка команд прекращается, как только кнопка отпускается.

Ссылка на получившийся в итоге open-source проект web-robot-control.

Итоговая cтруктура проекта

Структура теперь имеет следующий вид:

web-robot-control ├── src │   └── web_robot_control │       ├── __pycache__ │       ├── __init__.py │       ├── main.py │       ├── settings.py │       ├── views.py ├── static │   └── command.js │   └── index.html │   └── style.css ├── tests │   └── __init__.py ├── .env ├── .env.example ├── .gitignore ├── LICENSE ├── poetry.lock ├── pyproject.toml └── README.md

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

Web-приложение в действии

Запускаю веб-приложение:

poetry run uvicorn web_robot_control.main:app --host localhost --port 8095

Разбор команды:

  • poetry run — запускает команду внутри виртуальной среды, созданной Poetry;

  • uvicorn web_robot_control.main:app — запускает ASGI-сервер Uvicorn, указывая:

    • web_robot_control.main — модуль Python, где находится приложение;

    • app — экземпляр приложения FastAPI, который будет запущен.

  • --host localhost — сервер будет слушать локальный хост, то есть доступен только на текущем устройстве;

  • --port 8095 — приложение будет запущено на порту 8095.

Работа веб-приложения в браузере по URL http://localhost:8095:

На видео видно, что видеострим успешно работает в реальном времени, а команды «left», «right», «forward», «backward» отображаются в консоли браузера. Я управляю роботом обычным ИК-пультом.

Работа бэкенда:

INFO:     Started server process [43146] INFO:     Waiting for application startup. INFO:     Application startup complete. INFO:     Uvicorn running on http://localhost:8095 (Press CTRL+C to quit) INFO:     ::1:37202 - "GET / HTTP/1.1" 200 OK INFO:     ::1:37202 - "GET /static/command.js HTTP/1.1" 304 Not Modified INFO:     ('::1', 47800) - "WebSocket /ws" [accepted] INFO:     ::1:37202 - "GET /config HTTP/1.1" 200 OK INFO:     connection open Получена команда: forward Получена команда: right Получена команда: backward Получена команда: left ^CINFO:     Shutting down WebSocket отключен INFO:     connection closed INFO:     Waiting for application shutdown. INFO:     Application shutdown complete. INFO:     Finished server process [17634]

Бэкенд успешно принимает команды и выводит результат в терминал:

  • Получена команда: forward;

  • Получена команда: right;

  • Получена команда: backward;

  • Получена команда: left.

Заключение и планы на будущее

Были установлены все необходимые зависимости и библиотеки для видеострима. Видеострим с веб-камеры успешно протестирован. Настроено виртуальное окружение, а также установлены зависимости для проекта. Написан код, обеспечивающий отправку команд на сервер и отображение видеострима. В результате получилась достойная основа для open-source проекта, которая пока ещё не достигла статуса MVP1 и требует доработки.

Хочу добавить в проект logger — инструмент для ведения логов, который будет помогать отслеживать события во время работы программы. Также планирую реализовать валидацию команд и механизм их отправки роботу. Кроме того, нужно добавить утилиту для робота, которая будет принимать команды с сервера. Без неё робот не получит команды управления. Если у вас есть идеи по развитию проекта, поделитесь ими в комментариях — буду рад услышать ваши предложения!

Автор статьи @Arduinum


НЛО прилетело и оставило здесь промокод для читателей нашего блога:
— 15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.


ссылка на оригинал статьи https://habr.com/ru/articles/907394/