Привет! Я преподаю робототехнику и стараюсь делать учебные проекты интересными, вдохновляющими на изучение нового, с применением различных технологий и в то же время повторяемыми! В данной работа будет рассмотрено много аспектов робототехники, которые интересны моим ученикам, но могут быть полезны и остальным!

Сборка платформы

Механика самая простая
Механика самая простая

Механическая часть платформы самая простая - 4 колеса, и перфорированная платформа для удобства монтажа элементов управления для отладки.

Схема питания:

Используются Li-On аккумуляторы 18650. А для возможности их заряда не снимая с робота применяется плата балансировки заряда, а также модуль заряда, который подключается к Type-C и с 5В повышает напряжение до 8.4В, необходимых для заряда двух последовательно соединенных АКБ 18650.
Полный список компонентов для этого решения есть в посте в моем телеграм-канале.

Для управления логикой работы используетcя Arduino Nano в комплекте с радиомодулем NRF24L01.
Код для приемника:

#include <SPI.h>
#include <nRF24L01.h>
#include <RF24.h>
const uint64_t pipe = 0xF0F0F0F0F0LL; 
RF24 radio(9, 10); // CE, CSN
byte data[1];
uint32_t radioTimer=0;

int speed = 128;
void setup() {

  Serial.begin(9600);
  Serial.println(!radio.begin());
  
  delay(2);
  radio.setChannel(100); // канал (0-127)
  radio.setDataRate(RF24_1MBPS);
  radio.setPALevel(RF24_PA_HIGH);
  radio.openReadingPipe(1, pipe);
  radio.startListening(); 
  pinMode(2,OUTPUT);
  pinMode(3,OUTPUT);
  pinMode(4,OUTPUT);
  pinMode(5,OUTPUT);
}

void forward() {
  digitalWrite(2,0);
  analogWrite(3,speed);
  digitalWrite(4,0);
  analogWrite(5,speed);
}

void backward() {
  digitalWrite(2,1);
  analogWrite(3,255-speed);
  digitalWrite(4,1);
  analogWrite(5,255-speed);
}

void left() {
  digitalWrite(2,0);
  analogWrite(3,0);
  digitalWrite(4,0);
  analogWrite(5,speed);
}

void right() {
  digitalWrite(2,0);
  analogWrite(3,speed);
  digitalWrite(4,0);
  analogWrite(5,0);
}

void STOP(){
  digitalWrite(2,0);
  analogWrite(3,0);
  digitalWrite(4,0);
  analogWrite(5,0);

}

void loop()
{
  if (radio.available()) { 
    radioTimer = millis();
    radio.read(data,1); 
    byte p1 = (data[0] >> 0) & 1;
    byte p2 = (data[0] >> 1) & 1;
    byte p3 = (data[0] >> 2) & 1;
    byte p4 = (data[0] >> 3) & 1;

    if (p1 && p2 && p3 && p4) forward();
    else if (p1 && !p2 && !p3 && p4) backward();
    else if (p1 && !p2 && !p3 && !p4) left();
    else if (!p1 && !p2 && !p3 && p4) right();
    else if (!p1 && !p2 && !p3 && !p4) STOP();

  }
  if (millis()-radioTimer>500) STOP();
}

В целом код достаточно прост, однако некоторые моменты прокомментирую:

  1. Подключение библиотек и определение констант и переменных:

    • Подключаются библиотеки для работы с SPI-интерфейсом и nRF24L01.

    • Устанавливаются номера пинов для управления модулем RF24.

    • Определяется адрес трубы связи для приёма данных.

    • Объявляются переменные для хранения данных и таймера радио.

  2. Настройки в функции setup():

    • Инициализация Serial порта для отладки.

    • Настройка параметров радиомодуля, таких как канал связи, скорость передачи данных и уровень мощности передатчика.

    • Конфигурация пинов для управления двигателями.

  3. Функции управления движением:

    • forward(), backward(), left(), right(), STOP(): функции для управления двигателями в различных направлениях или остановки устройства.

  4. Основной цикл в loop():

    • Проверка наличия данных от радиопередатчика.

    • Чтение и интерпретация полученных данных для управления движениями устройства. Данные передаются в одном байте, поэтому используются операции битового сдвига, запись в 4 отдельные переменные для простоты понимания и дальнейшей работы с управлением

    • Автоматическая остановка устройства, если в течение 500 мс не было получено новых команд.

Передатчик

Arduino UNO + NRF24L01
Arduino UNO + NRF24L01

Любая Arduino + радиомодуль NRF24L01.
Задача этого устройства: получать данные от скрипта, работающего с камерой и передавать их на мобильную платформу.
Программа для этой части:

