Android. Surface

от автора

Дисклеймер

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

Почему в заголовке вынесен класс Surface? В android множество классов имеют в своем названии слово Surface (Surface, SurfaceHolder, SurfaceTexture, SurfaceView, GLSurfaceView) они не связаны общей иерархией тем не менее объединены низкоуровневой логикой работы с вывод изображений. Мне показалось разумным использовать его в названии чтобы подчеркнуть попытку раскрытия работы именно с этой частью SDK.

Пример использования с разным API

Попробуем написать следующий пример: будем брать preview с камеры, накладывать на него анимированный drawable, выводить это все на экран и по необходимости записывать в файл. Полный код будет лежать https://github.com/tttzof351/AndroidSurfaceExample/

Для вывода на экраны мы воспользуемся GLSurfaceView, для записи классами MediaCodec и EGLSurface, а с камерой общаться через API V2. Общая схема примерно следующая:

Наложение нескольких Surface

Surface — фактически дескриптор области в памяти, которую нужно заполнить изображением. Скорее всего, мы получаем его пытаясь вывести что то на экран или в файл, таким образом он работает как буфер для некоторого “процесса” который производит данные.

Чтобы создать наложение из нескольких Surface воспользуемся OpenGL.
Для этого мы создадим две квадратные external-текстуры и получим из них Surface-ы

В коде это будет выглядеть как то так:

OpenGLExtarnalTexture.kt

val textures = IntArray(1) GLES20.glGenTextures(1, textures, 0) val textureId = textures[0] //Как то рассчитаем размеры val textureWidth = ... val textureHeight = ... //Прослойка между  val surfaceTexture = SurfaceTexture(textureId) surfaceTexture.setDefaultBufferSize(textureWidth, textureHeight) //Собственно, surface который "связан" с нашей текстурой val surface = Surface(surfaceTexture)

XYZ координаты

Теперь нам нужно понять как создать и расположить текстуры, а для этого придется вспомнить как устроена координатная сетка в OpenGL: ее центр совпадает с центром сцены (окна), а границы нормированы т.е от -1 до 1.

На этой сцене мы хотим задать два прямоугольника (работа идет на плоскости поэтому все z координаты логично установлены в 0f) — красным мы обозначим тот куда будем помещать preview для камеры, а синим для анимированного drawable-а:

Выпишем наши координаты явно:

fullscreenTexture = floatArrayOf(    // X,     Y,     Z      -1.0f,  -1.0f,  0.0f,     1.0f,  -1.0f,  0.0f,    -1.0f,   1.0f,  0.0f,     1.0f,   1.0f,  0.0f, ) smallTexture = floatArrayOf(    // X,      Y,    Z     0.3f,   0.3f,  0.0f,     0.8f,   0.3f,  0.0f,     0.3f,   0.8f,  0.0f,     0.8f,   0.8f,  0.0f )

UV координаты

Достаточно ли этого? Оказывается, что нет 🙁

Текстура это отображение картинки на область сцены и чтобы его правильно совершить нужно указать в какое точно место точки на картинке попадут внутри этой области — для этого в OpenGL применяются UV координаты — они выходят из левого нижнего угла и имеют границы от 0 до 1 по каждой из осей.

Работает это следующим образом — каждой вершине нашей области мы зададим UV координаты и будем искать соответствующие точки на изображении, считая что там ширина и высота равны по 1.

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

Тонкий момент — на данном этапе мы не знаем соотношения сторон области на экране — у нас есть только квадрат в относительных координатах, который заполнит собой всю сцену и соответственно растянется. Если бы мы делали fullscreen камеру то наши относительные размеры (2 по каждой стороне) растянулись бы до условных 1080×1920. Будем считать что размеры сцены мы зададим такие что их соотношение будет равно соотношению камеры.
Посмотрим куда перейдут координаты — правая верхняя точка нашей области (1, 1, 0) должна перейти в UV координату (0, 0), левая нижняя в (0.8f, 0.8f) и т. д

Таким образом получим соответствие XYZ и UV:

// X,     Y,     Z,     U,     V -1.0f,  -1.0f,  0.0f,  0.8f, 0.8f,  1.0f,  -1.0f,  0.0f,  0.8f, 0.0f, -1.0f,   1.0f,  0.0f,  0.0f, 0.8f,  1.0f,   1.0f,  0.0f,  0.0f, 0.0f

Если соотношение сторон между preview с камеры и областью на экране совпадало изначально то оно очевидным образом продолжит сохранятся т.к в нашем случаи мы просто умножили на 0.8f.
А что будет есть мы зададим значения больше 1? В зависимости от настроек которые мы передали OpenGL-у мы получим точки какой то части изображения. В нашем примере будет повторяться последняя линия по соответствующей оси и мы увидим артефакты в виде “полосок”

Итог: если мы хотим сжать/вырезать изображение сохраняя при этом позицию области на экране то UV координаты наш выбор!

Зададим координаты для наших текстур

fullscreenTexture = floatArrayOf(   // X,   Y,      Z,      U,  V    -1.0f,  -1.0f,  0.0f,  1f, 0f,     1.0f,  -1.0f,  0.0f,  0f, 0f,    -1.0f,   1.0f,  0.0f,  1f, 1f,     1.0f,   1.0f,  0.0f,  0f, 1f ) smallTexture = floatArrayOf(   // X,   Y,      Z,      U,  V     0.3f,   0.3f,  0.0f,  0f, 0f,     0.8f,   0.3f,  0.0f,  1f, 0f,     0.3f,   0.8f,  0.0f,  0f, 1f,     0.8f,   0.8f,  0.0f,  1f, 1f )

Шейдеры

Иметь статичные XYZ и UV-координаты не очень удобно — мы например можем захотеть перемещать и масштабировать жестами наши текстуры. Чтобы их трансформировать заведем две матрицы для каждой текстуры: MVPMatrix и TexMatrix для для XYZ и UV координат соответственно.

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

Прежде всего шейдера два — vertex и fragment.

Первый (vertex) будет обрабатывает наши вершины, а именно просто перемножать наши XYZ / UV координаты с соответствующими им матрицами и заполнять OpenGL переменную gl_Position которая как раз отвечает за финальное положение нашей текстуры на экране.

Второй (fragment) должен заполнить gl_FragColor пикселями изображения.

Итого имеем: переменные внутри vertex шейдера мы должны заполнить поля нашими данными, а именно:

  • MVPMatrix -> uMVPMatrix
  • TexMatrix -> uTexMatrix
  • наши XYZ координаты вершины -> aPosition
  • UV координаты -> aTextureCoord

vTextureCoord — нужна для проброса данных из vertex шейдера в fragment шейдер
В fragment шейдере мы берем преобразованные UV координаты и используем их для отображения пикселей изображения в области текстуры.

val vertexShader = """    uniform mat4 uMVPMatrix;    uniform mat4 uTexMatrix;    attribute vec4 aPosition;    attribute vec4 aTextureCoord;    varying vec2 vTextureCoord;    void main() {        gl_Position = uMVPMatrix * aPosition;        vTextureCoord = (uTexMatrix * aTextureCoord).xy;    } """ val fragmentShader = """    #extension GL_OES_EGL_image_external : require    precision mediump float;    varying vec2 vTextureCoord;    uniform samplerExternalOES sTexture;    void main() {        gl_FragColor = texture2D(sTexture, vTextureCoord);    } """

Ради справки укажем чем отличаются типы:

  • uniform — переменная такого типа будет сохранять значения при многократном вызове, мы используем один шейдер которые вызывается последовательно для двух текстур, так что все равно будем перезаписывать при каждой отрисовки
  • attribute — данные такого типа читаются из вершинного буфера, их нужно загружать при каждой отрисовки
  • varying — нужны для передачи данных из vertex шейдера в fragment

Как передать параметры в шейдер? Для этого вначале нужно получить id (указатель) переменной:

val aPositionHandle = GLES20.glGetAttribLocation(programId, "aPosition")

Теперь по этому id нужно загрузить данные:

//Вначале превратим наш массив вершин во floatbuffer val verticesBuffer = ByteBuffer.allocateDirect(     fullscreenTexture.size * FLOAT_SIZE_BYTES ).order(     ByteOrder.nativeOrder() ).asFloatBuffer() verticesBuffer.put(fullscreenTexture).position(0) /* Установим начальное смещение - для XYZ это будет 0 т.к они находится в начале Затема передадим id нашего аттрибута куда мы загружаем данные, указав сколько  значений координат брать, и какое то смещение в байтах нужно затем совершить чтобы попасть в следующую вершуны - 5 это кол-во XYZUV, а 4 - кол-во байт во float */ verticesBuffer.position(0) GLES20.glVertexAttribPointer(     aPositionHandle,     3,     GLES20.GL_FLOAT,     false,     5 * 4,     verticesBuffer )

Непосредственно отрисовка

После того как мы заполнили наши шейдеры всеми данными мы должны попросить текстуру обновить изображение, а OpenGL отрисовать наши вершины:

fun updateFrame(...) {     ...     surfaceTexture.updateTexImage()     GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4) } 

В нашем примере мы разобьем работу с OpenGL сценой на два классы — непосредственно сцены и текстуры:

OpenGLExternalTexture.kt

class OpenGLExternalTexture(verticesData: FloatArray, ...) {     val surfaceTexture: SurfaceTexture     val surface: Surface     init {         //Проинициализируем матрицы, создадим текстуру и т.д     }     ...     fun updateFrame(aPositionHandle: Int, ...) {...} //заполним данные, отрисуем кадр     fun release() {...} // отчистим данные }

OpenGLScene.kt

class OpenGLScene(     sceneWidth: Int,     sceneHeight: Int,     ... ) {     val fullscreenTexture = OpenGLExternalTexture(...)     val smallTexture = OpenGLExternalTexture(..)      val aPositionHandle: Int      ...     init {       //Создадим шейдеры, получим все указатали на переменные шейдера и т.д     }     fun updateFrame() {         ...         fullscreenTexture.updateFrame(aPositionHandle, ...)         smallTexture.updateFrame(aPositionHandle, ...)     }     fun release() {         fullscreenTexture.release()         smallTexture.release()             } }

StateMachine / Машина состояний / Конечный автомат

Все API которое мы предполагаем использовать в нашем примере принципиально асинхронное (ну может за исключением анимированного Drawable-а). Мы будем заворачивать такие вызовы в отдельные StateMachine-ы — подходе когда явно выписывают состояния системы, а переходы между ними происходят через отправку событий.

Давайте на простом примере посмотрим как это будет выглядеть, предположим у нас есть такое код:

imageView.setOnClickListener {     loadImage { bitmap ->          imageView.setBitmap(bitmap)     } }

В целом все хорошо — красиво и компактно, но мы попробуем переписать его в следующим образом:

val uiMachine = UIMachine() imageView.setOnClickListener { uiMachine.send(Click(imageView)) } class UIMachine {     var state: State = WaitClick()     fun send(action: Action) = transition(action)     private fun transition(action: Action) {         val state = this.state         when {             state is WaitingClick && action is Click -> {                 this.state = WaitBitmap(imageView = action.imageView)                 loadImage { send(BitmapIsReady(bitmap = it)) }             }             state is WaitingBitmap && action is BitmapIsReady -> {                 this.state = WaitClick                 state.imageView.setImageBitmap(action.bitmap)             }         }     } }  sealed class State {     object WaitingClick : State()     class WaitingBitmap(val imageView: ImageView): State() } sealed class Action {     class Click(val imageView: ImageView): Action()     class BitmapIsReady(val bitmap: Bitmap): Action() }

С одной стороны получилось сильно больше, тем не менее появилось несколько неявных, но полезных свойств: многократное нажатие теперь не приводит к лишним запускам loadImage, хотя и не очевидно с таким объемом, но мы избавились от вложенного вызова колбеков, чем и будем в последствии пользоваться, а еще стиль написания метода transition позволяет построить диаграмму переходов которая один в один повторяет код т.е в нашем случаи:


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

Создадим базовый интерфейсы для StateMachine:

interface Action interface State interface StateMachine<S : State, A : Action> {     var state: S     fun transition(action: A)     fun send(action: A) }

GLSurfaceView

Самый простой способ вывести что-то на экран используя OpenGL в android это класс GLSurfaceView — он автоматически создает новые поток для рисования, запуск/пауза которого происходит по методам GLSurfaceView::onResume/onPause.

Для простоты мы будем задавать нашей вьюхе соотношение 16:9

Сам процесс отрисовки вынесен в отдельный колбек — GLSurfaceView.Renderer.
Завернув его в StateMachine-у мы получим что то вроде этого:

GLSurfaceMachine.kt

class GLSurfaceMachine: StateMachine<GLSurfaceState, GLSurfaceAction> {     override var state: GLSurfaceState = WaitCreate()     override fun send(action: GLSurfaceAction) = transition(action)     override fun transition(action: GLSurfaceAction) {         val state = this.state         when {             state is WaitCreate && action is Create -> {                 this.state = WaitSurfaceReady(...)                 this.state.glSurfaceView?.setRenderer(object :Renderer {                     override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int)                         send(SurfaceReady(width, height, gl))                     }                     override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {                     }                     override fun onDrawFrame(gl: GL10?) {                         send(Draw)                     }                 })                       }             state is WaitSurfaceReady && action is SurfaceReady -> {                             val openGLScene = OpenGLScene(width, height)                 this.state = DrawingAvailable(openGLScene, ...)             }             state is DrawingAvailable && action is Draw -> {                 state.openGLScene.updateFrame()             }             state !is WaitCreate && action is Stop -> {                 state.uiHolder.glSurfaceView?.onPause()                 state.uiHolder.openGLScene?.release()                 this.state = WaitSurfaceReady()             }              state is WaitSurfaceReady && action is Start -> {               state.uiHolder.glSurfaceView?.onResume()             }                    }     } } ... val glSurfaceMachine = GLSurfaceMachine() val glSurfaceView = findViewById(R.id.gl_view) glSurfaceView.layoutParams.width = width glSurfaceView.layoutParams.height = ((16f/9f) * width).toInt() glSurfaceMachine.send(GLSurfaceAction.Create(glSurfaceView, ...))

Давайте нарисуем диаграмму переходов:

Теперь наш код пытается что то выводить на экран, правда пока у него это получается плохо — ни чего кроме черного экрана мы не увидим. Как не сложно догадаться дело в том, что в наши Surface-ы сейчас ни чего не попадает т.к мы пока не реализовали источники изображений. Давайте это исправим — первым делом создадим CanvasDrawable:

CanvasDrawable.kt

class CanvasDrawable : Drawable() {     private val backgroundPaint = Paint().apply { ... }     private val circlePaint = Paint().apply { ... }     override fun draw(canvas: Canvas) {         canvas.drawRect(bounds, backgroundPaint)         val width = bounds.width()         val height = bounds.height()         val posX = ...         val posY = ...         canvas.drawCircle(posX, posY, 0.1f * width, circlePaint)     }     ... }

Теперь секцию в GLSurfaceMachine мы можем дополнить отрисовкой canvasDrawable на canvas-е которые предоставляет surface у соответствующей текстуры:

state is DrawingAvailable && action is Draw -> {   val canvasDrawable = state.canvasDrawable   val smallTexture = state.openGLScene.smallTexture   val bounds = canvasDrawable.bounds   val canvas = smallSurface.lockCanvas(bounds)   canvasDrawable.draw(canvas)   smallSurface.unlockCanvasAndPost(canvas)   state.openGLScene.updateFrame() }

После чего увидим что то наподобие:

Camera API V2

Зеленый прямоугольник это конечно весело и интригующе, но пора попробовать вывести preview с камеры на оставшейся surface.

Давайте выпишем этапы работы с камерой:

  • Ожидаем получение permission-а. У нас это будет состояние WaitingStart
  • Получаем инстанс camera manager-а, находим логический id (обычно их два — для back и front, а логический он потому что на современных девайсах камера может состоять из множества датчиков) нужной камеры, выбираем подходящий размер, открываем камеру, получаем cameraDevice. Состояние WaitingOpen

val manager = getSystemService(Context.CAMERA_SERVICE) as CameraManager var resultCameraId: String? = null var resultSize: Size? = null for (cameraId in manager.cameraIdList) {     val chars = manager.getCameraCharacteristics(cameraId)     val facing = chars.get(CameraCharacteristics.LENS_FACING) ?: -1     if (facing == LENS_FACING_BACK) {          val confMap = chars.get(             CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP         )         val sizes = confMap?.getOutputSizes(SurfaceTexture::class.java)         resultSize = findSize(sizes)         resultCameraId = cameraId         break     } } resultCameraId?.let { cameraId ->      manager.openCamera(cameraId, object : CameraDevice.StateCallback() {         override fun onOpened(camera: CameraDevice) {             //Success open camera             ...         }     }) }

  • Имея открытую камеру мы обратимся в обратимся за получением Surface-а для вывода изображения. Состояние WaitingSurface
  • Теперь имея cameraDevice, Surface мы должны открыть сессию чтобы камера наконец начала передавать данные. Состояние WaitingSession

cameraDevice.createCaptureSession(     arrayListOf(surface),     object : CameraCaptureSession.StateCallback() {         override fun onConfigured(session: CameraCaptureSession) {             send(CameraAction.SessionReady(session))         }     },     handler )

  • Теперь мы можем захватить preview. Состояние StartingPreview

val request = cameraDevice.createCaptureRequest(     CameraDevice.TEMPLATE_PREVIEW ).apply {     addTarget(surface) } session.setRepeatingRequest(     request.build(),     object : CameraCaptureSession.CaptureCallback() {...}     handler )

Проиллюстрируем нашу текущую схему:

CameraMachine.kt

MediaCodec

MediaCodec класс для низкоуровневой работы с системными кодеками, в общем виде его API это набор input/output буферов (звучит, к сожалению, проще чем работать с ним) в которые помещаются данные (сырые или закодированные зависит от режима работы encoder/decoder), а на выходе мы получаем результат.

Несмотря на то, что к качестве буферов обычно выступают ByteBuffer, для работы с видео можно использовать Surface который вернет нам MediaCodec::createInputSurface, на нем мы должны отрисовывать кадры, которое хотим записать (при таком подходе документация обещает нам ускорение кодирования за счет использования gpu).

Хорошо, теперь мы должны научиться отрисовывать уже существующие Surface-ы которое мы создали в GLSurfaceMachine на Surface от MediaCodec-а. При этом важно помнить: Surface это объект который создает consumer-ом и прочитать что то из него в общем случаи нельзя т.е нет условного метода getBitmap/readImage/…

Мы поступим следующим образом: на основе существующего GL контекста мы создадим новый который будем иметь общую с ним память, а потому мы сможем использовать переиспользовать там id-шники текстур которые мы создали ранее. Затем используя новый GL контекст и Surface от MediaCodec-а, мы создадим EGLSurface — внеэкранный буфер на котором мы так же сможем создать наш класс OpenGLScene. Затем при каждой отрисовке кадра мы попробуем параллельно записывать кадр на файл.

EGL означает интерфейс взаимодействия OpenGL API с оконной подсистемой платформы, работу с ним мы украдем из grafika. Конвейер (EncoderHelper) с MediaCodec-ом напрямую описывать тоже не буду, приведу лишь итоговую схему взаимодействия наших компонентов:

EncoderMachine.kt
EncoderHelper.kt

Итог:

  • Работа с видео требует хотя бы базовых навыков в OpenGL-е
  • Media API android достаточно низкоуровневое, что дает гибкость, однако заставляет иногда писать чуть больше код чем хотелось бы
  • Асинхронное API можно заворачивать в StateMachine-ы


ссылка на оригинал статьи https://habr.com/ru/post/480878/


Комментарии

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

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