Android Surface

Clause de non-responsabilité


Cet article est destiné aux développeurs Android débutants ayant peu d'expérience avec la vidéo et / ou la caméra, en particulier ceux qui ont commencé à analyser des exemples de grafika et qui les ont trouvés difficiles, ici nous examinerons un code similaire avec une description simplifiée des étapes de base illustrées par des diagrammes d'état.


Pourquoi la classe Surface est-elle rendue dans l'en-tête? Dans Android, de nombreuses classes ont le mot Surface dans leur nom (Surface, SurfaceHolder, SurfaceTexture, SurfaceView, GLSurfaceView), elles ne sont pas connectées par une hiérarchie commune, cependant, elles sont combinées par une logique de bas niveau pour travailler avec la sortie d'image. Il m'a semblé raisonnable de l'utiliser dans le titre pour souligner une tentative de divulgation de travaux avec cette partie particulière du SDK.


Exemple d'utilisation avec différentes API


Essayons d'écrire l'exemple suivant: nous allons prendre un aperçu de la caméra, superposer un dessin animé sur celle-ci, tout afficher à l'écran et, si nécessaire, écrire dans un fichier. Le code complet se trouvera https://github.com/tttzof351/AndroidSurfaceExample/


Pour la sortie vers les écrans, nous utiliserons GLSurfaceView , pour l'enregistrement avec les classes MediaCodec et EGLSurface , et communiquerons avec la caméra via l' API V2 . Le schéma général est approximativement le suivant:



Superpositions de surfaces multiples


La surface est en fait une poignée pour la zone en mémoire qui doit être remplie avec l'image. Très probablement, nous obtenons qu'il essaie d'afficher quelque chose à l'écran ou dans un fichier, donc cela fonctionne comme un tampon pour certains «processus» qui produisent des données.


Pour créer une superposition à partir de plusieurs surfaces, nous utiliserons OpenGL.
Pour ce faire, nous allons créer deux textures externes carrées et en extraire Surface


Dans le code, cela ressemblera à ceci:


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) 

Coordonnées XYZ


Maintenant, nous devons comprendre comment créer et organiser des textures, et pour cela, nous devrons nous rappeler comment la grille de coordonnées est structurée dans OpenGL: son centre coïncide avec le centre de la scène (fenêtre), et les bordures sont normalisées, c'est-à-dire de -1 à 1.


Dans cette scène, nous voulons définir deux rectangles (le travail est dans le plan, donc toutes les coordonnées z sont logiquement définies à 0f) - en rouge, nous désignons celui où nous placerons l'aperçu pour la caméra, et en bleu pour le dessin animé:


Nous notons explicitement nos coordonnées:



 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 ) 

Coordonnées UV


Est-ce suffisant? Il s'avère que non :(


Une texture est un mappage d'une image à une zone de scène et pour la rendre correctement, vous devez spécifier exactement où les points de l'image se situent dans cette zone - pour cela, OpenGL utilise les coordonnées UV - ils sortent du coin inférieur gauche et ont des bordures de 0 à 1 pour chaque axes.


Cela fonctionne comme suit - nous définirons les coordonnées UV pour chaque sommet de notre zone et rechercherons les points correspondants dans l'image, en supposant que la largeur et la hauteur sont égales à 1.


Prenons un exemple - nous supposerons que la caméra nous donne l'image dans un état inversé et réfléchi, et en même temps, nous voulons montrer uniquement la partie supérieure droite, c'est-à-dire prendre 0,8 en latitude et en hauteur de l'image.


Le point subtil - à ce stade, nous ne connaissons pas le rapport d'aspect de la zone à l'écran - nous n'avons qu'un carré en coordonnées relatives, qui remplira toute la scène et s'étirera en conséquence. Si nous devions créer une caméra plein écran, nos tailles relatives (2 de chaque côté) s'étireraient jusqu'au 1080x1920 conventionnel. Nous supposons que nous définissons les dimensions de la scène de telle sorte que leur rapport soit égal au rapport de la caméra.
Voyons où vont les coordonnées - le point supérieur droit de notre zone (1, 1, 0) devrait aller aux coordonnées UV (0, 0), le coin inférieur gauche dans (0.8f, 0.8f), etc.



Ainsi, nous obtenons la correspondance de XYZ et 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 

Si le rapport d'aspect entre l'aperçu de la caméra et la zone sur l'écran coïncidait initialement, il continuera évidemment à être enregistré, car dans notre cas, nous venons de multiplier par 0,8f.
Et que mangerons-nous, nous fixerons des valeurs supérieures à 1? Selon les paramètres que nous avons passés à OpenGL, nous obtiendrons des points d'une partie de l'image. Dans notre exemple, la dernière ligne sera répétée le long de l'axe correspondant et nous verrons des artefacts sous forme de «rayures»


Conclusion: si nous voulons compresser / couper l'image tout en conservant la position de la zone sur l'écran, alors les coordonnées UV sont notre choix!


Définissez les coordonnées de nos textures.



 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 ) 

Shaders


Avoir des coordonnées statiques XYZ et UV n'est pas très pratique - par exemple, nous pourrions vouloir déplacer et mettre à l'échelle nos textures avec des gestes. Pour les transformer, nous allons créer deux matrices pour chaque texture: MVPMatrix et TexMatrix pour respectivement les coordonnées XYZ et UV.


Chaque OpenGL2 doit contenir des shaders afin d'afficher quelque chose à l'écran. Bien sûr, ce n'est pas un sujet qui peut être divulgué dans un paragraphe, cependant, dans notre cas, ils seront triviaux, et donc vous pouvez rapidement comprendre ce qu'ils font, sans trop connaître le matériel.


Tout d'abord, il y a deux shaders - vertex et fragment.


Le premier (sommet) traitera nos sommets, à savoir, simplement multiplier nos coordonnées XYZ / UV avec leurs matrices correspondantes et remplir la variable OpenGL gl_Position qui est exactement responsable de la position finale de notre texture à l'écran.


Le second (fragment) devrait remplir gl_FragColor avec des pixels d'image.


Total nous avons: les variables à l'intérieur du vertex shader nous devons remplir les champs avec nos données, à savoir:


  • MVPMatrix -> uMVPMatrix
  • TexMatrix -> uTexMatrix
  • nos coordonnées de sommet XYZ -> aPosition
  • Coordonnées UV -> aTextureCoord

vTextureCoord - nécessaire pour transmettre les données du vertex shader au fragment shader
Dans le fragment shader, nous prenons les coordonnées UV converties et les utilisons pour afficher les pixels de l'image dans la zone de texture.


 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); } """ 

Pour référence, nous indiquons la différence entre les types:


  • uniforme - une variable de ce type conservera des valeurs lors d'appels répétés, nous utilisons un shader qui est appelé séquentiellement pour deux textures, nous allons donc toujours l'écraser à chaque rendu
  • attribut - les données de ce type sont lues à partir du tampon de vertex, elles doivent être chargées à chaque rendu
  • variable - nécessaire pour transférer des données du vertex shader vers un fragment

Comment passer des paramètres à un shader? Pour ce faire, vous devez d'abord obtenir l'id (pointeur) de la variable:


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

Maintenant, pour cet identifiant, vous devez charger les données:


 //      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 ) 

Dessin direct


Après avoir rempli nos shaders avec toutes les données, nous devons demander à la texture de mettre à jour l'image et à OpenGL de dessiner nos sommets:


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

Dans notre exemple, nous allons diviser le travail avec la scène OpenGL en deux classes - directement les scènes et les textures:


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 / State Machine / State Machine


Toutes les API que nous avons l'intention d'utiliser dans notre exemple sont fondamentalement asynchrones (enfin, peut-être à l'exception du Drawable animé). Nous encapsulerons ces appels dans des StateMachines séparés, une approche où l'état du système est explicitement écrit, et les transitions entre elles se produisent par l'envoi d'événements.


Regardons un exemple simple de ce à quoi cela ressemblera, supposons que nous ayons ce code:


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

En général, tout va bien - beau et compact, mais nous allons essayer de le réécrire de la manière suivante:


 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() } 

D'une part, il s'est avéré beaucoup plus, néanmoins, plusieurs propriétés implicites mais utiles sont apparues: un appui répété maintenant ne conduit pas à un démarrage inutile de loadImage, bien que ce ne soit pas évident avec un tel volume, mais nous nous sommes débarrassés de l'appel de rappel imbriqué, que nous utiliserons plus tard , et le style d'écriture de la méthode de transition vous permet de construire un diagramme de transition qui répète le code un par un, c'est-à-dire dans notre cas:



Le gris indique des transitions qui ne sont pas explicitement écrites. Souvent, ils sont enregistrés ou levés une exception, le considérant comme un signe d'erreur. Pour l'instant, nous réussirons à simplement l'ignorer et à l'avenir nous ne pointerons pas vers les diagrammes.


Créez les interfaces de base pour StateMachine:


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

GLSurfaceView


La façon la plus simple d'afficher quelque chose à l'aide d'OpenGL dans Android est la classe GLSurfaceView - elle crée automatiquement un nouveau flux pour le dessin, qui démarre / s'arrête en utilisant les méthodes GLSurfaceView :: onResume / onPause.


Pour simplifier, nous allons définir notre point de vue sur un rapport 16: 9.


Le processus de rendu lui-même est déplacé vers un rappel distinct - GLSurfaceView.Renderer.
En l'enveloppant dans StateMachine, nous obtenons quelque chose comme ceci:


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, ...)) 

Dessinons un diagramme de transition:



Maintenant, notre code essaie d'afficher quelque chose à l'écran, bien que pour l'instant il le fasse mal - nous ne verrons rien d'autre qu'un écran noir. Il n'est pas difficile de deviner le fait que rien n'entre dans notre Surface maintenant parce que nous n'avons pas encore implémenté de sources d'images. Corrigeons cela - tout d'abord, créez un 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) } ... } 

Maintenant, nous pouvons compléter la section dans GLSurfaceMachine en rendant canvasDrawable sur canvas qui fournit la surface de la texture correspondante:


 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() } 

Après quoi, nous verrons quelque chose comme:



Camera API V2


Le rectangle vert est certainement amusant et intriguant, mais il est temps d'essayer de faire passer l'aperçu de l'appareil photo sur la surface restante.


Écrivons les étapes pour travailler avec la caméra:


  • Nous attendons la permission. Nous aurons cet état WaitingStart
  • Nous obtenons l'instance de gestionnaire de caméra, nous trouvons l'ID logique (généralement il y en a deux - pour l'arrière et l'avant, et la logique est parce que la caméra peut se composer de nombreux capteurs sur les appareils modernes) de la caméra souhaitée, sélectionnez la taille appropriée, ouvrez la caméra, nous obtenons CameraDevice. Statut en attente Ouvert

 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 ... } }) } 

  • Ayant une caméra ouverte, nous nous tournons pour demander une surface pour afficher l'image. État WaitingSurface
  • Maintenant que nous avons cameraDevice, Surface, nous devons ouvrir une session pour que la caméra commence enfin à transmettre des données. Statut WaitingSession

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

  • Nous pouvons maintenant capturer l'aperçu. État StartingPreview

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

Nous illustrons notre schéma actuel:


CameraMachine.kt




Mediacodec


MediaCodec est une classe pour le travail de bas niveau avec les codecs système, en général, son API est un ensemble de tampons d'entrée / sortie (cela semble, malheureusement, plus facile que de travailler avec lui) dans lequel les données (brutes ou encodées dépendent du mode de fonctionnement de l'encodeur / décodeur) sont placées, et en sortie on obtient le résultat.


Malgré le fait que ByteBuffer agit généralement comme des tampons, vous pouvez utiliser Surface pour travailler avec la vidéo, ce qui nous renverra MediaCodec :: createInputSurface, nous devons y dessiner les images que nous voulons enregistrer (avec cette approche, la documentation nous promet un encodage plus rapide grâce à l'utilisation de gpu )


Eh bien, nous devons maintenant apprendre à dessiner la surface existante que nous avons créée dans GLSurfaceMachine sur Surface à partir de MediaCodec. Il est important de se rappeler: Surface est un objet qui crée un consommateur, et en général, il est impossible de lire quelque chose, c'est-à-dire qu'il n'y a pas de méthode conditionnelle getBitmap / readImage / ...


Nous procéderons comme suit: sur la base du contexte GL existant, nous en créerons un nouveau qui aura une mémoire commune avec lui, et donc nous pouvons l'utiliser pour réutiliser les id-shniks des textures que nous avons créées précédemment. Ensuite, en utilisant le nouveau contexte GL et la surface de MediaCodec, nous allons créer une EGLSurface - un tampon hors écran sur lequel nous pouvons également créer notre classe OpenGLScene. Ensuite, à chaque rendu d'image, nous essaierons d'écrire l'image en parallèle dans le fichier.


EGL signifie l'interface d'interaction de l'API OpenGL avec le sous-système de fenêtre de la plate-forme, nous allons voler le travail avec elle de grafika. Je ne décrirai pas non plus directement le convoyeur (EncoderHelper) avec MediaCodec, je ne donnerai que le schéma final d'interaction entre nos composants:


EncoderMachine.kt
EncoderHelper.kt



Le résultat:


  • Travailler avec la vidéo nécessite au moins des compétences de base en OpenGL
  • L'API Android Media est assez bas niveau, ce qui donne de la flexibilité, mais parfois elle vous oblige à écrire un peu plus de code que vous ne le souhaiteriez
  • Les API asynchrones peuvent être encapsulées dans StateMachines

Source: https://habr.com/ru/post/fr480878/


All Articles