#include <SPI.h>
#include <nRF24L01.h>
#include <RF24.h>
const uint64_t pipe = 0xF0F0F0F0F0LL;
long timer;
RF24 radio(9, 10); // CE, CSN
byte send[1] = {0};

void setup() {
  Serial.begin(9600);
  Serial.println(radio.begin());
  delay(2);
  radio.setChannel(100);
  radio.setDataRate(RF24_1MBPS);
  radio.setPALevel(RF24_PA_HIGH);
  radio.setAutoAck(1);
  radio.stopListening();
  radio.openWritingPipe(pipe);
}

void loop() {
  if (Serial.available() > 0) {
    send[0] = Serial.read();
    radio.write(send, 1);
  }
}
  1. Подключение библиотек и определение констант и переменных:

    • Подключаются библиотеки для работы с SPI-интерфейсом и nRF24L01.

    • Устанавливаются номера пинов для управления модулем RF24.

    • Определяется адрес и номер канала связи для приёма данных. (ВАЖНО, чтобы они совпадали на передатчике и приемнике)

    • Объявляются переменные для хранения данных и таймера радиопередатчика.

  2. Настройки в функции setup():

    • Инициализация Serial порта

    • Настройка параметров радиомодуля, таких как канал связи, скорость передачи данных и уровень мощности передатчика.

  3. Основной цикл в loop():

    • Проверка наличия данных от Python-скрипта через Serial.

    • Передача полученного байта через радиоканал на платформу

Обработка жестов руки

Для обработки используются библиотеки mediapipe (для распознавания точек) и OpenCV для визуализации изображения.

Устанавливаются они стандартной командой pip (или pip3 для linux):

pip install mediapipe
pip install opencv-python

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

import cv2
import mediapipe as mp
import numpy as np

mp_hands = mp.solutions.hands
mp_drawing = mp.solutions.drawing_utils
hands = mp_hands.Hands(static_image_mode=False, max_num_hands=1)

cap = cv2.VideoCapture(0)

while True:
    ret, frame = cap.read()
    if not ret:
        continue

    frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    results = hands.process(frame)
    frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)

    if results.multi_hand_landmarks:
        for hand_landmarks in results.multi_hand_landmarks:
            mp_drawing.draw_landmarks(frame, hand_landmarks, mp_hands.HAND_CONNECTIONS)

    cv2.imshow('Fingers', frame)
    
    if cv2.waitKey(10) == 27:
        break

cap.release()
cv2.destroyAllWindows()

Этот код открывает камеры, читает поток изображений и передает его в обработку библиотеке MediaPipe. Важными параметрами являются:

  • static_image_mode=False - гарантирует, что при потоковом видео будет постоянно определяться одна и та же рука

  • max_num_hands=1 - исключает обработку других найденных в кадре рук.

hands = mp_hands.Hands(static_image_mode=False, max_num_hands=1)

В результате получаем картинку:

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

Далее, определяем расстояние между крайними точками каждого пальца, и если они меньше заданного порога, считает что палец загнут.

import cv2
import mediapipe as mp
import numpy as np

mp_hands = mp.solutions.hands
mp_drawing = mp.solutions.drawing_utils
hands = mp_hands.Hands(static_image_mode=False,
                       max_num_hands=1)

cap = cv2.VideoCapture(0)

tip_ids = [4, 8, 12, 16, 20]
base_ids = [0, 5, 9, 13, 17]
extension_threshold = 0.17

def get_vector(p1, p2):
    return np.array([p2.x - p1.x, p2.y - p1.y, p2.z - p1.z])

def is_finger_extended(base, tip, is_thumb=False):
    base_to_tip = get_vector(base, tip)
    base_to_tip_norm = np.linalg.norm(base_to_tip)
    return base_to_tip_norm > extension_threshold
    
while True:
    ret, frame = cap.read()
    if not ret:
        continue

    frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    results = hands.process(frame)
    frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)
    if results.multi_hand_landmarks:
        for hand_landmarks in results.multi_hand_landmarks:
            mp_drawing.draw_landmarks(frame,
                                      hand_landmarks,
                                      mp_hands.HAND_CONNECTIONS)
        if hand_landmarks:
            landmarks = hand_landmarks.landmark
            for id, landmark in enumerate(hand_landmarks.landmark):
                h, w, c = frame.shape
                cx, cy = int(landmark.x * w), int(landmark.y * h)
                cv2.putText(frame,
                            str(id),
                            (cx, cy),
                            cv2.FONT_HERSHEY_SIMPLEX,
                            0.5,
                            (0, 255, 255),
                            1)
                
            for finger_index, tip_id in enumerate(tip_ids):
                base_id = base_ids[finger_index]  
                if is_finger_extended(landmarks[base_id], landmarks[tip_id]):
                    cx, cy = int(landmarks[tip_id].x * frame.shape[1]), int(landmarks[tip_id].y * frame.shape[0])
                    cv2.circle(frame, (cx, cy), 10, (0, 255, 0), cv2.FILLED)


    cv2.imshow('Fingers', frame)

    if cv2.waitKey(10) == 27:
        break

cap.release()
cv2.destroyAllWindows()
    

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

Итак, у нас есть 4 пальца для управления и сжатая рука для остановки робота:

Вперед-назад-влево-вправо-стоп
Вперед-назад-влево-вправо-стоп

Остается преобразовать состояние пальцев в биты, сложить их в один байт и передать в Arduino.

Полный код проекта на Python

import cv2
import mediapipe as mp
import numpy as np
import serial
import serial.tools.list_ports
import time


ser = serial.Serial("COM11", 9600, timeout=1)
if ser is None:
    exit()  # Завершаем программу, если подключение не удалось
time.sleep(2) #Ждем открытия порта

# Переменная для хранения состояний светодиодов
handStates = 0


mp_hands = mp.solutions.hands
mp_drawing = mp.solutions.drawing_utils
hands = mp_hands.Hands(static_image_mode=False, max_num_hands=1, min_detection_confidence=0.7)
tip_ids = [4, 8, 12, 16, 20]  # Индексы кончиков пальцев
base_ids = [0, 5, 9, 13, 17]  # Индексы баз пальцев

cap = cv2.VideoCapture(0)

extension_threshold = 0.17  # Общий порог для большинства пальцев
thumb_extension_threshold = 0.1  # Специальный порог для большого пальца

def get_vector(p1, p2):
    """ Возвращает вектор от точки p1 к точке p2 """
    return np.array([p2.x - p1.x, p2.y - p1.y, p2.z - p1.z])

def is_finger_extended(base, tip, is_thumb=False):
    """ Определяет, разогнут ли палец, исходя из его вектора """
    base_to_tip = get_vector(base, tip)
    # Нормализация вектора
    base_to_tip_norm = np.linalg.norm(base_to_tip)
    # Проверка на разгибание, учитывая, является ли это большим пальцем
    if is_thumb:
        return base_to_tip_norm > thumb_extension_threshold
    else:
        return base_to_tip_norm > extension_threshold

def count_fingers(hand_landmarks):
    finger_count = 0
    extended_fingers = []
    finger_states = [0, 0, 0, 0, 0]  # Состояние пальцев: 0 - сжат, 1 - разогнут
    
    if hand_landmarks:
        landmarks = hand_landmarks.landmark
        # Проверка большого пальца с учетом его специфики
##        if is_finger_extended(landmarks[base_ids[0]], landmarks[tip_ids[0]], is_thumb=True):
##            finger_count += 1
##            extended_fingers.append(tip_ids[0])
##            finger_states[0] = 1
        
        # Проверка остальных пальцев
        for i in range(1, 5):
            if is_finger_extended(landmarks[base_ids[i]], landmarks[tip_ids[i]]):
                finger_count += 1
                extended_fingers.append(tip_ids[i])
                finger_states[i] = 1
    
    return finger_count, extended_fingers, finger_states

while True:
    ret, frame = cap.read()
    if not ret:
        continue

    frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    results = hands.process(frame)
    frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)

    if results.multi_hand_landmarks:
        for hand_landmarks in results.multi_hand_landmarks:
            mp_drawing.draw_landmarks(frame, hand_landmarks, mp_hands.HAND_CONNECTIONS)
            fingers_counted, extended_fingers, finger_states = count_fingers(hand_landmarks)
            
            # Подсветка кончиков разогнутых пальцев
            for tip_index in extended_fingers:
                tip_landmark = hand_landmarks.landmark[tip_index]
                x, y = int(tip_landmark.x * frame.shape[1]), int(tip_landmark.y * frame.shape[0])
                cv2.circle(frame, (x, y), 10, (0, 255, 0), cv2.FILLED)
            
            # Вывод состояния каждого пальца
            finger_state_text = ' '.join(['1' if state else '0' for state in finger_states])
            cv2.putText(frame, f'Fingers: {finger_state_text}', (50, 50), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 0, 0), 2, cv2.LINE_AA)

            # Передаем значение пальцев в Arduino
            handStates = 0
            for i in range(len(finger_states[1:])):
                handStates ^= (finger_states[i+1] << i)
            ser.write(bytearray([handStates]))

    cv2.imshow('Fingers Count', frame)
    
    if cv2.waitKey(10) & 0xFF == 27:
        break

cap.release()
cv2.destroyAllWindows()

Спасибо за внимание и интерес! Удачи и интересных экспериментов!

Комментарии (23)


  1. GennPen
    07.05.2024 10:59
    +1

    radio.setPALevel(RF24_PA_HIGH);

    За это нужно по пальцам бить. Почему во всех ардуиновских проектах на NRF24L01 выставляют высокую/максимальную мощность передачи, на расстояние передачи в пределах комнаты, и это при том что сам модуль может бить чуть ли не на километр.


    1. Stepan_Burmistrov Автор
      07.05.2024 10:59

      А в чем проблема, если модуль работает на высокой мощности?


      1. GennPen
        07.05.2024 10:59
        +1

        В том, что нет смысла бить на большую мощность забивая этот канал, мешая другим устройствам. 2.4ГГц это не только NRF24, это еще и WiFi, и BlueTooth.

        В пределах комнаты (и даже квартиры с бетонными стенами) более чем хватает минимальной мощности.


        1. Stepan_Burmistrov Автор
          07.05.2024 10:59

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


          1. GennPen
            07.05.2024 10:59

            Однако в пределах всего нашего помещения сигнал добивает только на высокой мощности.

            В стандартных примерах запустите сканер каналов и посмотрите какой самый свободный, возможно загруженный попался.


        1. ret77876
          07.05.2024 10:59
          +1

          Тут стоит заметить, что на передатчике стоит "слабая" версия нрфки(судя по фото), без усилителя на выходе. Если я ничего не путаю, то выходная мощность у неё пару мВт. Это намного меньше, чем у тех же роутеров. Тут скорее роутеры и смартфоны мешают нрфке


          1. Stepan_Burmistrov Автор
            07.05.2024 10:59

            А вы проводили тесты с разными версиями NRF?


            1. ret77876
              07.05.2024 10:59
              +1

              С тремя разными:
              0. nrf24l01+(как в статье)
              1. nrf24l01+PA+LNA(у него ещё sma антенна отдельная)
              2. nrf24l01 на 27 dBm от Ebyte (у них есть как с SPI, так и UART)
              По-сути везде одинаковая микросхема трансивера (https://www.sparkfun.com/datasheets/Components/SMD/nRF24L01Pluss_Preliminary_Product_Specification_v1_0.pdf). Только обвязка отличается. Но основная цель проверки заключалась в измерении максимальной дальности, поэтому они всегда работали на максимальной мощности и я особо не задумывался о том кому они там мешают. Самый мощный, на 27dbm, при постоянной передаче не мешал WiFi роутеру (либо я не заметил). Канал для nrf'ки я выбирал с минимальной загруженностью, поэтому скорее всего они просто были достаточно хорошо разнесены по частоте. Про bluetooth сказать ничего не могу, но он автоматический "скачет" по частотам в поиске менее загруженной, а значит просто так nrf'ка ему мешать не может (при адекватном использовании).


  1. VasVovec
    07.05.2024 10:59

    Вы не пробовали после отладки на компьютере перенести реализацию машинного зрения непосредственной на мобильную платформу, используя MicroPython, например?


    1. Stepan_Burmistrov Автор
      07.05.2024 10:59

      Можно, конечно! Только вот с рукой бегать за мобильным роботом будет не сильно удобно)))
      А так, можно и не microPython, а любой orangePi.
      Связать с Arduino можно по uart или по SPI (https://habr.com/ru/articles/708844/)


      1. VasVovec
        07.05.2024 10:59

        Можно, конечно! Только вот с рукой бегать за мобильным роботом будет не сильно удобно)

        Об этом я как-то не подумал ) Но тут надо провести эксперименты - с какого расстояния будут жесты распознаваться. Плюс еще возникает проблема, что при маневрах платформы надо будет удерживать в поле зрения кисть руки каким-то образом.


      1. Dynasaur
        07.05.2024 10:59

        Если это не робот, а, например, инвалидное кресло, то может быть очень даже удобно. Но вот заведутся ли нужные библиотеки на микропитоне? И потянет ли ардуина распознавание видеосигнала?


        1. Stepan_Burmistrov Автор
          07.05.2024 10:59

          А почему бы orangePi Zero не взять для этой задачи? Маленький, быстрый. Для такой задачи вполне подойдет.
          А Arduino для управления аппаратной частью.


          1. Dynasaur
            07.05.2024 10:59

            Так там вроде полноценный питон. В Распберри Пай точно, а в Зеро не знаю.