Если вы интересуетесь разработкой AR приложения на андроиде, в частности хотите написать свои «Маски», или любое другое AR приложение, то вам сюда. Здесь я вам дам краткий экскурс, как это можно сделать, не считаю, что я предлагаю самый лучший вариант, может быть проще воспользоваться уже готовыми фреймворками, но он по крайней мере рабочий и не сложен в реализации. Вообще в данном случае, как мне кажется не надо использовать какую-либо дополнительную обвеску, в виде библиотек или фреймверков или чего-либо еще, т.к. она только загромождает все сущее, а достаточно воспользоваться стандартными средствами. Здесь я покажу как работать с камерой, как можно обработать изображение, и как наложить эффекты, предполагаю, что читатель уже умеет создавать “Hello world” на андроиде. Все это под катом.

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

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

Мне пришлось разбить статью на несколько частей, в первой мы должны получить рабочий продукт без эффектов, далее, если конечно статья понравится уже расскажу про примеры применения алгоритмов и эффектов. План текущей статьи — получить изображение с камеры, вывести изображение через 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;
}

Мне кажется, что исходников и так слишком много, предлагаю остановится на этом месте.

В заключение

Поздравляю, вы честно добрались до конца или все же быстро пролистали вниз, тем не менее подведем некоторые итоги. Если все собрать вместе, то получится, что мы научились запускать камеру, получать изображение, трансформировать его в текстуру шейдера и показывать на экранчике телефона. В следующей статье, если конечно эта понравится, я напишу как вкрутить алгоритм, к примеру, поиск лица на кадре с использованием OpenCV и наложим простейший эффект.

Комментарии (3)


  1. marsermd
    23.01.2018 23:04

    Если я правильно понимаю, этот AR сейчас поддерживается только на
    Google Pixel / Pixel XL / Pixel 2 / Pixel 2 XL
    или
    Samsung Galaxy S8


    1. trolleg Автор
      23.01.2018 23:20

      Если мы говорим об аппаратных средствах поддержки, то возможно ваш список верный, но здесь говориться об работе с камерой и opengl, то это будет работать начиная с Android 4.0, исключая какие-либо совсем отдельные случаи.


      1. marsermd
        23.01.2018 23:29

        А, я просто так подумал, что вы раскроете дальше ARCore:) Но перечитал и понял что будет OpenCV