Если вы интересуетесь разработкой AR приложения на андроиде, в частности хотите написать свои «Маски», или любое другое AR приложение, то вам сюда. Здесь я вам дам краткий экскурс, как это можно сделать, не считаю, что я предлагаю самый лучший вариант, может быть проще воспользоваться уже готовыми фреймворками, но он по крайней мере рабочий и не сложен в реализации. Вообще в данном случае, как мне кажется не надо использовать какую-либо дополнительную обвеску, в виде библиотек или фреймверков или чего-либо еще, т.к. она только загромождает все сущее, а достаточно воспользоваться стандартными средствами. Здесь я покажу как работать с камерой, как можно обработать изображение, и как наложить эффекты, предполагаю, что читатель уже умеет создавать “Hello world” на андроиде. Все это под катом.
Раз вы все-таки решили заглянуть в статью, то постараюсь не разочаровать, и кто знает, может быть после прочтения данной статьи у вас возникнет желание написать приложение, которое покорит сердца пользователей и займет заслуженное место в лучших приложениях.
Но хочу предупредить сразу, всех готовых исходников выкладывать не буду, дам лишь выдранные кусочки, т.к. хорошая статья должна быть как мини-юбка — достаточно короткой, чтобы вызвать интерес и достаточно длинной, чтобы покрыть все самое важное.
Мне пришлось разбить статью на несколько частей, в первой мы должны получить рабочий продукт без эффектов, далее, если конечно статья понравится уже расскажу про примеры применения алгоритмов и эффектов. План текущей статьи — получить изображение с камеры, вывести изображение через GLSurfaceView.
И так… Начнем с того, что нам надо получать поток изображения с камеру. Андроид нам предоставляет Camera API, описание которого вы легко можете найти на официальном сайте.
К сожалению, я вам покажу способ работы со старым пакетом android.hardware.camera, до нового — android.hardware.camera2 у меня руки никак не дошли, но думаю, что переделать — незначительная проблема.
Сперва даем permission для работы с камерой:
Добавляем в наш layout превью для камеры
Обратите внимание, что мы скрываем данный элемент через alpha= 0, т.к. превью будет показывать только поток с камеры в первозданном виде, а нам надо на него наложить эффекты, но и не использовать preview для камеры нельзя, вроде как из-за соображений безопасности, чтобы не делали скрытых видеосъемок.
Далее нам надо запустить камеру, комментарии в коде ниже, куски кода были выдраны из проекта и слегка отредактированы, так что для запуска придется слегка отрефакторить.
Камера запустится вместе с запуском нашего созданного View.
Далее у нас начинается OpenGL, про который можно прочитать на википедии. Коротко — это стандарт для работы с графикой через шейдеры. Шейдеры — это программы, которые работают в параллель на ядрах GPU, мы будем использовать вершинный и фрагментный шейдеры. Вершинный шейдер обычно используется для пребразования 3d координат на плоскость, а фрагментный вычисляет цвет каждого пикселя на плоскости за счет апроксимации координаты на плоскости к координате текстуры проецируемого объекта. Хватит скучной теории, далее немного кода. Добавляем в наш же layout соответствующий элемент:
Делаем необходимые настройки
Далее создаем класс рендерер, в нем будет крутиться вся логика создания изображения.
Здесь наш вспомогательный метод вызов шейдера, он разделяет текстуру на два треугольника и применяет к ним линейное аффинное преобразование, если по-простому.
Про формат NV21 можно почитать здесь. Если кратко, то формат изображение NV21 — матрица пикселей ч\б изображения, далее за ней идет разность цветов Y и U на каждые четыре пикселы ч\б изображения, и итого получается 4 ч\б байта и 2 байта разности цветов, сокращаем дробь получаем 2 к 1, т.е. NV21.
Вот наши шейдеры для пребразования nv21 в rgba. Вершинный шейдер(vss_2d.glsl):
И фрагментный шейдер(fss_n21_to_rgba.glsl), он центрирует, меняет масштаб и преобразовывает цвет
Мне кажется, что исходников и так слишком много, предлагаю остановится на этом месте.
Раз вы все-таки решили заглянуть в статью, то постараюсь не разочаровать, и кто знает, может быть после прочтения данной статьи у вас возникнет желание написать приложение, которое покорит сердца пользователей и займет заслуженное место в лучших приложениях.
Но хочу предупредить сразу, всех готовых исходников выкладывать не буду, дам лишь выдранные кусочки, т.к. хорошая статья должна быть как мини-юбка — достаточно короткой, чтобы вызвать интерес и достаточно длинной, чтобы покрыть все самое важное.
Мне пришлось разбить статью на несколько частей, в первой мы должны получить рабочий продукт без эффектов, далее, если конечно статья понравится уже расскажу про примеры применения алгоритмов и эффектов. План текущей статьи — получить изображение с камеры, вывести изображение через GLSurfaceView.
Получение изображения с камеры
И так… Начнем с того, что нам надо получать поток изображения с камеру. Андроид нам предоставляет Camera API, описание которого вы легко можете найти на официальном сайте.
К сожалению, я вам покажу способ работы со старым пакетом android.hardware.camera, до нового — android.hardware.camera2 у меня руки никак не дошли, но думаю, что переделать — незначительная проблема.
Сперва даем permission для работы с камерой:
<uses-permission android:name="android.permission.CAMERA" />
Добавляем в наш layout превью для камеры
<trolleg.CameraView
android:id="@+id/fd_fase_surface_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:alpha="0"/>
Обратите внимание, что мы скрываем данный элемент через alpha= 0, т.к. превью будет показывать только поток с камеры в первозданном виде, а нам надо на него наложить эффекты, но и не использовать preview для камеры нельзя, вроде как из-за соображений безопасности, чтобы не делали скрытых видеосъемок.
Далее нам надо запустить камеру, комментарии в коде ниже, куски кода были выдраны из проекта и слегка отредактированы, так что для запуска придется слегка отрефакторить.
public class CameraView extends SurfaceView implements SurfaceHolder.Callback, Camera.PreviewCallback {
public FrameCamera frameCamera = new FrameCamera(); // Здесь мы храним изображения в массиве байтов и размер кадра, думаю без проблем сможете создать подобный класс
private static final int MAGIC_TEXTURE_ID = 10;
boolean cameraFacing;
private byte mBuffer[];
private static final String TAG = "CameraView";
private Camera mCamera;
private SurfaceTexture mSurfaceTexture;
int numberOfCameras;
int cameraIndex;
int previewWidth;
int previewHeight;
int cameraWidth;
int cameraHeight;
public CameraView(Context context, AttributeSet attrs) {
super(context, attrs);
cameraIndex = 0;
// получаем все камеры с устрояства
numberOfCameras = android.hardware.Camera.getNumberOfCameras();
android.hardware.Camera.CameraInfo cameraInfo = new android.hardware.Camera.CameraInfo();
// мы хотим получить фронтальную камеру
for (int i = 0; i < numberOfCameras; i++) {
android.hardware.Camera.getCameraInfo(i, cameraInfo);
if (cameraInfo.facing == android.hardware.Camera.CameraInfo.CAMERA_FACING_FRONT) {
cameraIndex = i;
}
}
getHolder().addCallback(this);
}
@Override
public void surfaceCreated(SurfaceHolder holder) {
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) {
previewHeight = h;
previewWidth = w;
startCameraPreview(w , h);
}
private void startCameraPreview(int previewWidthLocal, int previewHeightLocal) {
releaseCamera();
mCamera = Camera.open(cameraIndex);
Camera.Parameters params = mCamera.getParameters();
params.setPreviewFormat(ImageFormat.NV21); // устанавливаем формат превью NV21, скажем о нем чуть позже
// здесь был вырезан кусок кода получения наиболее подходящего размера превью
...
mCamera.setParameters(params);
// вычисляем размер буфера для превью-кадра
int size = cameraWidth * cameraHeight;
size = size * ImageFormat.getBitsPerPixel(params.getPreviewFormat()) / 8;
mBuffer = new byte[size];
try {
// добавляем буфер и коллбэк для изображения
mCamera.addCallbackBuffer(mBuffer);
mCamera.setPreviewCallbackWithBuffer(this);
// не делать превью
mCamera.setPreviewDisplay(null);
mCamera.startPreview();
} catch (Exception e){
Log.d(TAG, "Error starting camera preview: " + e.getMessage());
}
}
@Override
public void surfaceDestroyed(SurfaceHolder surfaceHolder) {
releaseCamera();
}
@Override
public void onPreviewFrame(byte[] data, Camera camera) {
synchronized (frameCamera) {
// получаем и сохраняем кадр для дальнейшей обработки
frameCamera.cameraWidth = cameraWidth;
frameCamera.cameraHeight = cameraHeight;
frameCamera.facing = cameraFacing;
if (frameCamera.bufferFromCamera == null || frameCamera.bufferFromCamera.length != data.length) {
frameCamera.bufferFromCamera = new byte[data.length];
}
System.arraycopy(data, 0, frameCamera.bufferFromCamera, 0, data.length);
frameCamera.wereProcessed = false;
}
// добавляем этот же буфер для получения следующего кадра
mCamera.addCallbackBuffer(mBuffer);
}
public void disableView() {
releaseCamera();
}
public void enableView() {
startCameraPreview(previewWidth, previewHeight);
}
}
Камера запустится вместе с запуском нашего созданного View.
Показываем изображение через GLSurfaceView
Далее у нас начинается OpenGL, про который можно прочитать на википедии. Коротко — это стандарт для работы с графикой через шейдеры. Шейдеры — это программы, которые работают в параллель на ядрах GPU, мы будем использовать вершинный и фрагментный шейдеры. Вершинный шейдер обычно используется для пребразования 3d координат на плоскость, а фрагментный вычисляет цвет каждого пикселя на плоскости за счет апроксимации координаты на плоскости к координате текстуры проецируемого объекта. Хватит скучной теории, далее немного кода. Добавляем в наш же layout соответствующий элемент:
<android.opengl.GLSurfaceView
android:id="@+id/fd_glsurface"
android:layout_width="match_parent"
android:layout_height="match_parent" />
Делаем необходимые настройки
GLSurfaceView gLSurfaceView = findViewById(R.id.fd_glsurface);
gLSurfaceView.setEGLContextClientVersion(2);
gLSurfaceView.setEGLConfigChooser(8, 8, 8, 8, 16, 0);
gLSurfaceView.getHolder().setFormat(PixelFormat.TRANSPARENT);
gLSurfaceView.setRenderer(new OurRenderer()); // здесь должен быть наш обработчик
gLSurfaceView.setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY);
Далее создаем класс рендерер, в нем будет крутиться вся логика создания изображения.
public class OurRenderer implements GLSurfaceView.Renderer {
int programNv21ToRgba; // шейдер конвертации nv21 в rgba, сами шейдеры будут чуть ниже
int texNV21FromCamera[] = new int[2]; // id-шники текстур, для ч\б и UV
// буферы для хранения кадра перед отправкой в текстуры
ByteBuffer bufferY;
ByteBuffer bufferUV;
private void initShaders() {
int vertexShaderId = ShaderUtils.createShader(GLES20.GL_VERTEX_SHADER, FileUtils.getStringFromAsset(context.getAssets(), "shaders/vss_2d.glsl"));
int fragmentShaderId = ShaderUtils.createShader(GLES20.GL_FRAGMENT_SHADER, FileUtils.getStringFromAsset(context.getAssets(), "shaders/fss_n21_to_rgba.glsl"));
programNv21ToRgba = ShaderUtils.createProgram(vertexShaderId, fragmentShaderId);
}
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
initShaders();
// здесь мы инициализируем текстуры
GLES20.glGenTextures(2, texNV21FromCamera, 0);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texNV21FromCamera[0]);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_NEAREST);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texNV21FromCamera[1]);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_NEAREST);
}
// данный метод будет вызываться постоянно как мы и указали в параметре GLSurfaceView.RENDERMODE_CONTINUOUSLY
public void onDrawFrame(GL10 gl) {
// синхронизируемся над изображением полученным от камеры, нам надо его быстренько забрать и отпустить
synchronized (frameCamera) {
mCameraWidth = frameCamera.cameraWidth;
mCameraHeight = frameCamera.cameraHeight;
int cameraSize = mCameraWidth * mCameraHeight;
if (bufferY == null) {
bufferY = ByteBuffer.allocateDirect(cameraSize);
bufferUV = ByteBuffer.allocateDirect(cameraSize / 2);
}
// запихиваем наш кадр в nv21 формате в две текстуры: ч\б и UV
bufferY.put(frameCamera.bufferFromCamera, 0, cameraSize);
bufferY.position(0);
bufferUV.put(frameCamera.bufferFromCamera, cameraSize, cameraSize / 2);
bufferUV.position(0);
// копируем ч\б
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texNV21FromCamera[0]);
GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_LUMINANCE, mCameraWidth, (int) (mCameraHeight), 0, GLES20.GL_LUMINANCE, GLES20.GL_UNSIGNED_BYTE, bufferY);
GLES20.GL_LUMINANCE, GLES20.GL_UNSIGNED_BYTE, bufferY);
GLES20.glFlush();
// копируем UV часть в виде ч\б+ALPHA, мне так показалось проще для понимания, можно было запихать все в одну текстуру, тогда преобразование в шейдере бы поменялось
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texNV21FromCamera[1]);
GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_LUMINANCE_ALPHA, mCameraWidth / 2, (int) (mCameraHeight * 0.5), 0, GLES20.GL_LUMINANCE_ALPHA, GLES20.GL_UNSIGNED_BYTE, bufferUV);
GLES20.GL_LUMINANCE_ALPHA, GLES20.GL_UNSIGNED_BYTE, bufferUV);
GLES20.glFlush();
}
// далее с помощью шейдеров преобразовываем наши текстуры nv21 в rgba
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0); // buffer 0 - это и есть наш GlSurfaceView, если бы хотели добавить эффекты, то использовали бы промежуточные буферы
GLES20.glViewport(0, 0, widthSurf, heightSurf);
GLES20.glUseProgram(programNv21ToRgba);
// заполняем параметры для шейдера
int vPos = GLES20.glGetAttribLocation(programNv21ToRgba, "vPosition");
int vTex = GLES20.glGetAttribLocation(programNv21ToRgba, "vTexCoord");
GLES20.glEnableVertexAttribArray(vPos);
GLES20.glEnableVertexAttribArray(vTex);
int ufacing = GLES20.glGetUniformLocation(programNv21ToRgba, "u_facing");
GLES20.glUniform1i(ufacing, facing1 ? 1 : 0);
GLES20.glUniform1f(GLES20.glGetUniformLocation(programNv21ToRgba, "cameraWidth"), mCameraWidth);
GLES20.glUniform1f(GLES20.glGetUniformLocation(programNv21ToRgba, "cameraHeight"), mCameraHeight);
GLES20.glUniform1f(GLES20.glGetUniformLocation(programNv21ToRgba, "previewWidth"), widthSurf);
GLES20.glUniform1f(GLES20.glGetUniformLocation(programNv21ToRgba, "previewHeight"), heightSurf);
ShaderEffectHelper.shaderEffect2dWholeScreen(new Point(0, 0), new Point(widthSurf, heightSurf), texNV21FromCamera[0], programNv21ToRgba, vPos, vTex, texNV21FromCamera[1]);
}
Здесь наш вспомогательный метод вызов шейдера, он разделяет текстуру на два треугольника и применяет к ним линейное аффинное преобразование, если по-простому.
public class ShaderEffectHelper {
...
public static void shaderEffect2dWholeScreen(Point center, Point center2, int texIn, int programId, int poss, int texx, Integer texIn2) {
GLES20.glUseProgram(programId);
int uColorLocation = GLES20.glGetUniformLocation(programId, "u_Color");
GLES20.glUniform4f(uColorLocation, 0.0f, 0.0f, 1.0f, 1.0f);
int uCenter = GLES20.glGetUniformLocation(programId, "uCenter");
GLES20.glUniform2f(uCenter, (float)center.x, (float)center.y);
int uCenter2 = GLES20.glGetUniformLocation(programId, "uCenter2");
GLES20.glUniform2f(uCenter2, (float)center2.x, (float)center2.y);
// это координаты вершин треугольников на входной текстуре
FloatBuffer vertexData = convertArray(new float[]{
-1, -1,
-1, 1,
1, -1,
1, 1
});
// соответствующие координаты вершин треугольников на выходной текстуре
FloatBuffer texData = convertArray(new float[] {
0, 0,
0, 1,
1, 0,
1, 1
});
GLES20.glVertexAttribPointer(poss, 2, GLES20.GL_FLOAT, false, 0, vertexData);
GLES20.glVertexAttribPointer(texx, 2, GLES20.GL_FLOAT, false, 0, texData);
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texIn);
GLES20.glUniform1i(GLES20.glGetUniformLocation(programId, "sTexture"), 0);
if (texIn2 != null) {
GLES20.glActiveTexture(GLES20.GL_TEXTURE1);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texIn2);
GLES20.glUniform1i(GLES20.glGetUniformLocation(programId, "sTexture2"), 1);
}
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); // говорим, что у нас 4 вершины и они составляют собой два трегольника, где предыдущие две координаты используются для новго треугольника
GLES20.glFlush();
}
...
Про формат NV21 можно почитать здесь. Если кратко, то формат изображение NV21 — матрица пикселей ч\б изображения, далее за ней идет разность цветов Y и U на каждые четыре пикселы ч\б изображения, и итого получается 4 ч\б байта и 2 байта разности цветов, сокращаем дробь получаем 2 к 1, т.е. NV21.
Вот наши шейдеры для пребразования nv21 в rgba. Вершинный шейдер(vss_2d.glsl):
attribute vec2 vPosition;
attribute vec2 vTexCoord;
varying vec2 texCoord;
uniform mat4 uMVP;
// for 2d triangles
varying vec2 v_TexCoordinate;
varying vec2 v_TexOrigCoordinate;
// simple coomon 2d shader
void main() {
texCoord = vTexCoord;
v_TexCoordinate = vTexCoord;
v_TexOrigCoordinate = vec2(vPosition.x / 2.0 + 0.5, vPosition.y / 2.0 + 0.5);
gl_Position = vec4 ( vPosition.x, vPosition.y, 0.0, 1.0 );
}
И фрагментный шейдер(fss_n21_to_rgba.glsl), он центрирует, меняет масштаб и преобразовывает цвет
precision mediump float;
uniform sampler2D sTexture; // y - texture
uniform sampler2D sTexture2; //uv texture
varying vec2 texCoord;
uniform int u_facing;
uniform float cameraWidth;
uniform float cameraHeight;
// remember, camera is rotated 90 degree
uniform float previewWidth;
uniform float previewHeight;
const mat3 yuv2rgb = mat3(
1, 0, 1.2802,
1, -0.214821, -0.380589,
1, 2.127982, 0
);
// shader from convert NV21 to RGBA
void main() {
vec2 coord = vec2(texCoord.y, texCoord.x);
if (u_facing == 0) coord.x = 1.0 - coord.x;
// centered pic by maximum size
coord.y = 1.0 - coord.y;
if (previewWidth / previewHeight > cameraHeight / cameraWidth)
{
coord.x = 0.5 - (0.5 - coord.x) * previewHeight * (cameraHeight / previewWidth) / cameraWidth;// (cameraHeight / cameraWidth) * (previewWidth / previewHeight);
} else if (previewWidth / previewHeight < cameraHeight / cameraWidth)
{
coord.y = 0.5 - (0.5 - coord.y) * previewWidth * (cameraWidth / previewHeight) / cameraHeight;
}
float y = texture2D(sTexture, coord).r;
float u = texture2D(sTexture2, coord).a;
float v = texture2D(sTexture2, coord).r;
vec4 color;
// another way sligthly lighter
// TODO find correct way of transfromation
color.r = (1.164 * (y - 0.0625)) + (1.596 * (v - 0.5));
color.g = (1.164 * (y - 0.0625)) - (0.391 * (u - 0.5)) - (0.813 * (v - 0.5));
color.b = (1.164 * (y - 0.0625)) + (2.018 * (u - 0.5));
color.a = 1.0;
vec3 yuv = vec3(
1.1643 * y - 0.0627,
u - 0.5,
v - 0.5
);
vec3 rgb = yuv * yuv2rgb;
color = vec4(rgb, 1.0);
gl_FragColor = color;
}
Мне кажется, что исходников и так слишком много, предлагаю остановится на этом месте.
marsermd
Если я правильно понимаю, этот AR сейчас поддерживается только на
Google Pixel / Pixel XL / Pixel 2 / Pixel 2 XL
или
Samsung Galaxy S8
trolleg Автор
Если мы говорим об аппаратных средствах поддержки, то возможно ваш список верный, но здесь говориться об работе с камерой и opengl, то это будет работать начиная с Android 4.0, исключая какие-либо совсем отдельные случаи.
marsermd
А, я просто так подумал, что вы раскроете дальше ARCore:) Но перечитал и понял что будет OpenCV