Проект «робот-грузчик»: определение ориентации

от автора

Месяц назад я писал об определении моим роботом-грузчиком собственного положения. (Жаль, ту статью я запостил в неудачное время в ночь на субботу, так что её мало кто увидел.) Как я отметил, показания колёсных датчиков позволяют роботу определять своё положение достаточно точно — медленно накапливающаяся ошибка корректируется, как только робот сканирует баркод на любой из полок склада. С другой стороны, накапливающуюся ошибку направления корректировать было нечем.

Я обсудил свои затруднения с девушкой-гуманитарием, и спросил, какие ей известны способы ориентации в пространстве. По её словам, в Лондонском музее науки она застала экспозицию, посвящённую ориентации муравьёв по виду вертикально вверх над головой. Посетителям предлагалось взять зеркало и идти по комнате, разглядывая в это зеркало узоры на потолке и ориентируясь лишь по ним. (Карта потолка прилагалась.)

Я решил проверить: что видит на потолке склада мой робот?


Даже на такой низкокачественной записи отлично различимы длинные прямоугольные окна. Все они, естественно, параллельны одной из осей склада, а значит, параллельны и рядам полок. Получается, если «в кадр» попадёт окно и я смогу определить его направление — то я получаю и направление робота, с точностью до 180°. Окна видно не из любой точки склада, но для периодической коррекции курса — они попадают в кадр достаточно часто.

В распознавании образов я не мастак, и первым делом я спросил на Q&A — нет ли для распознавания прямоугольных окон чего-нибудь уже готового, например, применим ли OpenCV? К сожалению, ничего толкового мне не подсказали — люди, которые «в теме», сочли ниже своего достоинства разжёвывать чайнику азы.

Американский форум: задал вопрос — получишь ответ.
Израильский форум: задал вопрос — получишь вопрос.
Русский форум: задал вопрос — тебе объяснят, какой ты мудак.

Тем более, что робот управлялся из-под Microsoft Robotics Development Studio, а готовой .NET-сборки для работы с OpenCV я не нашёл — я решил писать собственное распознавание с нуля. Не ракетная наука, чай — всего-то распознавать ярко-белые прямоугольники.

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

Для начала отделим ярко-белое окно от тёмного фона. Переводим из RGB в YCbCr и разделяем по Y=227 (порог выбран опытным путём, и в идеальном мире подбирался бы адаптивно по яркости изображения в целом). Попутно уменьшаем исходное изображение 640х480 до 320х240 — нам этого достаточно, а обработка ускорится вчетверо.

Код:

byte[] bytes = response.Frame; int stride = bytes.Length / height;  byte[,] belong = (byte[,])Array.CreateInstance(typeof(byte), new int[] { 326, 246 }, new int[] { -3, -3 });  int Ythreshold = settings.Ythreshold; for (int y = 0; y < 240; y++) {     int offset = stride * y * 2;     for (int x = 0; x < 320; x++)     {         int blu = bytes[offset] + bytes[offset + 3] + bytes[offset + stride] + bytes[offset + stride + 3]; offset++;         int grn = bytes[offset] + bytes[offset + 3] + bytes[offset + stride] + bytes[offset + stride + 3]; offset++;         int red = bytes[offset] + bytes[offset + 3] + bytes[offset + stride] + bytes[offset + stride + 3]; offset += 4;          belong[x, y] = (0.299 / 4 * red + 0.587 / 4 * grn + 0.114 / 4 * blu > Ythreshold ? (byte)1 : (byte)0);     } } 

В результате разделения получается такое изображение:

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

Удаляем шум медианным фильтром (хотя в QA мне отчего-то советовали гауссовское размытие. Ну вот зачем здесь размытие?) с радиусом 3 пиксела, и порогом в 5 светлых пикселей из 21 рассмотренных. Такой фильтр «склоняется» к соединению светлых областей, между которыми есть тёмные пиксели — так, чтобы наше окно из трёх стёкол объединилось в один прямоугольник.

private bool[,] filtered = (bool[,])Array.CreateInstance(typeof(bool), new int[] { 326, 246 }, new int[] { -3, -3 });  int medianThreshold = settings.MedianThreshold; for (int x = 0; x < 320; x++)     for (int y = 0; y < 240; y++)               filtered[x, y] = belong[x - 1, y - 2] + belong[x, y - 2] + belong[x + 1, y - 2] +         belong[x - 2, y - 1] + belong[x - 1, y - 1] + belong[x, y - 1] + belong[x + 1, y - 1] + belong[x + 2, y - 1] +         belong[x - 2, y * 1] + belong[x - 1, y * 1] + belong[x, y * 1] + belong[x + 1, y * 1] + belong[x + 2, y * 1] +         belong[x - 2, y + 1] + belong[x - 1, y + 1] + belong[x, y + 1] + belong[x + 1, y + 1] + belong[x + 2, y + 1] +                                belong[x - 1, y + 2] + belong[x, y + 2] + belong[x + 1, y + 2] > medianThreshold; 

