Автоматическое слежение за оранжевым шариком

Все животные, включая людей, очень любят следить за движущимися объектами. Этот встроенный в живых существ механизм, позволяет им не терять вещь из виду и позиционировать её в пространстве. Этот проект посвящен созданию робота-наблюдателя, который при помощи OpenCV будет следить за теннисным мячиком.

Итак, за каким объектом будет следить робот? По началу, в качестве такого объекта я выбрал обычный теннисный шарик. Шарик этот имеет классический ярко-зеленый цвет, и теоретически должен был легко выделяться из явно не зеленого окружения. Однако, в дальнейшем пришлось заменить большой зеленый шар на маленький оранжевый шарик для пинг-понга. Причина замены будет описана ниже.

В проекте были использованы два самых простых сервомотора sg90, драйвер сервомоторов Pololu и вебкамера Microsoft LifeCam HD 3000.

1. Программа для детектирования окружности

Писать программу было решено на языке python. Алгоритм работы python-скрипта для распознавания состоит из шести основных шагов:

  • преобразование кадра в формат HSV;
  • фильтрация в заданном диапазоне HSV;
  • морфологическое преобразование;
  • размывание;
  • детектирование окружностей;
  • передача управляющих сигналов на сервоприводы.

Преобразование в HSV

hsv = cv2.cvtColor(img, cv.CV_BGR2HSV )

Здесь всё просто. Даем изображение с веб-камеры, получаем — конвертированное в HSV изображение. Почему HSV? Потому что в HSV проще создать правильную маску для выделения нужного цвета.

Слежение за шариком OpenCV, HSV модель

Фильтрация по цвету

thresh = cv2.inRange(hsv, h_min, h_max)

Очень важный шаг. Функция inRange преобразует цветную картинку в черно-белую маску. В этой маске, все пиксели, попадающие в заданный диапазон — становятся белыми. Прочие — черными. Результат работы inRange представлен на второй картинке.

Слежение за шариком OpenCV, threshold

Морфологическое преобразование

st1 = cv2.getStructuringElement(cv2.MORPH_RECT, (21, 21), (10, 10))
st2 = cv2.getStructuringElement(cv2.MORPH_RECT, (11, 11), (5, 5))
thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, st1)
thresh = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, st2)

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

Слежение за шариком OpenCV, morph

 

Размывание

thresh = cv2.GaussianBlur(thresh, (5, 5), 2)

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

Детектирование окружностей

circles = cv2.HoughCircles( thresh, cv.CV_HOUGH_GRADIENT, 2, mcr, np.array([]), cet1, cet2, mcs, xcs)

Собственно, само детектирование. Процедура HoughCircles находит на изображении все окружности, используя при этом преобразование Хофа. Важными параметрами здесь являются:

  • mcr — минимальное расстояние между окружностями (h/4);
  • cet1 и cet2 — параметры оператора Кэнни, используемого для построения границ объекта (80 и 50);
  • mcs, xcs — минимальный и максимальный радиус окружностей (5 и 0).

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

cv2.circle(img, (x, y), 3, (0,255,0), -1)
cv2.circle(img, (x, y), maxRadius, (255,0,0), 3)

Также в углу кадра отображается время между двумя кадрами.

text = '%0.1f' % (1./dt)
cv2.putText( img, text, (20, 20), 
             cv2.FONT_HERSHEY_PLAIN,
             1.0, (0, 110, 0), thickness = 2)

Результат:

Слежение за шариком OpenCV

На последнем шаге, на основе координат обнаруженной окружности, рассчитываются углы поворота сервоприводов.

sctrl.shift(0, (x*1./w)*20-10)
sctrl.shift(1, -((y*1./h)*20-10))

Полный код программы детектирования окружности

import sys, time
from threading import Thread
import cv2.cv as cv
import cv2
import numpy as np
import video

from servo import Servo

COM = 'COM4'

MORPH_ON = 1
HOUGH_ON = 1
SERVO_ON = 1
BLUR_ON = 1
SHOW_MAIN = 1
SHOW_THRESH = 1
FLIP = 1

COLOR_RANGE={
 'ball_light': (np.array((20, 70, 170), np.uint8), np.array((40, 170, 255), np.uint8)),
 'ball_dark': (np.array((0, 170, 120), np.uint8), np.array((20, 240, 255), np.uint8)),
}

def nothing( *arg ):
    pass

def createPath( img ):
    h, w = img.shape[:2] 
    return np.zeros((h, w, 3), np.uint8)

class Tracker(Thread):
    def __init__(self, color, color_2=None, flag=0):
        Thread.__init__(self)
        self.color = color
        self.path_color = cv.CV_RGB(0,110,0)

        self.lastx = 0
        self.lasty = 0
        self.time = 0
 
        self.h_min = COLOR_RANGE[color][0] 
        self.h_max = COLOR_RANGE[color][1]
        if color_2:
            self.h_min_2 = COLOR_RANGE[color_2][0]
            self.h_max_2 = COLOR_RANGE[color_2][1]

        self.flag = flag
 
        if self.flag:
            cv2.namedWindow( self.color )
 
    def poll(self,img):
        par1 = 80#40
        par2 = 50#67
        h, w = img.shape[:2]
        dt = time.clock() - self.time
        self.time = time.clock()

        hsv = cv2.cvtColor(img, cv.CV_BGR2HSV )
        thresh = cv2.inRange(hsv, self.h_min, self.h_max)
        thresh_2 = cv2.inRange(hsv, self.h_min_2, self.h_max_2)
        thresh = cv2.bitwise_or(thresh, thresh_2)

        if MORPH_ON:
            st1 = cv2.getStructuringElement(cv2.MORPH_RECT, (21, 21), (10, 10))
            st2 = cv2.getStructuringElement(cv2.MORPH_RECT, (11, 11), (5, 5))
            thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, st1) 
            thresh = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, st2)

        if BLUR_ON: 
            thresh = cv2.GaussianBlur(thresh, (5, 5), 2)
 
        circles = None
        if HOUGH_ON:
            circles = cv2.HoughCircles( thresh, 
                                        cv.CV_HOUGH_GRADIENT, 2, h/4, 
                                        np.array([]),
                                        par1, par2, 5, 0) 
 
        if circles is not None:
            maxRadius = 0
            x = 0
            y = 0
            found = False
 
            for c in circles[0]:
                found = True
                if c[2] > maxRadius:
                    maxRadius = int(c[2])
                    x = int(c[0])
                    y = int(c[1])
            if found:
                cv2.circle(img, (x, y), 3, (0,255,0), -1)
                cv2.circle(img, (x, y), maxRadius, (255,0,0), 3)
 
                xspeed = abs(x - self.lastx)/dt;
                yspeed = abs(y - self.lasty)/dt;
 
                self.lastx = x
                self.lasty = y

                if SERVO_ON:
                    yaw = (x*1./w)*50.0 - 25.0
                    pitch = (y*1./h)*39.0 - 19.5
                    sctrl.shift(0, (x*1./w)*20-10)
                    sctrl.shift(1, -((y*1./h)*20-10))

        if FLIP:
            img = cv2.flip(img,0)
            thresh = cv2.flip(thresh,0)

        if self.flag:
            cv2.imshow(self.color, thresh)

        text = '%0.1f' % (1./dt)
        cv2.putText( img, text, (20, 20), 
                     cv2.FONT_HERSHEY_PLAIN,
                     1.0, (0, 110, 0), thickness = 2)

        if SHOW_MAIN:
            cv2.imshow('result', img)

if __name__ == '__main__':
    if SHOW_MAIN:
        cv2.namedWindow( "result", cv.CV_WINDOW_AUTOSIZE )
     green = Tracker('ball_light', 'ball_dark', SHOW_THRESH)
     green.start()

    if SERVO_ON:
        sctrl = Servo( com=COM )
        sctrl.setpos(0,45)
        sctrl.setpos(1,45)

    cap = video.create_capture(1)
    flag, img = cap.read()
    green.path = createPath(img)

    while True:
        flag, img = cap.read()
        try:
            green.poll(img)
        except:
            cap = None
            green = None
            if SERVO_ON:
                sctrl.stop()
            raise
 
        green.join()
        ch = cv2.waitKey(5)
        if ch == 27:
            break

    if SERVO_ON:
        sctrl.setpos(0,45)
        sctrl.setpos(1,45) 
        sctrl.stop()

   green = None
   cap = None
   cv2.destroyAllWindows()

2. Программа для управления сервомоторами на Python

В представленной выше программе класс Servo отвечает за передачу управляющих команд для сервомоторов в специальный драйвер фирмы Pololu. Полный код класса:

import serial
import sys

MODE_256 = 0
MODE_QUARTER = 1

def clamp( val, mmin, mmax ):
    if val > mmax:
        val = mmax
    elif val < mmin:
        val = mmin
    return val

class Servo:
    ser = None

    def __init__( self, com='COM6', baud = 9600, mode=MODE_256 ):
        self.ser = serial.Serial(com)
        self.ser.baudrate = baud
        self.mode = mode
        self.coords = [0,0]

   def stop( self ):
        self.ser.close()

   def shift( self, n, angle ):
        angle = clamp( self.coords[n] + angle, 0, 90)
        self.setpos( n, angle )

   def setpos( self, n, angle ):
        angle = clamp(angle, 0, 90)
        self.coords[n] = angle

        if self.mode == MODE_256:
            pos = int( 254 * angle/90.0 )
            bud=chr(0xFF)+chr(n)+chr(pos)
        elif mode == MODE_QUARTER:
            pos = int( 1000 * angle/90.0 ) + 1000
            lo = pos & 0x7F
            hi = ( pos >> 7 ) & 0x7F
            bud=chr(0x84)+chr(n)+chr(lo)+chr(hi)

    self.ser.write(bud)
        self.ser.flush()

2. Проблемы

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

Камера

Изначально, я хотел использовать старенькую 300-килопиксельную веб-камеру. Я даже вытащил её из родного корпуса и прикрутил к монтировке (см. предыдущий пост). Однако, после попытки провести распознавание, меня ждало разочарование. Цвета изображения были каким-то блеклыми, и зеленый шарик выглядел совсем не зеленым. Никак не получалось подобрать маску для отделения этого шарик от фона. Кроме того, изрядно тормозил видеопоток, вероятно из-за встроенного алгоритма автоматический экспозиции, который старался высветлить темную картинку.

Ввиду такого безобразия, я решил заменить камеру на что-то более качественное и настраиваемое. Посмотрев различные ролики на ютубе, мой выбор пал на доступную Microsoft LifeCam HD 3000. Подключив эту камеру к роботу, я снова немного разочаровался 🙁 Опять тормоза и непонятная цветопередача. Благо, отключение автоматической экспозиций решило проблему с тормозами, а отключение TrueColor и авто-баланса белого — немного стабилизировало цветопередачу.

Инфракрасное излучение

Несмотря на новую камеру, мне по-прежнему долго не удавалось настроить маску для выделения нужного цвета. Лишь спустя множество попыток, я понял что камера видит этот мир совсем не так как я. Смотря на оранжевый шарик, я вижу оранжевый шарик. Когда на этот же шарик смотрит веб-камера — она видит чертовски яркий оранжевый шарик. Такой яркий, что он уже не оранжевый, а желтый. Как это обычно со мной бывает, причина явления лежала на поверхности. Дело в том, что матрица камеры очень хорошо чувствует ближнее инфракрасное излучение (Near-infrared). Эти ИК лучи в больших количествах излучаются галогеновыми (и не только) лампами и солнцем.

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

Настройка цветовой маски

Как уже говорилось, для каждого объекта требуется специально настраивать цветовую маску (даже две). Для настройки этой маски, а именно параметров h_min, h_max функции inRange пришлось сделать отдельное приложение с кучей бегунков. Чтобы выделить нужный цвет необходимо подобрать границы компонента H (Hue). Параметр S (Saturation) отвечает за насыщенность цвета. Например, кожа человека имеет оранжево-желтый цвет (H) как у шарика, но слабую насыщенность. Наконец V (Value) определяет яркость цвета. Затененный шарик будет иметь низкое значение V.

Для моего оранжевого шарика я подобрал такие маски:

светлый шарик — (20, 70, 170) — (40, 170, 255)
темный шарик — (0, 170, 120) — (20, 240, 255)

Несмотря на возникшие трудности, робот-наблюдатель всё-таки ожил. Следующий этап — установка ИК фильтра и распознавание лиц. Небольшое видео представлено ниже.

0

Изменено: