Дополненная реальность на Qt

e2fdedc7a7364291888664455005c33c.png
Сейчас дополненная реальность — это одно из самых интересных направлений. Поэтому я и взялся за ее изучение, а результатом этого стала собственная реализация кроссплатформенной безмаркерной дополненной реальности на Qt. Речь в этой статье пойдет о том, как это было реализовано (или же как это реализовать самому). Под катом можно найти демку и ссылку на проект на гитхабе.
Для работы дополненной реальности не требуется какие-либо маркеры, подойдет любая картинка. Нужно только выполнить инициализацию: направить камеру на точку на картинке, нажать кнопку старт и двинуть камеру вокруг выбранной точки.
Здесь можно скачать демки под Windows и Android (почему-то не работает на windows 10).

О проекте


Проект разделен на три части:
AR — здесь все что связано с дополненной реальностью. Все спрятано в пространство имен AR, ARSystem — главный объект системы, в котором и осуществляются все расчеты.
QScrollEngine — графический движок для Qt. Находится в пространстве имен — QScrollEngine. Есть отдельный проект на гитхабе.
App — собственно здесь описано приложение, использующее систему дополненной реальности и графический движок.
Все, что описано ниже основывается на проектах PTAM и SVO: Fast Semi-Direct Monocular Visual Odometry.Входные данные
Входными данными у нас является видеопоток. Под видеопотоком будем понимать в данном случае последовательность кадров, где информация от кадра к кадру меняется не очень сильно (что позволяет нам определить соответствия между кадрами).
В Qt определенны классы для работы с видеопотоком, но они не работают на мобильных платформах. Но заставить работать можно. Здесь описано как, в этой статье на этом останавливаться не буду.

Общий алгоритм работы


Чаще всего для работы дополненной реальности используются какие-то маркеры помогающие определять положение камеры в пространстве. Это ограничивает ее использования, так как, во-первых, маркеры должны быть постоянно в кадре, а во-вторых, их необходимо сначала подготовить (распечатать). Однако есть альтернатива — техника structure from motion, в которой данные о положении камеры находятся только по перемещению точек изображения по кадрам видеопотока.
Работать сразу со всеми точками изображения сложновато (хотя и вполне возможно (DTAM)), но для работы на мобильных платформах нужно упрощать. Поэтому мы просто будем выделять отдельные «особые» точки на изображении и следить за их перемещениями. Находить «особые» точки можно разными способами. Я использовал FAST. Этот алгоритм имеет недостаток — он находит углы только заданного размера (9, 10 пикселей). Чтобы он находил точки разного масштаба, используется пирамида изображений. Вкратце, пирамида изображений — это набор изображений, где первое изображение (основание) — это исходное изображение, а изображения следующего уровня в два раза меньше. Находя особые точки на разных уровнях пирамиды, находим «особые» точи разного масштаба. А сама пирамида используется также в оптическом потоке для получения траекторий движений наших точек. Прочитать про него можно здесь и здесь.

Итак, у нас есть траектории движения точек и теперь по ним надо как-то определить положение камеры в пространстве. Для этого, как можно понять из приложения, вначале выполняется инициализация по двум кадрам, в которых камера направлена примерно на одну и ту же точку, только под разными углами. В этот момент происходит вычисление положения камеры в этих кадрах и положение «особых» точек. Далее, по известным трехмерным координатам точек можно уже вычислить положения камеры в каждом следующем кадре видеопотока. Для более стабильной работы добавляем новые точки в процесс слежения, составляя карту пространства, которое видит камера.

Преобразование в экранные координаты


Для начала разберемся с тем, как трехмерные координаты «особых» точек переходят в экранные. Для этого используем эту формулу (обозначим ее, как формула 1):
fc613e59761846c98364f2e342b31212.png
world[i] — это «особые» точки в мировых координатах. Чтобы не усложнять себе жизнь, предположим, что эти координаты не меняются на протяжении всего времени. До инициализации эти координаты не известны.
screen[i] — компоненты x и y — это координаты «особой» точки на изображении (их дает нам оптический поток), z — глубина относительно камеры. Все эти координаты уже будут своими на каждом кадре видеопотока.
mProj — матрица проекции размером 3 на 3 и имеет вид 4b368485203a467cb6cd59f219d5097f.png, здесь pf — фокальное расстояние камеры в пикселях, pc — оптический центр камеры также в пикселях (обычно примерно центр изображения). Понятно, что эта матрица должна формироваться под параметры камеры (ее угол обзора).
mWorld — матрица, описывающая трансформацию точек, размером 3 на 4 (т. е. из обычная мировая матрица из которой убрали последнюю строчку (0 0 0 1). В этой матрице заключена информация о перемещении и повороте камеры. И это то, что собственно мы в первую очередь ищем на каждом кадре.
В данном случае не учитывается дисторсия, но будем считать, что она почти не влияет, и ею можно пренебречь.

Мы можем упростить формулу, избавившись от матрицы mProj (в формуле 1):
88abe445e3664bbdbe3f937005e54c26.png
9c28156159d742d0aaa961d0177ae923.png.
Введем 43974773072542df922d44972bc63d18.png, которые считаем заранее. Тогда формула 1 упрощается до c0ac2bdafebc42f5aa14ae224645c568.png (пусть это будет формула 2).

Инициализация


Как уже говорилось, инициализация происходит по двум кадрам. Обозначим их как A и B. Значит у нас появятся матрицы 5c4acd35bef44a2c818cbe4c84893f19.png и 2652f56fd068452aa86156a5cae0c407.png. Точки c[i] на кадрах A и B обозначим как cA[i] и cB[i]. Так как мы сами вольны выбирать начало координат, предположим, что камера в кадре A как раз там и находится, поэтому 5c4acd35bef44a2c818cbe4c84893f19.png — это единичная матрица (только размером 3 на 4). А вот матрицу 2652f56fd068452aa86156a5cae0c407.png придется все-таки вычислять. И сделать это возможно при помощи точек, расположенных на одной плоскости. Для них будет справедлива следующая формула:
6c9f693b85634004a89a28a46655e42e.png, где матрица H — это плоская гомография.
Перепишем выражение таким образом (убрав индекс i для наглядности):

c414d972de3a4828a97ee70e812dce0e.png

А теперь перейдем к такому виду, избавившись от 26423de4b3104cdd8b37fcc6b40fb007.png:

b5a73ed7d85544bf800207719aff7080.png

Представив матрицу H в виде вектора 17fb8a1a206b4304b7d5cb3bcdf0eedd.png, можем представить эти уравнений в матричном виде:

2858771ac4f740bea299b1267689c3b3.png.

Обозначим новую матрицу как M. Получим M * = 0. Все это расписывалось только для одной точки, поэтому в матрице M только 2 строки. Для того, чтобы найти матрицу H', необходимо, чтобы в матрице M было количество строк больше либо равно количеству столбцов. Если имеем только четыре точки, то можно просто добавить еще одну строку из нулей, выйдет матрица размером 9 на 9. Далее при помощи сингулярного разложения находим вектор (само собой он не должен быть нулевым). Вектор  — это, как мы помним, векторное представление матрицы H, так что у нас теперь есть эта матрица.
Но как говорилось выше, все это справедливо только для точек, расположенных на одной плоскости. А какие из них расположены, а какие нет, заранее мы не знаем, но может предположить с помощью метода Ransac таким образом:

  1. В цикле, с заранее заданным количеством итераций (скажем 500) выполняем следующие действия:
    • Случайным образом выбираем четыре пары точек кадров A и B.
    • Находим матрицу H.
    • Считаем сколько точек дают ошибку меньше заданного значения, т. е. пусть 17a012da22a449d8b706e92e30e6f585.png и тогда должно выполняться условие — 7a73817a342742d187104b0d4440e69a.png.

  2. Выбираем H на той итерации, в которой получилось больше всего точек.

Матрицу H можно получить используя функцию из библиотеки OpenCV — cvFindHomography.

Из матрицы H теперь получим матрицу перехода положения от кадра A к кадру B и назовем ее mMotion.
Для начала выполняем сингулярное разложение матрицы H. Получаем три матрицы: 5be704c73390453bb509cb18c34d0d2b.png. Введем заранее некоторые значения:
7249c3fe300944f7a2a8d2569a99570c.png — в итоге должно быть равно ±1;

3055248a73ac4bfead23627f75987954.png

d8d2507eb6ac40be9905914f1740e8fe.png

Массивы (ну или вектора), указывающие нужный знак:
10e7de46c34e419e902c94b4aa7edb4b.png

А дальше уже можем получить 8 возможных вариантов mMotion:
dd713cadbbf94fcca5a3ee40dbede20a.png

3073efbc200d49118ea1e6b6e6808513.png

3de3f8a476084773b8288a9b46b460a5.png;

b1b693fdb44b4fcd95a8c28ee41e4065.png, где R[i] — матрица поворота,
t[i] — вектор смещения.

И матрицы 35da77d9065b49ddbe8d7df931439790.png. Параметр i = 0, …, 7, и соответственно получаем 8 вариантов матрицы mMotion.
Вообщем, мы имеем следующее соотношение: 2652f56fd068452aa86156a5cae0c407.png = mMotion[i] * 5c4acd35bef44a2c818cbe4c84893f19.png, т. к. 5c4acd35bef44a2c818cbe4c84893f19.png — это единичная матрица, то выходит 2652f56fd068452aa86156a5cae0c407.png = mMotion[i].
Осталось выбрать одну матрицу из 8 ми mMotion[i]. Понятно, что если выпустить лучи из точек первого и второго кадра, то они должны пересекаться, причем перед камерой, как в первом, так и во втором случае. Значит, подсчитываем количество точек пересечения перед камерой в первом и во втором кадре используя получившиеся mMotion[i], и отбрасываем варианты, при которых количество точек будет меньшим. Оставив пару матриц в итоге, выбираем ту, которая дает меньше ошибок.
Итак, у нас есть матрицы 5c4acd35bef44a2c818cbe4c84893f19.png и 2652f56fd068452aa86156a5cae0c407.png, теперь зная их можно найти мировые координаты точек по их проекциям.

Вычисление мировых координат точки по нескольким проекциям


Можно было бы воспользоваться методом наименьших квадратов, но на практике у меня лучше работал следующий способ:
Вернемся к формуле 2. Нам необходимо найти точку world, которую обозначим как a. Допустим у нас есть кадры, в которых известны матрицы mWorld (обозначим их как mW[0], mW[1], …) и известны координаты проекций точки a (возьмем сразу с[0], с[1], …).
И тогда имеем такую систему уравнений:
8f0c619d0fc4465cbd880f290bdd9e84.png

Но можно представить их в таком виде, избавившись от bb2fb798a16b426fb0fc32620189be53.png (подобно тому как делали ранее):
d7054309f6ea438bab11a43b9e0e6154.png

7c13433cf104437cb0be8d4bf4051e92.png где s –любое число не равное нулю,
ee8e9bb39bfc4e99b7b95077f52b393e.png — система уравнений в матричном виде. T — известно, f — необходимо вычислить, чтобы найти a.
Решив данное уравнение с помощью сингулярного разложения (также как нашли H'), получим вектор f. И соответственно точка dedc2ba54e004eb68c76d31d6ad307b2.png.

Вычисление положения камеры используя известные мировые координаты точек


Используется итеративный алгоритм. Начальное приближение — предыдущий результат определения. На каждой итерации:

  1. Ведем еще точки 27d9267d2ae14cd2aaa3a8ab4b67c658.png. В идеале точки c[i] должны быть равны точкам b[i], на так как текущее приближение mWorld — это лишь пока приближение (плюс еще другие ошибки вычислений), они будут различаться. Подсчитаем вектор ошибки таким образом: 83b754bd9f094b0eadb186ac32201503.png. Решаем систему уравнений методом наименьших квадратов:
    31135b2f54ab499b98dafb16ef708ad5.png

    Найти необходимо шестимерный вектор a547ad979c4d4ca389b092b062fb483b.png — вектор експоненты.

  2. Находим матрицу cмещения из вектора a547ad979c4d4ca389b092b062fb483b.png: dT = exp_transformMatrix (mu).
    Код этой функции:
    TMatrix exp_transformMatrix(const TVector& mu)
    {
        //Вектор mu - 6-мерный вектор 
        TMatrix result(4, 4);//создаем матрицу 4 на 4
        static const float one_6th = 1.0f / 6.0f;
        static const float one_20th = 1.0f / 20.0f;
        TVector w = mu.slice(3, 3);//получаем последние 3 элемента вектора mu 
        TVector mu_3 = mu.slice(0, 3);//получаем первые 3 элемента вектора mu
        float theta_square = dot(w, w);//dot - скалярное произведение векторов
        float theta = std::sqrt(theta_square);
        float A, B;
    
        TVector crossVector = cross3(w,  mu.slice(3));//cross3 векторное произведение 2х 3х-мерных векторов
        if (theta_square < 1e-4) {
            A = 1.0f - one_6th * theta_square;
            B = 0.5f;
            result.setColumn(3, mu_3 + 0.5f * crossVector);//устанавливаем 4ый столбец матрицы
        } else {
            float C;
            if (theta_square < 1e-3) {
                C = one_6th * (1.0f - one_20th * theta_square);
                A = 1.0f - theta_square * C;
                B = 0.5f - 0.25f * one_6th * theta_square;
            } else {
                float inv_theta = 1.0f / theta;
                A = std::sin(theta) * inv_theta;
                B = (1.0f - std::cos(theta)) * (inv_theta * inv_theta);
                C = (1.0f - A) * (inv_theta * inv_theta);
            }
            result.setColumn(3, mu_3 + B * crossVector + C * cross3(w,  crossVector));
        }
        exp_rodrigues(result, w, A, B);
        result(3, 0) = 0.0f;
        result(3, 1) = 0.0f;
        result(3, 2) = 0.0f;
        result(3, 3) = 1.0f;
        return result;
    }
    
    void exp_rodrigues(TMatrix& result, const TVector& w, float A, float B)
    {   
        float wx2 = w(0) * w(0);
        float wy2 = w(1) * w(1);
        float wz2 = w(2) * w(2);
        result(0, 0) = 1.0f - B * (wy2 + wz2);
        result(1, 1) = 1.0f - B * (wx2 + wz2);
        result(2, 2) = 1.0f - B * (wx2 + wy2);
        float a, b;
        a = A * w(2);
        b = B * (w(0) * w(1));
        result(0, 1) = b - a;
        result(1, 0) = b + a;
        a = A * w(1);
        b = B * (w(0) * w(2));
        result(0, 2) = b + a;
        result(2, 0) = b - a;
        a = A * w(0);
        b = B * (w(1) * w(2));
        result(1, 2) = b - a;
        result(2, 1) = b + a;
    }
    
    

  3. Обновляем матрицу 0ff6183b87824660bda2f4aebd161a5b.png.


Достаточно 10–15 итераций. Тем не менее можно вставить какое-то дополнительное условие, которое выводит из цикла, если значение mWorld уже достаточно близко к искомому значению.
По мере определения положения на каждом кадре, какие-то точки будут теряться, а значит необходимо искать потерянные точки. Также не помешает поиск новых точек, на которые можно будет ориентироваться.

Бонус — трехмерная реконструкция


Если можно найти положение отдельных точек в пространстве, так почему бы все-таки не попробовать определить положение всех видимых точек в пространстве? В реальном времени делать это слишком затратно. Но можно попробовать сделать запись, а реконструкцию выполнить потом. Собственно это я и попробовал реализовать. Результат явно не идеален, но что-то выходит:
f246be9917b7486093e20208c199cd3d.jpg

Ссылка на исходный код на github.

© Habrahabr.ru