Размерность для массивов belong и filtered — [-3..322, -3..242] — выбрана нарочно с «полями» по три пиксела с каждой стороны, чтобы работать с этими массивами, не заморачиваясь проверками индексов.

После фильтрации на изображении остаются белыми только окно, объединённое из трёх стёкол, и несколько бликов на полке:

Постановим, что самое большое белое пятно в кадре — это непременно окно. Зальём (floodfill) каждое белое пятно, и возьмём наибольшее по площади.

int biggest_area = 0; byte area_id = 1, biggest_id = 0; // areas start from 2 Rectangle bounds = new Rectangle(); PointF cg = new PointF(); // center Point[] stack = new Point[320*200];   for (int x = 0; x < 320; x++)     for (int y = 0; y < 240; y++)         if (filtered[x, y] && belong[x, y] <= 1)         {             int area = 0, left = 320, top = 240, right = 0, bottom = 0;             int sx = 0, sy = 0;             ++area_id;             // FloodFill              int sp = 0;             stack[0] = new Point(x, y);             while (sp >= 0)             {                 Point next = stack[sp--];                 area++;                 sx += next.X;                 sy += next.Y;                 belong[next.X, next.Y] = area_id;                  if (next.X < left) left = next.X;                 if (next.X > right) right = next.X;                 if (next.Y < top) top = next.Y;                 if (next.Y > bottom) bottom = next.Y;                  if (filtered[next.X - 1, next.Y] && belong[next.X - 1, next.Y] <= 1) stack[++sp] = new Point(next.X - 1, next.Y);                 if (filtered[next.X, next.Y - 1] && belong[next.X, next.Y - 1] <= 1) stack[++sp] = new Point(next.X, next.Y - 1);                 if (filtered[next.X, next.Y + 1] && belong[next.X, next.Y + 1] <= 1) stack[++sp] = new Point(next.X, next.Y + 1);                 if (filtered[next.X + 1, next.Y] && belong[next.X + 1, next.Y] <= 1) stack[++sp] = new Point(next.X + 1, next.Y);             }             if (area > biggest_area)             {                 biggest_area = area;                 biggest_id = area_id;                 bounds = new Rectangle(left, top, right - left, bottom - top);                 cg = new PointF((float)sx / area, (float)sy / area);             }         } 

Изображение для распознавания готово! Двухуровневое — белый прямоугольник на чёрном фоне. Мы даже вычислили во время заливки координаты его «центра масс» cg (на изображениии — красная точка) и границ (зелёная точка — центр bounding box). Можем теперь проверить, насколько наше белое пятно похоже на окно: площадь biggest_area должна быть не меньше 2000 пикселей, расстояние между двумя центрами — не больше 20 пикселей, иначе фигура слишком несимметрична для прямоугольника. Но как дальше будем определять его ориентацию?

Ракетные учёные, может быть, применили бы преобразование Хафа, перевели бы изображение в 4-мерное пространство вероятностных параметров прямоугольника (ширина, длина, угол наклона, смещение от начала координат), и искали бы там максимум. Но я подошёл к задаче по рабоче-крестьянски, и для начала составил «гистограмму» удаления точек прямоугольника от его геометрического центра:

PointF c = new PointF(bounds.Left + (float)bounds.Width / 2, bounds.Top + (float)bounds.Height / 2);  int[] hist = new int[400];  for (int i = 0; i < 400; i++) hist[i] = 0;  int maxdist = 0; for (int x = bounds.Left; x <= bounds.Right; x++)     for (int y = bounds.Top; y <= bounds.Bottom; y++)         if (belong[x, y] == biggest_id)         {             int dist = (int)Math.Sqrt(Sqr(x - c.X) + Sqr(y - c.Y));             hist[dist]++;             if (dist > maxdist) maxdist = dist;         } 

Серый график — это и есть гистограмма, на ней тёмная полоса — максимум, т.е. наибольшее число точек находится именно на таком расстоянии от центра прямоугольника. (На прямоугольнике проведена тёмная окружность, соответствующая этому расстоянию.) Легко понять, что это расстояние как раз и есть полуширина прямоугольника: до неё — окружности целиком помещаются внутри прямоугольника, и гистограмма линейно (с коэффициентом π) растёт; потом гистограмма медленно убывает, пока не дойдёт до полудлины прямоугольника; с этого момента внутри прямоугольника помещаются только четыре «уголка» окружностей, и гистограмма резко спадает. К сожалению, найти длину через этот «обрыв» на гистограмме не удаётся — края прямоугольника оказываются слишком рваными, и зашумляют конец гистограммы. Мы пойдём другим путём, и рассмотрим круг на 5 пикселей шире прямоугольника. В нём будут две чёрные «боковушки» как раз на поперечной оси прямоугольника:

Аккуратно найдём центры масс этих «боковушек»: сложность здесь в том, чтобы их разделить. Считаем отдельно центр масс в каждом «квадранте», потом объединяем «квадранты» в две пары по близости центров.

int r1 = 0; // incircle radius for (int x = maxdist; x >= 3; x--) {     if (hist[x] > hist[r1]) r1 = x; }  int rSample = r1 + 5; int[] voters = new int[4]; Point[] sums = new Point[4]; Point sampleOrg = new Point(Math.Max((int)(c.X - rSample), 0),                             Math.Max((int)(c.Y - rSample), 0)); Rectangle sample = new Rectangle(sampleOrg, new Size(                                  Math.Min((int)(c.X + rSample), 319) - sampleOrg.X,                                  Math.Min((int)(c.Y + rSample), 239) - sampleOrg.Y)); for (int x = sample.Left; x <= sample.Right; x++)     for (int y = sample.Top; y <= sample.Bottom; y++)         if (belong[x, y] != biggest_id)         {             int dist = (int)Math.Sqrt(Sqr(x - c.X) + Sqr(y - c.Y));             if (dist > r1 && dist <= rSample)             {                 int idx = y < c.Y ? (x < c.X ? 1 : 0)                                   : (x < c.X ? 2 : 3);                 voters[idx]++;                 sums[idx].X += x;                 sums[idx].Y += y;             }         }  PointF adjusted = new PointF(); int vAbove = voters[0] + voters[1],     vBelow = voters[2] + voters[3],      vLeft = voters[2] + voters[1],     vRight = voters[0] + voters[3],     allVoters = vAbove + vBelow; if (allVoters == 0) {     // abort: no black pixels found } else {     if (vAbove > 0 && vBelow > 0)     {         // split vertically         PointF above = new PointF((float)(sums[0].X + sums[1].X) / vAbove - c.X, (float)(sums[0].Y + sums[1].Y) / vAbove - c.Y),                below = new PointF((float)(sums[2].X + sums[3].X) / vBelow - c.X, (float)(sums[2].Y + sums[3].Y) / vBelow - c.Y);         double dAbove = Math.Sqrt(above.X * above.X + above.Y * above.Y),                dBelow = Math.Sqrt(below.X * below.X + below.Y * below.Y);         if (dAbove >= r1 && dAbove <= rSample && dBelow >= r1 && dBelow <= rSample)             // the split is valid             adjusted = new PointF((above.X * vAbove - below.X * vBelow) / allVoters, (above.Y * vAbove - below.Y * vBelow) / allVoters);     }     if (adjusted.X == 0 && adjusted.Y == 0 &&         vLeft > 0 && vRight > 0)     {         // split horizontally         PointF toleft = new PointF((float)(sums[2].X + sums[1].X) / vLeft - c.X, (float)(sums[2].Y + sums[1].Y) / vLeft - c.Y),               toright = new PointF((float)(sums[0].X + sums[3].X) / vRight - c.X, (float)(sums[0].Y + sums[3].Y) / vRight - c.Y);         double dLeft = Math.Sqrt(toleft.X * toleft.X + toleft.Y * toleft.Y),               dRight = Math.Sqrt(toright.X * toright.X + toright.Y * toright.Y);         if (dLeft >= r1 && dLeft <= rSample && dRight >= r1 && dRight <= rSample)             // the split is valid             adjusted = new PointF((toleft.X * vLeft - toright.X * vRight) / allVoters, (toleft.Y * vLeft - toright.Y * vRight) / allVoters);     } } 

Точка adjusted теперь указывает из центра прямоугольника вдоль его поперечной оси:

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

Продемонстрирую работу на ещё одном примере — когда окно попадает в кадр не полностью. Благодаря тому, что мы не пользуемся «концом» гистограммы для распознавания — нас неполное окно нисколько не сбивает с толку.

Второй пример

Исходное изображение:

После разделения:

После фильтрации:

Гистограмма:

Круг с боковушками:

Поперечная ось:

Написанный мной код был облачён в MRDS-сервис WindowDetector — по образу и подобию стандартного Technologies\Vision\ColorSegment. Мой сервис привязывается к видеокамере, и по каждому обновлению кадра высылает подписчикам UpdateFoundWindow с углом наклона окна в кадре. MRDS-сервис, управляющий роботом, привязывается к WindowDetector точно так же, как привязывается к сканеру баркодов, и совершенно аналогично обрабатывает сообщения от обоих — корректируя в первом случае курс, в другом случае положение.

С детектором окон мой робот ездил по складу довольно резво:

Дальнейшая судьба робота печальна. Всего через час после съёмки этого видео колёсный датчик забился складской пылью, и робот перестал следить за своим положением. При изначальной сборке робота этот датчик ставится самым первым, т.е. для его замены нужно по сути всё разобрать и потом собрать по-новой. Я решил попробовать заменить датчик «на живом» роботе, но по неуклюжести закоротил батарею обо что-то внутри робота, и ездить он перестал совсем. Пришлось его оставить на складской полке рядом с позапрошлогодним роботом — тем, что на основе Roomba. Прямо робокладбище, а не полка.

Так я перед отъездом из Британии и не успел сделать Eddie действующим роботом-грузчиком. Но уже этой весной я, скорее всего, вернусь туда с запчастями, оживлю его, и продолжу испытания.

ссылка на оригинал статьи http://habrahabr.ru/post/164979/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *