Готовый перевод Mastering OpenCV with Practical Computer Vision Projects / Опановуюємо OpenCV через практичні проекти комп'ютерного зору: Розділ 2. Доповнена реальність на iOS та Android з використанням маркерів
Доповнена реальність (AR) - це живе зображення реального середовища із фрагментами, доповненими згенерованими комп'ютером графічними елементами. В результаті технології покращують сприйняття людиною дійсності. За домовленістю доповнення відбувається в режимі реального часу та семантичному контексті з елементами середовища. Завдяки розвиненим AR технологіям (наприклад, приєднанню комп'ютерного зору та розпізнавання об'єктів) інформація про навколишній світ користувача стає інтерактивною та може оброблюватись цифровими методами. Штучні дані про середовище та об'єкти можна розмістити над реальними кадрами.
У цьому розділі ми створимо AR застосунок для мобільних пристроїв iPhone/iPad. Для початку зупинимось на програмі, що використовує маркери для виводу штучних об'єктів на поверхні отриманих з камери кадрів. Ви зрозумієте, як налаштувати проєкт в середовищі розробки XCode та приєднати до нього бібліотеку OpenCV. Окрім того, пояснено такі речі як захоплення відеопотоку з вбудованої камери, візуалізація тривимірної сцени з використанням OpenGL ES та побудова базової архітектури застосунку AR. Перш за все, подамо вичерпний список знань та необхідних програмних рішень:
Знадобиться комп'ютер Apple зі встановленим середовищем XCode. Розробка застосунків для iPhone/iPad можлива лише з його використанням. Це єдиний спосіб програмувати для платформи iOS.
Також необхідна модель пристрою iPhone, iPad чи iPod Touch. Щоб запускати на ньому власні застосунки, доведеться купити Сертифікат Розробника Apple (ADC) за $99 в рік. Ви не зможете запустити розроблену програму на пристрої без цього сертифікату.
Необхідні базові знання XCode IDE. Припускаємо, що читачі мають певний досвід використання середовища.
Базові знання мов програмування Objective-C та С++ також важливі. Хоча, всі складні частини вихідного коду застосунку пояснимо детально.
З цього розділу ви дізнаєтесь більше про маркери, а також повну процедуру їх розпізнавання. Читаючи далі, ви зможете створити власний алгоритм розпізнавання маркерів, оцінити їх позу у тривимірному просторі залежно від пози камери та використати перетворення між останніми, щоб візуалізувати довільні 3D об'єкти.
Серед додатків до книги доступний навчальний проєкт до цього розділу. Це добра початкова точка для створення першого мобільного застосунку з доповненою реальністю.
Розділ розкриє наступні теми:
Створення iOS проєкту, що використовує OpenCV
Архітектура застосунку
Розпізнавання маркера
Ідентифікація маркера
Знаходження коду маркера
Розміщення маркера в 3D-просторі
Візуалізація віртуального 3D-об'єкта
Створення iOS проєкту, що використовує OpenCV
У цій частині створимо програму для пристроїв iPhone/iPad, який використає OpenCV (бібліотека комп'ютерного зору з відкритим кодом) для розпізнавання маркерів на кадрі та зображення 3D-об'єктів над ними. Приклад показує, як отримати доступ до необробленого потоку відеоданих з камери приладу, як виконати обробку зображення з використанням бібліотеки OpenCV, як знайти маркер та доповнити зображення.
Почнемо зі створення нового проєкту XCode. Оберемо шаблон "iOS Single View Application", як показано на наступному скріншоті:
Тепер маємо додати OpenCV до проєкту. Цей крок необхідний, адже застосунок використає багато функцій бібліотеки для розпізнавання маркерів та оцінки їх пози.
OpenCV - це бібліотека для програмування функцій комп'ютерного зору в режимі реального часу. Вона розроблена компанією Intel, а зараз підтримується авторами Willow Garage та Itseez. Бібліотека написана мовами C та С++. Існує також офіційний модуль для Python та неофіційні для Java та .NET.
Додаємо фреймворк OpenCV
На щастя, бібліотека кросплатформова, тому її можна використати на пристроях iOS. Починаючи з версії 2.4.2, OpenCV офіційно підтримується на платформі iOS, відповідний пакет можна завантажити із сайту бібліотеки на http://opencv.org/. За адресою OpenCV для iOS знаходиться зменшений фреймворк оригінального продукту. Не хвилюйтесь, якщо вперше стикаєтесь із розробкою для iOS; фреймворк - це лише набір файлів. Звичайно, кожен пакет фреймворку включає файли заголовків та статично зв'язані бібліотеки. Фреймворки дозволяють легко поширювати завчасно збудовані бібліотеки розробникам.
Звісно, можна будувати й власні бібліотеки з нуля. Документація OpenCV детально пояснює цей процес. Для простоти слідуватимемо рекомендованому шляху та застосуємо фреймворк для цього розділу. Завантаживши файли, скопіюємо їх вміст у теку проєкту, як показано на наступному скріншоті:
Щоб XCode використав який-небудь фреймворк на етапі побудови рішення, виберіть налаштування проєкту та знайдіть закладку з фазами. Тут можна додати чи видалити список тих із них, що стосуються процесу побудови. Натисніть на знак плюса, аби додати новий фреймворк. Результат має бути приблизно таким:
Обрати можна зі списку стандартних фреймворків. Але щоб додати користувацький фреймворк, маємо натиснути на кнопку "Додати інше". Відкриється діалогове вікно відкриття файлу. Вкажіть на теку проєкту opencv2.framework ось так:
Підключаємо заголовки OpenCV
Ми додали фреймворк OpenCV до проєкту, тому найважливішу частину завдання вже виконано. Залишилося останнє - під'єднати заголовки OpenCV до попередньо скомпільованих для цього проєкту. Це чудова річ, яка дозволяє прискорити етап компіляції. Додаючи відповідні файли таким чином, ми автоматично включимо заголовки OpenCV до всього вихідного коду. Знайдіть файл .pch у дереві проєкту та модифікуйте його так:
Наступний код показує, як модифікувати файл .pch у дереві проєкту:
//
// Префікс заголовків для всіх файлів з кодом для 'Example_MarkerBasedAR'
//
#import <Availability.h>
#ifndef __IPHONE_5_0
#warning "Цей проєкт використовує особливості, доступні лише для iOS SDK 5.0 і
пізніше."
#endif
#ifdef __cplusplus
#include <opencv2/opencv.hpp>
#endif
#ifdef __OBJC__
#import <UIKit/UIKit.h>
#import <Foundation/Foundation.h>
#endif
Тепер можна викликати будь-яку функцію OpenCV в будь-якому місці вашого проєкту.
От і все. Наш шаблон проєкту налаштовано, а ми готові рухатися далі. Дружня порада: зробіть копію проєкту, це збереже вам чимало часу, коли створюватимете іншу!
Архітектура застосунку
Кожна програма iOS має принаймні один екземпляр інтерфейсу UIViewController, що оброблює всі події виду та керує бізнес-логікою застосунку. Клас пропонує фундаментальну модель управління видом для всіх програм iOS. Контролер виду керує набором елементів, що формує частину інтерфейсу користувача застосунку. Відіграючи роль частини відповідного рівня, контролер виду координує зусилля з об'єктами моделі та іншими структурами - включно з рештою контролерів виду - тому застосунок матиме єдиний послідовний графічний інтерфейс.
Застосунок, який ми запрограмуємо, матиме лише один вид; тому оберемо шаблон "SingleView Application". Використаємо його, щоб зобразити малюнок. Наш клас ViewController включатиме три головні компоненти, необхідні кожному застосунку доповненої реальності (дивіться наступну діаграму):
Джерело відео
Порядок обробки
Двигун візуалізації
Джерело відео відповідальне за передачу нових кадрів від вбудованої камери в код користувача. Це значить, що він повинен мати можливість обирати тип камери (передня чи задня), налаштовувати її параметри (наприклад, роздільна здатність отриманого відео, баланс білого та витримка), та отримувати кадри, не зупиняючи основний потік графічного інтерфейсу.
Процедуру обробки зображення інкапсульовано в клас MarkerDetector. Клас пропонує крихітний інтерфейс для коду користувача - набір функцій, подібних до processFrame та getResult. Взагалі кажучи, це все, про що має знати ViewController. Не потрібно виставляти напоказ низькорівневі структури даних та алгоритми рівню видів без особливої необхідності. VisualizationController включає всю логіку, пов'язану з візуалізацією доповненої реальності на елементах виду.
VisualizationController також є фасадом, що абстрагує конкретну реалізацію способу візуалізації. Слабка послідовність коду дає можливість змінювати ці компоненти без необхідності переписувати решту коду.
Подібний підхід дозволяє вільно використовувати незалежні модулі на інших платформах і компіляторах. Наприклад, можна легко застосувати клас MarkerDetector, щоб запрограмувати настільні рішення для систем Mac, Windows та Linux без жодних змін у коді. Подібним чином, можна перенести VisualizationController на платформу Windows та використати Direct3D для візуалізації. У цьому випадку знадобиться написати лише нову реалізацію VisualizationController, а інші частини коду залишити тими ж.
Головна процедура обробки починається з отримання нового кадру від джерела відео. Це змушує відеопотік інформувати код користувача про цю подію з використанням функції зворотного виклику. ViewController оброблює її результат та виконує наступні операції:
1. Відправляє новий кадр контролеру візуалізації.
2. Виконує обробку нового кадру за встановленим порядком.
3. Передає виявлені маркери етапу візуалізації.
4. Виводить сцену на екран.
Розглянемо процедуру більш детально. Візуалізація AR-сцени включає вивід фонового зображення останнього отриманого кадру; штучні тривимірні об'єкти малюються дещо пізніше. Коли новий кадр відправляється на етап візуалізації, ми копіюємо дані у внутрішні буфери двигуна. Це все ще не візуалізація; ми лише оновлюємо текст новим растровим зображенням.
Другий крок - це обробка нового кадру та розпізнавання маркера. Ми передаємо зображення як вхідні дані, а в результаті отримуємо список знайдених маркерів. Останні передаємо контролеру візуалізації, що знає, як з ними працювати. Поглянемо на діаграму послідовності виконання цієї процедури:
Розробку почнемо зі створення компоненти відеозахоплення. Цей клас відповідатиме за передачу кадрів в код користувача та відправлення захоплених кадрів через функцію зворотного виклику. Пізніше напишемо алгоритм розпізнавання маркера. Дана процедура є ядром застосунку. У цій частині програми ми використаємо багато функцій OpenCV для обробки зображень, пошуку контурів, прямокутників маркерів та оцінки їх позицій. Після цього сконцентруємось на візуалізації результатів з використанням доповненої реальності. Поєднавши ці речі, отримаємо перший AR-застосунок. Тому не зупиняємось і йдемо далі!
Отримуємо доступ до камери
Застосунок з доповненою реальністю неможливо створити без двох найважливіших речей: захоплення відеопотоку та візуалізації. Перший етап включає передачу кадрів з камери пристрою, виконання важливих перетворень кольору та відправлення результатів на обробку. Оскільки для програм з AR критична швидкість обробки кожного кадра, процес захоплення має бути настільки ефективним, наскільки можливо.
Кращий спосіб досягнути максимальної продуктивності - отримати прямий доступ до кадрів камери. Така змога з'явилася, починаючи з четвертої версії iOS. Наявні інструменти для програмування з фреймворку AVFoundation дають необхідну функціональність для прямого зчитування з буферів зображень у пам'ять.
Існує багато прикладів, що використовують клас AVCaptureVideoPreviewLayer та функцію UIGetScreenImage, щоб захоплювати відеопотік камери. Цією технікою користувались в iOS версії 3 та нижче.
Зараз цей підхід вийшов з ужитку, адже має два суттєвих недоліки:
Відсутність прямого доступу до даних. Щоб отримати растрове зображення, доводиться створювати посередник типу UIImage, копіювати зображення в нього та з нього. Для AR ціна такого рішення надто висока, адже важлива кожна мілісекунда. Втрата декількох кадрів в секунду негативно вплине на досвід користувача.
Щоб зобразити доповнену реальність, треба надбудувати прозорий вид, що її представлятиме. Згідно з настановами Apple, слід уникати незаповнених прошарків, бо їхнє змішування повільне на мобільних процесорах.
Класи AVCaptureDevice та AVCaptureVideoDataOutput дозволять налаштувати, захопити та визначити необроблені кадри відео у 32-бітному форматі BGRA. Також можна змінити бажану роздільну здатність результату. Майте на увазі, це впливає на продуктивність, адже чим більші розміри кадру, тим більше часу для обробки та пам'яті вимагається.
Існує непогана альтернатива високопродуктивному захопленню відеопотоку. Програмний інтерфейс AVFoundation пропонує значно швидший та елегантніший спосіб отримати кадри напряму з камери. Але спершу поглянемо на наступний малюнок, де зображений процес захоплення для iOS:
AVCaptureSession - це наріжний об'єкт захоплення відеопотоку, який ми маємо створити. Процес потребує два компоненти - вхід та вихід. Пристрій входу може бути фізичним (камера) або відеофайлом (не показано на діаграмі). У нашому випадку це вбудована камера (передня чи задня). Вихідний пристрій можна представити одним з наступних інтерфейсів:
AVCaptureMovieFileOutput
AVCaptureStillImageOutput
AVCaptureVideoPreviewLayer
AVCaptureVideoDataOutput
Інтерфейс AVCaptureMovieFileOutput використовується для запису відео у файл, AVCaptureStillImageOutput - для створення зображень, а AVCaptureVideoPreviewLayer - для програвання відеоматеріалу на екрані. Ми зацікавлені в роботі з інтерфейсом AVCaptureVideoDataOutput, адже він дає прямий доступ до даних відео.
Примітка
Платформа iOS збудована на основі мови програмування Objective-C. Тому для роботи з фреймворком AVFoundation наш клас також має бути написаним нею. У цій частині всі фрагменти коду запрограмовано мовою Objective-C++.
Щоб інкапсулювати процес захоплення відео, ми створимо інтерфейс VideoSource як показано нижче:
@protocol VideoSourceDelegate<NSObject>
-(void)frameReady:(BGRAVideoFrame) frame;
@end
@interface VideoSource :
NSObject<AVCaptureVideoDataOutputSampleBufferDelegate>
{
}
@property (nonatomic, retain) AVCaptureSession *captureSession;
@property (nonatomic, retain) AVCaptureDeviceInput *deviceInput;
@property (nonatomic, retain) id<VideoSourceDelegate> delegate;
- (bool) startWithDevicePosition:
(AVCaptureDevicePosition)devicePosition;
- (CameraCalibration) getCalibration;
- (CGSize) getFrameSize;
@end
У цій функції зворотного виклику ми замикаємо буфер зображення, щоб запобігти модифікаціям новими кадрами, отримуємо вказівник на дані зображення та його розміри. Далі створюємо тимчасовий об'єкт BGRAVideoFrame, що передається назовні за допомогою особливого делегата з таким прототипом:
@protocol VideoSourceDelegate<NSObject>
-(void)frameReady:(BGRAVideoFrame) frame;
@end
У межах VideoSourceDelegate інтерфейс VideoSource інформує код користувача про прибуття нового кадра.
Покроковий процес налаштування відеопотоку виглядає так:
1. Створюємо екземпляр AVCaptureSession та встановлюємо якість сесії захоплення.
2. Визначаємо та формуємо AVCaptureDevice. Можна зупинитися на передній, задній камері або обрану за замовчуванням.
3. Налаштовуємо AVCaptureDeviceInput з врахуванням створеного пристрою та додаємо його до сесії захоплення.
4. Створюємо екземпляр AVCaptureVideoDataOutput та налаштовуємо його форматом відеокадру, делегатом зворотного виклику, відправляємо чергу.
5. Додаємо вивід в об'єкт сесії.
6. Починаємо сесію захоплення.
Пояснимо деякі з кроків більш детально. Після створення сесії захоплення можна визначити бажані налаштування якості задля впевненості, що ми отримаємо оптимальну продуктивність. Оскільки оброблювати відео HD якості не потрібно, роздільної якості 640х480 чи навіть нижче достатньо:
- (id)init
{
if ((self = [super init]))
{
AVCaptureSession * capSession = [[AVCaptureSession alloc] init];
if ([capSession
canSetSessionPreset:AVCaptureSessionPreset640x480])
{
[capSession setSessionPreset:AVCaptureSessionPreset640x480];
NSLog(@"Встановлюємо режим сесії
AVCaptureSessionPreset640x480");
}
else if ([capSession
canSetSessionPreset:AVCaptureSessionPresetLow])
{
[capSession setSessionPreset:AVCaptureSessionPresetLow];
NSLog(@"Встановлюємо режим сесії AVCaptureSessionPresetLow");
}
self.captureSession = capSession;
}
return self;
}
Примітка
Завжди перевіряйте можливості цільової машини, використовуючи належний API; не існує гарантії, що кожна камера зможе встановити певний режим сесії.
Після створення сесії захоплення маємо додати об'єкт входу - екземпляр AVCaptureDeviceInput взаємодіятиме з фізичною камерою. Допоміжна функція cameraWithPosition повертає пристрій із вказаною позицією (передня, задня чи за замовчуванням):
- (bool) startWithDevicePosition:
(AVCaptureDevicePosition)devicePosition
{
AVCaptureDevice *videoDevice = [self
cameraWithPosition:devicePosition];
if (!videoDevice)
return FALSE;
{
NSError *error;
AVCaptureDeviceInput *videoIn = [AVCaptureDeviceInput
deviceInputWithDevice:videoDevice error:&error];
self.deviceInput = videoIn;
if (!error)
{
if ([[self captureSession] canAddInput:videoIn])
{
[[self captureSession] addInput:videoIn];
}
else
{
NSLog(@"Не вдалося додати вхід відео");
return FALSE;
}
}
else
{
NSLog(@"Не вдалося створити вхід відео");
return FALSE;
}
}
[self addRawViewOutput];
[captureSession startRunning];
return TRUE;
}
Зверніть увагу на код обробки помилок. Для настільки важливих процесів, як налаштування апаратного забезпечення, повернення продуманих значень є хорошою практикою. Без цього виконання коду може неочікувано перерватися, не оповістивши користувача про причини.
Ми створили сесію захоплення та додали джерело відео. Настав час приєднати об'єкт, що отримуватиме власне дані зображень. Для обробки нестиснутих кадрів відеопотоку використовується клас AVCaptureVideoDataOutput. Камера здатна забезпечувати зображеннями у форматі BGRA, CMYK чи простій моделі у відтінках сірого. Для наших цілей найкраще підходить BGRA, оскільки цей варіант дозволяє легко показувати та оброблювати кадри. Наступний код належить функції addRawViewOutput:
- (void) addRawViewOutput
{
/*Налаштовуємо вивід*/
AVCaptureVideoDataOutput *captureOutput = [[AVCaptureVideoDataOutput
alloc] init];
/*Поки кадр все ще обробляється в методах делегатів -
captureOutput:didOutputSampleBuffer:fromConnection:,
жодні інші зображення не додаються в чергу.
Якщо така поведінка не потрібна, встановіть значення на NO */
captureOutput.alwaysDiscardsLateVideoFrames = YES;
/*Створюємо послідовну чергу для обробки кадрів*/
dispatch_queue_t queue;
queue =
dispatch_queue_create("com.Example_MarkerBasedAR.cameraQueue",
NULL);
[captureOutput setSampleBufferDelegate:self queue:queue];
dispatch_release(queue);
// Встановлюємо зберігання кадрів у форматі BGRA (це має підвищити
швидкість)
NSString* key = (NSString*)kCVPixelBufferPixelFormatTypeKey;
NSNumber* value = [NSNumber
numberWithUnsignedInt:kCVPixelFormatType_32BGRA];
NSDictionary* videoSettings = [NSDictionary
dictionaryWithObject:value
forKey:key];
[captureOutput setVideoSettings:videoSettings];
// Реєструємо вивід
[self.captureSession addOutput:captureOutput];
}
Сесія захоплення нарешті налаштована. Після запуску вона отримуватиме кадри камери та відправлятиме їх у код користувача. Коли маємо нове доступне зображення, об'єкт AVCaptureSession активує функцію зворотного виклику captureOutput: didOutputSampleBuffer:fromConnection. Тут здійснюємо незначні операції над виглядом даних, щоб отримати більш зручний формат, та передаємо їх далі.
- (void)captureOutput:(AVCaptureOutput *)captureOutput
didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
fromConnection:(AVCaptureConnection *)connection
{
// Отримуємо буфер, що зберігає кадри відео
CVImageBufferRef imageBuffer =
CMSampleBufferGetImageBuffer(sampleBuffer);
// Блокуємо буфер зображень
CVPixelBufferLockBaseAddress(imageBuffer,0);
// Збираємо інформацію про кадр
uint8_t *baseAddress = (uint8_t
*)CVPixelBufferGetBaseAddress(imageBuffer);
size_t width = CVPixelBufferGetWidth(imageBuffer);
size_t height = CVPixelBufferGetHeight(imageBuffer);
size_t stride = CVPixelBufferGetBytesPerRow(imageBuffer);
BGRAVideoFrame frame = {width, height, stride, baseAddress};
[delegate frameReady:frame];
/*Звільняємо буфер кадрів*/
CVPixelBufferUnlockBaseAddress(imageBuffer,0);
}
Ми отримали посилання на буфер зображень, що зберігає дані кадру. Потім здійснили блокування, аби нові зображення не модифікували його. Відтепер маємо виключний доступ до об'єкта. Використовуючи програмний інтерфейс CoreVideo, отримуємо розміри кадру, протяжність (кількість пікселів у рядку) та вказівник на початок даних.
Примітка
Зверніть увагу на пару функцій CVPixelBufferLockBaseAddress/CVPixelBufferUnlockBaseAddress в коді зворотного виклику. Поки буфер пікселів заблоковано, гарантується цілісність та коректність його даних. Зчитування можливе лише після виклику функції блокування. По завершенню роботи не забудьте звільнити буфер, щоб оперативна система змогла заповнити його новими даними.
Розпізнавання маркера
У більшості випадків маркер виглядає як прямокутне зображення з чорними та білими зонами всередині. Зважаючи на відомі обмеження, процедура розпізнавання є досить простою. Перш за все, необхідно знайти закриті контури вхідного кадру, розгорнути внутрішнє зображення у прямокутник та порівняти його з моделлю маркера.
Для цього прикладу використаємо маркер з розмірами 5х5. Він виглядає так:
У книзі описано проєкт з процедурою розпізнавання маркера, що інкапсульована в класі MarkerDetector:
/**
* Високорівневий клас, що інкапсулює алгоритм розпізнавання маркера
*/
class MarkerDetector
{
public:
/**
* Налаштовуємо новий екземпляр розпізнавача
* @calibration[in] - Калібрування камери необхідне для оцінки
пози.
*/
MarkerDetector(CameraCalibration calibration);
void processFrame(const BGRAVideoFrame& frame);
const std::vector<Transformation>& getTransformations() const;
protected:
bool findMarkers(const BGRAVideoFrame& frame, std::vector<Marker>&
detectedMarkers);
void prepareImage(const cv::Mat& bgraMat,
cv::Mat& grayscale);
void performThreshold(const cv::Mat& grayscale,
cv::Mat& thresholdImg);
void findContours(const cv::Mat& thresholdImg,
std::vector<std::vector<cv::Point> >& contours,
int minContourPointsAllowed);
void findMarkerCandidates(const std::vector<std::vector<cv::Point>
>&
contours, std::vector<Marker>& detectedMarkers);
void detectMarkers(const cv::Mat& grayscale,
std::vector<Marker>& detectedMarkers);
void estimatePosition(std::vector<Marker>& detectedMarkers);
private:
};
Щоб ви краще зрозуміли процедуру розпізнавання маркера, наведемо покроковий алгоритм обробки одного кадра відео. Як приклад буде використано вхідне зображення, зняте камерою iPad:
Ідентифікація маркера
Ось робочий потік процедури розпізнавання маркера:
1. Конвертуємо вхідне зображення у відтінки сірого.
2. Здійснюємо операцію бінарного порогу.
3. Знаходимо контури.
4. Обираємо можливі маркери.
5. Розпізнаємо їх та декодуємо.
6. Оцінюємо тривимірну позу маркера.
Конвертація у відтінки сірого
Перетворення у відтінки сірого важливе, адже в більшості випадків маркери складаються з чорних та білих блоків, а ними зручніше оперувати на конвертованих кадрах. На щастя, зміна простору кольорів в OpenCV доволі проста.
Погляньте на наступний фрагмент коду C++:
void MarkerDetector::prepareImage(const cv::Mat& bgraMat, cv::Mat&
grayscale)
{
// Конвертуємо у відтінки сірого
cv::cvtColor(bgraMat, grayscale, CV_BGRA2GRAY);
}
Ця функція перетворить вхідне зображення BGRA у відтінки сірого (за необхідності буде виділено буфери кадрів) та використає результат як другий аргумент. Усі подальші кроки здійснимо на конвертованих кадрах.
Бінаризація зображення
Операція бінаризації змінить колір кожного пікселя зображення на чорний (з нульовою інтенсивністю) чи білий (з повною). Цей крок необхідний для пошуку контурів. Існує декілька порогових методів; кожен має сильні та слабкі сторони. Найлегший та найшвидший метод - це абсолютний поріг, у якому результат залежить від інтенсивності поточного пікселя та деякого значення порогу. Якщо інтенсивність пікселя більша за значення порогу, результат буде білим (255); інакше чорним (0).
Цей спосіб має величезний недолік - він залежить від умов освітлення та незначних змін інтенсивності. До кращих методів відносять адаптивний поріг. Головна його відмінність - це використання усіх пікселів в певному радіусі навколо поточного. Використання середньої інтенсивності дає прийнятні результати та дозволяє знаходити більш точні кути.
Наступний фрагмент коду належить функції MarkerDetector:
void MarkerDetector::performThreshold(const cv::Mat& grayscale,
cv::Mat& thresholdImg)
{
cv::adaptiveThreshold(grayscale, // Вхідні дані
thresholdImg,// Бінарне зображення
255, //
cv::ADAPTIVE_THRESH_GAUSSIAN_C, //
cv::THRESH_BINARY_INV, //
7, //
7 //
);
}
Після застосування адаптивного порогу до вхідного зображення, результат стає схожим на наступне:
Кожен маркер у більшості випадків має квадратну форму з чорними та білими зонами всередині. Тому найкращий спосіб визначити маркер полягає в пошуку закритих контурів та їх апроксимації до полігонів з 4 вершинами.
Розпізнавання контурів
Функція cv::findCountours розпізнає контури на вхідному бінарному зображенні:
void MarkerDetector::findContours(const cv::Mat& thresholdImg,
std::vector<std::vector<cv::Point>
>& contours,
int minContourPointsAllowed)
{
std::vector< std::vector<cv::Point> > allContours;
cv::findContours(thresholdImg, allContours, CV_RETR_LIST,
CV_CHAIN_APPROX_NONE);
contours.clear();
for (size_t i=0; i<allContours.size(); i++)
{
int contourSize = allContours[i].size();
if (contourSize > minContourPointsAllowed)
{
contours.push_back(allContours[i]);
}
}
}
Ця функція поверне список полігонів, кожен з яких представляє єдиний контур. Вона пропускає ті з них, кількість точок яких менша за значення змінної minContourPointsAllowed. Це так, бо ми не зацікавлені в малих контурах. (Скоріше за все, вони не включатимуть маркери або контур не вдасться розпізнати через малі розміри.) Наступний малюнок показує етап візуалізації знайдених контурів:
Пошук кандидатів
Після знаходження контурів виконується етап апроксимації полігону. Це робиться для зниження кількості точок, що описують форму контуру. Це також непоганий контроль якості, що дозволяє відфільтрувати зони без маркерів, адже останні завжди можна представити у вигляді полігонів з чотирма вершинами. Якщо апроксимований полігон має більше або менше ніж чотири вершини, вочевидь, нам він непотрібний. Наступний код реалізує цю ідею:
void MarkerDetector::findCandidates
(
const ContoursVector& contours,
const ContoursVector& contours,
std::vector<Marker>& detectedMarkers
)
{
std::vector<cv::Point> approxCurve;
std::vector<Marker> possibleMarkers;
// Аналізуємо кожен контур, чи
// схожий він на паралелепіпед,
// яким може виявитися маркер
for (size_t i=0; i<contours.size(); i++)
{
// Апроксимуємо до полігону
double eps = contours[i].size() * 0.05;
cv::approxPolyDP(contours[i], approxCurve, eps, true);
// Ми зацікавлені лише в полігонах, що складаються з 4 точок
if (approxCurve.size() != 4)
continue;
// Крім цього, вони мають бути замкненими
if (!cv::isContourConvex(approxCurve))
continue;
// Впевнюємося, що відстань між послідовними точками достатньо велика
float minDist = std::numeric_limits<float>::max();
for (int i = 0; i < 4; i++)
{
cv::Point side = approxCurve[i] - approxCurve[(i+1)%4];
float squaredSideLength = side.dot(side);
minDist = std::min(minDist, squaredSideLength);
}
// Перевіряємо, що відстань не надто мала
if (minDist < m_minContourLengthAllowed)
continue;
// Всі тести пройдено. Збережемо кандидата на маркер:
Marker m;
for (int i = 0; i<4; i++)
m.points.push_back(
cv::Point2f(approxCurve[i].x, approxCurve[i].y) );
// Сортуємо точки в порядку проти годинникової стрілки
// Проводимо лінію між першою та другою точками.
// Якщо третя знаходиться з правого боку, точки
// розміщені проти годинникової стрілки
cv::Point v1 = m.points[1] - m.points[0];
cv::Point v2 = m.points[2] - m.points[0];
double o = (v1.x * v2.y) - (v1.y * v2.x);
if (o < 0.0) // якщо третя точка з лівого боку,
// тоді здійснюємо сортування у порядку
// проти годинникової стрілки
std::swap(m.points[1], m.points[3]);
std::swap(m.points[1], m.points[3]);
possibleMarkers.push_back(m);
}
// Видаляємо елементи, чиї кути знаходяться надто близько одна до одної.
// Спершу знайдемо кандидатів на видалення:
std::vector< std::pair<int, int> > tooNearCandidates;
for (size_t i=0;i<possibleMarkers.size();i++)
{
const Marker& m1 = possibleMarkers[i];
// вирахуємо середню відстань від кожного кута до
// найближчого кута іншого кандидата в маркери
for (size_t j=i+1;j<possibleMarkers.size();j++)
{
const Marker& m2 = possibleMarkers[j];
float distSquared = 0;
for (int c = 0; c < 4; c++)
{
cv::Point v = m1.points[c] - m2.points[c];
distSquared += v.dot(v);
}
distSquared /= 4;
if (distSquared < 100)
{
tooNearCandidates.push_back(std::pair<int,int>(i,j));
}
}
}
// Помітимо на видалення елемент пари з меншим периметром
std::vector<bool> removalMask (possibleMarkers.size(), false);
for (size_t i=0; i<tooNearCandidates.size(); i++)
{
float p1 =
perimeter(possibleMarkers[tooNearCandidates[i].first
].points);
float p2 =
perimeter(possibleMarkers[tooNearCandidates[i].second].points);
size_t removalIndex;
if (p1 > p2)
removalIndex = tooNearCandidates[i].second;
else
removalIndex = tooNearCandidates[i].first;
removalMask[removalIndex] = true;
}
// Повертаємо кандидатів
detectedMarkers.clear();
for (size_t i=0;i<possibleMarkers.size();i++)
for (size_t i=0;i<possibleMarkers.size();i++)
{
if (!removalMask[i])
detectedMarkers.push_back(possibleMarkers[i]);
}
}
Відтепер маємо список паралелепіпедів, які з певною мірою вірогідності представлятимуть маркери. Щоб підтвердити приналежність, здійснюємо три кроки:
1. По-перше, видаляємо проєкцію перспективи, щоб отримати фронтальний вид прямокутної зони.
2. Потім виконуємо процедуру порогу на зображенні з використанням алгоритму Отсу, що припускає використання бімодального розподілу та знаходить порогове значення, яке максимізує дисперсію для зовнішнього класу та зберігає для внутрішнього.
3. Нарешті здійснюємо ідентифікацію кода маркера. Якщо це саме він, внутрішній код має існувати. Маркер розділено на сітку блоків розмірами 7х7, з яких внутрішні 5х5 зберігають інформацію про ID. Решта відповідає зовнішній чорній границі. На цьому етапі ми спочатку перевіряємо, чи присутня вона взагалі. Потім зчитуємо внутрішні 5х5 блоків та перевіряємо, чи формують вони дійсний код. (Може знадобитися перевернути зображення, щоб отримати дійсний код.)
Щоб отримати прямокутний маркер, потрібно розгорнути вхідне зображення з використанням перспективних перетворень. Необхідну матрицю можна вирахувати за допомогою функції cv::getPerspectiveTransform. Вона знаходить перетворення для 4 пар відповідних точок. Перший аргумент - це координати маркера у просторі зображення, а другий відповідає координатам квадратного фрагмента маркера. Знайдені перетворення нададуть маркеру квадратної форми та дозволять його проаналізувати:
cv::Mat canonicalMarker;
Marker& marker = detectedMarkers[i];
// Знаходимо перетворення перспективи, що нададуть поточному маркеру
// прямокутної форми
cv::Mat M = cv::getPerspectiveTransform(marker.points,
m_markerCorners2d);
// Трансформуємо зображення, щоб отримати канонічне зображення маркера
cv::warpPerspective(grayscale, canonicalMarker, M, markerSize);
Розгортання зображення надає йому прямокутної форми за допомогою перетворень перспективи:
Приступимо до перевірки зображення на приналежність до дійсних маркерів. Потім спробуємо здобути бітову маску з кодом маркера. Оскільки ми очікуємо, що маркер включає лише чорні та білі кольори, застосуємо поріг Отсу, щоб видалити сірі пікселі та залишити решту:
// зображення порогу
cv::threshold(markerImage, markerImage, 125, 255, cv::THRESH_BINARY |
cv::THRESH_OTSU);
Розпізнавання кода маркера
Кожен маркер має внутрішній код, описаний 5 словами по 5 бітів в кожному. Застосований шифр є дещо зміненим кодом Хеммінга. В загальному кожне слово має 2 біти корисної інформації з 5 використаних. Решта 3 потрібні для розпізнавання помилок. Як результат, можна отримати до 1024 різних ідентифікаторів.
Головна відмінність від базового коду - це те, що перший біт (паритет 3 та 5) інвертовано. Тому ідентифікатор 0 (що описується 5 нулями) стає 10000 в нашому коді. Завдяки цьому можна запобігти явищу, коли повністю чорний маркер виявляється дійсним ідентифікатором. Це необхідно, щоб зменшити кількість хибних появ серед об'єктів середовища.
Підрахунок кількості чорних та білих пікселів для кожного блока дає маску розмірності 5х5 з кодом маркера. Щоб знайти число ненульових пікселів на певному зображенні, застосовано функцію cv::countNonZero. Вона рахує ненульові елементи заданого одно- чи двовимірного масиву. Клас cv::Mat здатний повертати новий екземпляр, що включає частину початкового зображення. Наприклад, якщо існує cv::Mat з розмірами 400х400, наступний фрагмент коду створить підматрицю для блоку зображення з координатами (:10,10) та розмірами 50х50:
cv::Mat src(400,400,CV_8UC1);
cv::Rect r(10,10,50,50);
cv::Mat subView = src(r);
Зчитування коду маркера
Використовуючи цю техніку, можна знайти блоки на поверхні маркера:
cv::Mat bitMatrix = cv::Mat::zeros(5,5,CV_8UC1);
// отримуємо інформацію (для кожного внутрішнього квадрата, визначаємо, чорний він чи білий)
for (int y=0;y<5;y++)
{
for (int x=0;x<5;x++)
{
int cellX = (x+1)*cellSize;
int cellY = (y+1)*cellSize;
cv::Mat cell = grey(cv::Rect(cellX,cellY,cellSize,cellSize));
int nZ = cv::countNonZero(cell);
if (nZ> (cellSize*cellSize) /2)
bitMatrix.at<uchar>(y,x) = 1;
}
}
Зверніть увагу на наступний малюнок. Той же маркер може мати чотири можливі представлення залежно від точки зору камери:
Оскільки існує 4 можливі орієнтації малюнку маркера, потрібно знайти коректну позицію маркера. Пам'ятайте, для кожних 2 бітів інформації доступні 3 для перевірки. Завдяки їм можна знайти відстань Хеммінга для кожної можливої орієнтації маркера. Правильна позиція матиме нульову похибку відстані, а інші варіанти повороту - ні.
Далі розміщено код, що повертає бітову матрицю 4 рази та знаходить вірну орієнтацію маркера:
// перевіряємо всі можливі повороти
cv::Mat rotations[4];
int distances[4];
rotations[0] = bitMatrix;
distances[0] = hammDistMarker(rotations[0]);
std::pair<int,int> minDist(distances[0],0);
for (int i=1; i<4; i++)
{
// отримуємо відстань Хеммінга до найближчого можливого слова
rotations[i] = rotate(rotations[i-1]);
distances[i] = hammDistMarker(rotations[i]);
if (distances[i] < minDist.first)
{
minDist.first = distances[i];
minDist.second = i;
}
}
Цей код знаходить орієнтацію бітової матриці таким чином, щоб мати мінімальну похибку для вимірів відстані Хеммінга. Вона має бути нульовою для коректного ідентифікатора маркера; якщо це не так, ми маємо невірний шаблон маркера (зіпсоване зображення чи розпізнавання хибних появ).
Обробка місцеположення маркера
Визначивши вірну орієнтацію маркера, повертаємо його кути так, щоб забезпечити правильний порядок:
// сортуємо точки, щоб їхній порядок
// не залежав від орієнтації камери
std::rotate(marker.points.begin(), marker.points.begin() + 4 -
nRotations,
marker.points.end());
Після розпізнавання маркера та декодування ідентифікатора, обробимо його кути. Ця операція знадобиться, коли ми оцінюватимемо положення маркера у тривимірному просторі. Щоб знайти положення точки з субпіксельною точністю, використаємо функцію cv::cornerSubPix:
std::vector<cv::Point2f> preciseCorners(4 * goodMarkers.size());
for (size_t i=0; i<goodMarkers.size(); i++)
{
Marker& marker = goodMarkers[i];
for (int c=0;c<4;c++)
{
preciseCorners[i*4+c] = marker.points[c];
}
}
cv::cornerSubPix(grayscale, preciseCorners, cvSize(5,5), cvSize(-1,-
1), cvTermCriteria(CV_TERMCRIT_ITER,30,0.1));
//copy back
for (size_t i=0;i<goodMarkers.size();i++)
{
Marker&marker = goodMarkers[i];
for (int c=0;c<4;c++)
{
marker.points[c] = preciseCorners[i*4+c];
}
}
Спершу підготуємо вхідні дані. Копіюємо список вершин у масив, після чого викликаємо функцію cv::cornerSubPix, передаємо поточне зображення, набір точок, а також встановлюємо групу параметрів, що впливають на якість та продуктивність обробки позицій. По закінченню процесу передаємо оброблені дані назад до кутів маркерів, як показано на наступному малюнку.
Ми не використовуємо функцію cornerSubPix на ранніх етапах через її складність. Це надто дорога операція, якщо на вхід подати велику кількість точок (говоримо про необхідний для обчислень час). Тому проводимо її лише для дійсних маркерів.
Розміщуємо маркер у тривимірному просторі
Доповнена реальність намагається об'єднати дійсний об'єкт з віртуальним контентом. Щоб помістити 3D-модель на сцені, потрібно знати її позу відповідно до камери, використаної для захоплення відеокадрів. Скористаємось евклідовими перетвореннями в прямокутній системі координат, щоб представити її. Позиція маркера в тривимірному та відповідна проєкція у двовимірному просторі обмежена наступним рівнянням:
P = A * [R|T] * M,
де:
M позначає точку в 3D просторі,
[R|T] стосується матриці [3|4] евклідових перетворень,
A описує матрицю камери або матрицю внутрішніх параметрів,
P відповідає проєкції M в екранних координатах.
Виконавши крок розпізнавання маркера, ми отримаємо чотири кути маркера у двовимірному просторі (проєкція в екранних координатах). У наступній частині ви дізнаєтесь, як знайти матрицю A та вектор параметрів M, а також як обрахувати перетворення [R|T].
Калібрування камери
Кожна лінза камери має унікальний набір характеристик, серед яких фокусна відстань, кардинальна точка та модель викривлень. Пошук внутрішніх параметрів камери називають калібруванням. Цей процес важливий для застосунків з доповненою реальністю, адже описує перетворення перспективи та викривлення лінзи на вихідному зображенні. Щоб досягнути кращого досвіду користувача, візуалізація доповненого об'єкта має відбутися з тою ж перспективною проєкцією.
Для калібрування знадобиться особливе зображення шаблону (шахова дошка чи чорні кола на білому фоні). Камера, над якою здійснюють цей процес, робить 10-15 знімків шаблону з різних точок зору. Потім алгоритм калібрування знаходить оптимальні внутрішні параметри та вектор викривлень:
Для опису калібрування камери використаємо клас CameraCalibration:
/**
* Клас калібрування, що зберігає внутрішню матрицю та
коефіцієнти викривлення.
*/
class CameraCalibration
{
public:
CameraCalibration();
CameraCalibration(float fx, float fy, float cx, float cy);
CameraCalibration(float fx, float fy, float cx, float cy, float
distorsionCoeff[4]);
void getMatrix34(float cparam[3][4]) const;
const Matrix33& getIntrinsic() const;
const Vector4& getDistorsion() const;
private:
Matrix33 m_intrinsic;
Vector4 m_distorsion;
};
Детальне пояснення процедури калібрування лежить за межами цього розділу. Зверніть увагу на приклад camera_calibration чи статтю "OpenCV: оцінка відношень проєкції на зображеннях" за адресою http://www.packtpub.com/article/opencv-estimating-projective-relations-images, щоб знайти додаткову інформацію чи код.
Що ж до цього прикладу, подамо внутрішні параметри для всіх сучасних пристроїв iOS (iPad2, iPad3 та iPhone 4).
Оцінка пози маркера
Маючи точне положення кутів, можна вирахувати перетворення між камерою та маркером у тривимірному просторі. Таку операцію називають оцінкою пози з урахуванням відношень 2D-3D. Даний процес дозволяє знайти евклідові перетворення (що складаються лише з компонентів повороту та перенесення) між камерою та об'єктом.
Поглянемо на наступний малюнок:
Точка С призначена позначати центр камери. P1-P4 - це тривимірні точки у світовій координатній системі, а p1-p4 - їхні проєкції на площину зображення. Ми маємо знайти відносні перетворення між відомою позицією маркера в 3D і камерою C, що використовує матрицю внутрішнього стану, та відомими проєкціями на поверхні зображення. Але ж як ми знайдемо координати позиції маркера у тривимірному просторі? Уявимо. Маркер завжди має квадратну форму, а вершини лежать в одній площині, тому кути можна визначити так:
Помістимо маркер на площину XY (компонента Z рівна нулю), причому центр маркера відповідатиме точці (0.0, 0.0, 0.0). Це необхідний крок, адже в такому випадку координатна система матиме початок в центрі маркера (вісь Z перпендикулярна площині маркера).
Щоб знайти положення камери за відомими відношеннями 2D-3D, можна використати функцію cv::solvePnP:
void solvePnP(const Mat& objectPoints, const Mat& imagePoints, const
Mat&
cameraMatrix, const Mat& distCoeffs, Mat& rvec, Mat& tvec, bool
useExtrinsicGuess=false);
Параметр objectPoints - це вхідний масив точок об'єкта у локальній площині координат. Це може бути, наприклад, std::vector<cv::Point3f>. Матрицю OpenCV з розмірами 3xN чи Nx3, де N - це кількість точок, також можна передати як вхідний аргумент. Тут ми передаємо список координат маркера в тривимірному просторі (вектор 4 точок).
Об'єкт imagePoints - це масив відповідних точок зображення (чи проєкцій). Також аргумент може мати тип std::vector<cv::Point2f> чи cv::Mat з розмірами 2xN чи Nx2, де N - це кількість точок.
Сюди передаємо список знайдених кутів маркера.
cameraMatrix - це матриця 3x3 внутрішнього стану камери.
distCoeffs - вхідний вектор викривлень (k1, k2, p1, p2, [k3]) розмірності 4x1, 1x4, 5x1 чи 1x5; якщо він набуває значення NULL, всі коефіцієнти дорівнюють 0.
rvec - вихідний вектор поворотів, що разом з tvec переносять точки з локальних координат у систему координат камери.
tvec - вихідний вектор перенесень.
useExtrinsicGuess: якщо рівна true, функція використає вектори rvec та tvec як початкові наближення для повороту та перенесення та пізніше оптимізує їх.
Функція знаходить перетворення камери у спосіб, що мінімізує похибку повторної проєкції, тобто суму квадратів відстаней між спостереженими точками imagePoints та проєктованими objectPoints.
Вирахувані перетворення визначені поворотом (rvec) та перенесенням (tvec). Вони також відомі як евклідові або ортогональні.
Евклідові перетворення формально визначено як такі, що перетворюють будь-який вектор v на інший, T(v), що має форму:
T(v) = Rv + t,
де RT = R-1 (тобто R є ортогональним перетворенням), а t - це вектор, що визначає перенесення початку координат. Крім того, коректне евклідове перетворення має
det(R) = 1.
Це значить, що R не формує віддзеркалень і, таким чином, визначає поворот (ортогональне перетворення, що зберігає орієнтацію). Для пошуку матриці розмірності 3x3 з вектора застосовано функцію cv::Rodrigues. Вона конвертує поворот, представлений вектором, та повертає еквівалентну матрицю.
Примітка
Через те, що функція cv::solvePnP шукає позицію камери відносно пози маркера у тривимірному просторі, ми маємо інвертувати знайдені перетворення. Результат опише перетворення у системі координат камери, що значно спрощує подальший процес візуалізації.
Це лістинг функції estimatePosition, яка шукає позицію виявлених маркерів:
void MarkerDetector::estimatePosition(std::vector<Marker>&
detectedMarkers)
{
for (size_t i=0; i<detectedMarkers.size(); i++)
{
Marker& m = detectedMarkers[i];
cv::Mat Rvec;
cv::Mat_<float> Tvec;
cv::Mat raux,taux;
cv::solvePnP(m_markerCorners3d, m.points, camMatrix,
distCoeff,raux,taux);
raux.convertTo(Rvec,CV_32F);
taux.convertTo(Tvec ,CV_32F);
cv::Mat_<float> rotMat(3,3);
cv::Rodrigues(Rvec, rotMat);
// Копіюємо в матрицю перетворень
m.transformation = Transformation();
for (int col=0; col<3; col++)
{
for (int row=0; row<3; row++)
{
// Копіюємо компоненту повороту
m.transformation.r().mat[row][col] = rotMat(row,col);
}
// Копіюємо компоненту перенесення
m.transformation.t().data[col] = Tvec(col);
}
// Оскільки solvePnP шукає положення камери відносно пози маркера,
// щоб отримати позу маркера відносно камери, інвертуємо її.
m.transformation = m.transformation.getInverted();
}
Візуалізуємо тривимірний віртуальний об'єкт
Дотепер ми дізнались, яким чином шукати маркери на зображенні, щоб визначити їхню точну позицію в просторі відносно камери. Настав час дещо намалювати. Як вже зазначено, для цього ми використаємо функції OpenGL. 3D візуалізація - це наріжний камінь доповненої реальності. Бібліотека OpenGL пропонує базові можливості для створення високоякісної графіки.
Примітка
Існує велика кількість комерційних та відкритих 3D-двигунів (Unity, Unreal Engine, Ogre і так далі). Але усі вони використовують OpenGL або DirectX, щоб передавати команди відеокарті. DirectX - це власний програмний інтерфейс Microsoft та підтримується лише на платформі Windows. Саме тому OpenGL - єдиний кандидат для програмування кросплатформових систем візуалізації.
Розуміння принципів систем візуалізації наділить вас необхідним досвідом та знаннями, необхідними для використання цих двигунів чи створення власних.
Створюємо рівень візуалізації OpenGL
Щоб використати функції OpenGL у своєму застосунку, необхідно отримати контекстну поверхню графіки iOS, яка презентуватиме візуалізовану сцену. Частіше за все, контекст прив'язано до виду, який спостерігає користувач. Наступний скріншот показує ієрархію інтерфейсу програми в інструменті XCode для їх побудови:
Інкапсулюємо логіку налаштування контексту OpenGL у класі EAGLView:
@class EAGLContext;
// Цей клас надбудовує CAEAGLLayer з CoreAnimation у
зручному підкласі UIView.
// Контент виду - це в основному поверхня EAGL, на якій зображено
сцену OpenGL.
// Врахуйте, що налаштування прозорості виду спрацює лише за умови,
що поверхня EAGL має альфа-канал.
@interface EAGLView : UIView
{
@private
// Для візуалізації у вид використано імена
OpenGL ES для обох
необхідних буферів.
GLuint defaultFramebuffer, colorRenderbuffer;
}
@property (nonatomic, retain) EAGLContext *context;
// The pixel dimensions of the CAEAGLLayer.
@property (readonly) GLint framebufferWidth;
@property (readonly) GLint framebufferHeight;
- (void)setFramebuffer;
- (BOOL)presentFramebuffer;
- (void)initContext;
@end
Цей клас під'єднується до нашого виду у файлі визначень інтерфейсів, тому, коли завантажується файл NIB, потік виконання створить новий екземпляр класу EAGLView. Він прийматиме події від iOS та налаштує контекст візуалізації OpenGL.
Далі розміщено код функції initWithCoder:
//Вид EAGL зберігається у файлі nib. Його зчитують та відправляють в -initWithCoder:.
- (id)initWithCoder:(NSCoder*)coder
{
self = [super initWithCoder:coder];
if (self) {
CAEAGLLayer *eaglLayer = (CAEAGLLayer *)self.layer;
eaglLayer.opaque = TRUE;
eaglLayer.drawableProperties = [NSDictionary
dictionaryWithObjectsAndKeys:
[NSNumber numberWithBool:FALSE],
kEAGLDrawablePropertyRetainedBacking,
kEAGLColorFormatRGBA8,
kEAGLDrawablePropertyColorFormat,
nil];
[self initContext];
}
return self;
}
- (void)createFramebuffer
{
if (context && !defaultFramebuffer) {
[EAGLContext setCurrentContext:context];
// Створюємо буфер кадрів за замовчуванням.
glGenFramebuffers(1, &defaultFramebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, defaultFramebuffer);
// Створюємо буфер візуалізації та виділяємо місце для збереження.
glGenRenderbuffers(1, &colorRenderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, colorRenderbuffer);
[context renderbufferStorage:GL_RENDERBUFFER fromDrawable:
(CAEAGLLayer *)self.layer];
glGetRenderbufferParameteriv(GL_RENDERBUFFER,
GL_RENDERBUFFER_WIDTH, &framebufferWidth);
glGetRenderbufferParameteriv(GL_RENDERBUFFER,
GL_RENDERBUFFER_HEIGHT, &framebufferHeight);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_RENDERBUFFER, colorRenderbuffer);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) !=
GL_FRAMEBUFFER_COMPLETE)
NSLog(@"Не вдалося створити повний об'єкт буферу кадрів %x",
glCheckFramebufferStatus(GL_FRAMEBUFFER));
//glClearColor(0, 0, 0, 0);
NSLog(@"Буфер кадрів створено");
}
}
Візуалізуємо сцену доповненої реальності
Вочевидь, клас EAGLView не включає методи для візуалізації тривимірних об'єктів та відео. Це робиться на вимогу. Завдання EAGLView - лише забезпечити наявність контексту. Подібне відокремлення відповідальності дозволяє пізніше змінити логіку візуалізації.
Для візуалізації доповненої реальності створимо окремий клас з назвою
VisualizationController:
@interface SimpleVisualizationController :
NSObject<VisualizationController>
{
EAGLView * m_glview;
GLuint m_backgroundTextureId;
std::vector<Transformation> m_transformations;
CameraCalibration m_calibration;
CGSize m_frameSize;
}
-(id) initWithGLView:(EAGLView*)view calibration:(CameraCalibration)
calibration frameSize:(CGSize) size;
-(void) drawFrame;
-(void) updateBackground:(BGRAVideoFrame) frame;
-(void) setTransformationList:(const std::vector<Transformation>&)
transformations;
Функція drawFrame виконує візуалізацію доповненої реальності на заданий цільовий вид EAGLView. Він включає наступні кроки:
1. Очищує сцену.
2. Налаштовує ортографічну проєкцію для зображення фону.
3. Малює останній отриманий з камери кадр в області виду.
4. Налаштовує перспективну проєкцію із врахуванням внутрішніх параметрів камери.
5. Для кожного визначеного маркера переміщує систему координат у тривимірну позицію маркера (передає матрицю перетворень розмірності 4x4 як матрицю моделі-виду OpenGL).
6. Візуалізує власне тривимірний об'єкт.
7. Показує буфер кадру.
Функцію drawFrame викликаємо, коли кадр повністю готовий для малювання. Це відбувається, коли зображення з камери вже надійшло до відеопам'яті, а етап розпізнавання маркера завершено. Наступний код належить цій функції:
- (void)drawFrame
{
// Встановлюємо активний буфер кадру
[m_glview setFramebuffer];
// Малюємо відео на фоні
[self drawBackground];
// Малюємо 3D-об'єкти в позиції знайдених маркерів
[self drawAR];
// Презентуємо буфер кадру
bool ok = [m_glview presentFramebuffer];
int glErCode = glGetError();
if (!ok || glErCode != GL_NO_ERROR)
{
std::cerr << "Виявлено помилку GL. Код останньої:" << glErCode <<
std::endl;
}
}
Намалювати фон доволі легко; ми встановлюємо ортографічну проєкцію та виводимо повноекранну текстуру, створену із зображення поточного кадра. Ось лістинг коду, що використовує GLES 1 API з цією метою:
- (void) drawBackground
{
GLfloat w = m_glview.bounds.size.width;
GLfloat h = m_glview.bounds.size.height;
const GLfloat squareVertices[] =
{
0, 0,
w, 0,
0, h,
w, h
};
static const GLfloat textureVertices[] =
{
1, 0,
1, 1,
0, 0,
0, 1
};
static const GLfloat proj[] =
{
0, -2.f/w, 0, 0,
-2.f/h, 0, 0, 0,
0, 0, 1, 0,
1, 1, 0, 1
};
glMatrixMode(GL_PROJECTION);
glLoadMatrixf(proj);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
glDisable(GL_COLOR_MATERIAL);
glEnable(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D, m_backgroundTextureId);
// Оновлюємо значення атрибутів.
glVertexPointer(2, GL_FLOAT, 0, squareVertices);
glEnableClientState(GL_VERTEX_ARRAY);
glTexCoordPointer(2, GL_FLOAT, 0, textureVertices);
glEnableClientState(GL_TEXTURE_COORD_ARRAY);
glColor4f(1,1,1,1);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glDisableClientState(GL_VERTEX_ARRAY);
glDisableClientState(GL_TEXTURE_COORD_ARRAY);
glDisable(GL_TEXTURE_2D);
}
Візуалізація штучних об'єктів на сцені - доволі специфічна задача. Спершу маємо налаштувати матрицю проєкції OpenGL відносно внутрішньої матриці калібрування камери. Без цього ми отримаємо некоректне перетворення перспективи. А це призведе до неприродного вигляду штучних об'єктів, які, так би мовити, "здійматимуться над поверхнею", а не будуть частиною дійсності. Правильна перспектива життєво важлива для будь-якої програми з доповненою реальністю.
Це фрагмент коду, що формує матрицю проєкції OpenGL з внутрішніх характеристик камери:
- (void)buildProjectionMatrix:(Matrix33)cameraMatrix:
(int)screen_width: (int)screen_height: (Matrix44&) projectionMatrix
{
float near = 0.01; // Ближня відстань відсічення
float far = 100; // Дальня відстань відсічення
// Параметри камери
float f_x = cameraMatrix.data[0]; // Фокусна відстань по осі x
float f_y = cameraMatrix.data[4]; // Фокусна відстань по осі y (звичайно така ж?)
float c_x = cameraMatrix.data[2]; // Координата x кардинальної точки
float c_y = cameraMatrix.data[5]; // Координата у кардинальної точки
projectionMatrix.data[0] = - 2.0 * f_x / screen_width;
projectionMatrix.data[1] = 0.0;
projectionMatrix.data[2] = 0.0;
projectionMatrix.data[3] = 0.0;
projectionMatrix.data[4] = 0.0;
projectionMatrix.data[5] = 2.0 * f_y / screen_height;
projectionMatrix.data[6] = 0.0;
projectionMatrix.data[7] = 0.0;
projectionMatrix.data[8] = 2.0 * c_x / screen_width - 1.0;
projectionMatrix.data[9] = 2.0 * c_y / screen_height - 1.0;
projectionMatrix.data[10] = -( far+near ) / ( far - near );
projectionMatrix.data[11] = -1.0;
projectionMatrix.data[12] = 0.0;
projectionMatrix.data[13] = 0.0;
projectionMatrix.data[14] = -2.0 * far * near / ( far - near );
projectionMatrix.data[15] = 0.0;
}
Під'єднавши останню до потоку роботи OpenGL, намалюємо декілька об'єктів. Кожне перетворення можна описати матрицею розмірності 4x4 та завантажити у внутрішню бібліотеки, моделі-виду. Це перемістить початок координат у позицію маркера у світовій системі.
Наприклад, намалюємо над кожним маркером вісь координат, що покаже його орієнтацію в просторі, та чотирикутник із градієнтною заливкою, який заповнить увесь маркер. Таким чином ми сформуємо візуальний доказ того, що код працює як належить.
Далі знаходиться фрагмент коду з описом функції drawAR:
- (void) drawAR
{
Matrix44 projectionMatrix;
[self
buildProjectionMatrix:m_calibration.getIntrinsic():m_frameSize.width
:m_frameSize.height :projectionMatrix];
glMatrixMode(GL_PROJECTION);
glLoadMatrixf(projectionMatrix.data);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
glEnableClientState(GL_VERTEX_ARRAY);
glEnableClientState(GL_NORMAL_ARRAY);
glPushMatrix();
glLineWidth(3.0f);
float lineX[] = {0,0,0,1,0,0};
float lineY[] = {0,0,0,0,1,0};
float lineZ[] = {0,0,0,0,0,1};
const GLfloat squareVertices[] = {
-0.5f, -0.5f,
0.5f, -0.5f,
-0.5f, 0.5f,
0.5f, 0.5f,
};
const GLubyte squareColors[] = {
255, 255, 0, 255,
0, 255, 255, 255,
0, 0, 0, 0,
255, 0, 255, 255,
};
for (size_t transformationIndex=0;
transformationIndex<m_transformations.size(); transformationIndex++)
{
const Transformation& transformation =
m_transformations[transformationIndex];
Matrix44 glMatrix = transformation.getInverted().getMat44();
glLoadMatrixf(reinterpret_cast<const GLfloat*>
(&glMatrix.data[0]));
// малюємо дані
glVertexPointer(2, GL_FLOAT, 0, squareVertices);
glEnableClientState(GL_VERTEX_ARRAY);
glColorPointer(4, GL_UNSIGNED_BYTE, 0, squareColors);
glEnableClientState(GL_COLOR_ARRAY);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glDisableClientState(GL_COLOR_ARRAY);
float scale = 0.5;
glScalef(scale, scale, scale);
glColor4f(1.0f, 0.0f, 0.0f, 1.0f);
glVertexPointer(3, GL_FLOAT, 0, lineX);
glDrawArrays(GL_LINES, 0, 2);
glColor4f(0.0f, 1.0f, 0.0f, 1.0f);
glVertexPointer(3, GL_FLOAT, 0, lineY);
glDrawArrays(GL_LINES, 0, 2);
glColor4f(0.0f, 0.0f, 1.0f, 1.0f);
glVertexPointer(3, GL_FLOAT, 0, lineZ);
glDrawArrays(GL_LINES, 0, 2);
}
glPopMatrix();
glDisableClientState(GL_VERTEX_ARRAY);
}
}
Запустивши програму, ви отримаєте такий малюнок:
Ми не використовуємо жодного двигуна 3D візуалізації для сцени, однак наявні всі необхідні дані, щоб зробити це власноруч. Тож ми отримали наступне:
Кадр у форматі BGRA з пристрою камери
Коректну матрицю проєкції, що дає вірні перспективні перетворення для візуалізації сцени доповненої реальності
Список знайдених поз маркерів
Ці дані легко використати у будь-якому 3D двигуні та створити власний застосунок з доповненою реальністю, заснованою на маркерах.
Як можна бачити, чотирикутники з градієнтним заповненням та центральні точки розміщуються чітко на маркерах. Це ключова особливість AR - безшовне змішування реальних зображень та штучних об'єктів.
Висновки
У цьому розділі ми навчилися створювати мобільні застосунки доповненої реальності для пристроїв iPhone/iPad, здобули знання про те, як використовувати бібліотеку OpenCV у проєктах XCode, щоб створювати дивовижні застосунки-шедеври. Використання OpenCV дозволяє програмам здійснювати складну обробку зображень на мобільних пристроях з прийнятною продуктивністю.
Також ми дізнались, як здійснювати первинну обробку зображень (перетворення у відтінки сірого та бінаризацію), як знаходити закриті контури та апроксимувати їх до полігонів, як виокремлювати маркери та декодувати їх, як обчислювати позицію маркера в просторі та візуалізувати 3D об'єкти доповненої реальності.
Посилання
ArUco: найменша бібліотека для застосунків доповненої реальності на базі OpenCV
(http://www.uco.es/investiga/grupos/ava/node/26)
OpenCV Camera Calibration and 3D Reconstruction
(http://opencv.itseez.com/modules/calib3d/doc/camera_calibration_and_3d_reconstruction.html)
OpenCV: Estimating Projective Relations in Images (http://www.packtpub.com/article/opencvestimatingprojective-relations-images)
Multiple View Geometry in Computer Vision (second edition), R.I. Hartley and A. Zisserman,
Cambridge University Press, ISBN 0-521-54051-8
Внимание! Этот перевод, возможно, ещё не готов.
Его статус: перевод редактируется
Переведено на Нотабеноиде
http://notabenoid.org/book/81521/499213
Переводчики: Atronax
Настройки
Готово:
