Русский
Русский
English
Статистика
Реклама

Работа с камерой в android

Android Camera2 API от чайника, часть 6. Стрим видео сначала кодировали, теперь декодируем

20.06.2020 18:10:45 | Автор: admin

Итак, в предыдущем посте мы занимались кодированием живого видео формата H.264 на Android устройстве, которое затем отправляли для просмотра на персональный компьютер под виндой. Там наш видеопоток успешно раскодировывался и лицезрелся с помощью VLC плеера. А так же с помощью библиотеки VLCJ CAPRICA благополучно впихивался и в окошки JAVA приложения. Правда, каким именно образом он (VLC плеер) всё это проделывал, так и осталось загадкой. Но с другой стороны работает, да и ладно.

Подстольный настольный компьютер, ноутбук, лэптоп всё это прекрасно, но тем не менее, всё больше народа смотрит видео и управляет разными девайсами не из-за стола, а чаще валяясь на диване, со смартфоном в руках. И соответственно, к примеру, даже нашей роботележкой ныне удобней управлять именно оттуда. Поэтому настало время выяснить, как наш закодированный видео поток принять и отобразить на экране такого же Android устройства. Естественно, как и раньше мы проделаем всё через Camera2 API, концепцию Surface, да ещё и асинхронно!

Кому интересно вперёд.

Собственно, для чего мне это понадобилось, кроме как обычно стремления, по выражению ослика Иа, к обалдеванию новыми знаниями? Откровенно говоря, про роботележку я упомянул только для примера (я думаю за такое количество статей она уже всем успела изрядно надоесть). На самом деле, кодируя-декодируя мы соберём незамысловатый видеотелефон для домашней сети.

Но это будет:
в следующей статье. Поскольку надо ещё и аудио канал прикрутить.

А в этом посте мы будем работать только со стримом видео (без звука). Но тоже с благородной целью, будем помогать сознанию выходить из тела. Есть такие интересные эксперименты у нейробиологов, когда одному человеку транслируют прямо в глаз то, что видит другой и соответственно тому другому в обратном порядке.

Этот эксперимент можно повторить с помощью нашего приложения, пары VR-очков для смартфона и естественно с помощью двух смартфонов. Одни очки хорошие и дорогие, которыми вы обычно пользуетесь, а другие Да, даже за четыреста рублей сойдут.

image

Второй телефон можно отобрать у своей девицы и использовать её (девицу) в качестве подопытного кролика второго участника эксперимента.

Если по существу, то изображение с камеры первого Android устройства передается на экран второго и раздваивается там (VR очки же). И наоборот, со второго девайса видео поток подается на первый в таком же порядке. То есть, вы видите то же, что должен видеть ваш партнер, а он (она), видит то, что должны видеть вы. Поскольку мы, в буквальном смысле, есть там, где есть наши глаза, ощущения будут у вас непередаваемые, особенно, если вы будете стараться двигаться синхронно и смотреть на свое (её) тело. Для неврологических экспериментов просто поле непаханное, ну или для камасутры всякой.

А ещё можно подавать для каждого глаза разные изображения, чтобы посмотреть, что получится и как переклинит ваши мозговые полушария.

Спойлер
Ничего не переклинит. Просто вы будете раз в несколько секунд переключаться между этими изображениями (видеть то одно, то другое). Но вот, если картинки будут отличаться не сильно (только некоторыми деталями), то тогда ощущения нереальности происходящего гарантированы.

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

А находится он, как известно, в классе:

MEDIA CODEC


В пред пред предыдущем посте с его помощью мы кодировали в формате H.264 видео поток полученный с камеры и отправляли его по UDP каналу на нужный адрес. Поэтому, чтобы начать работу, нам весьма пригодится код из того самого поста. Чтобы не парить голову с отладкой программы на двух телефонах сразу, для начала мы сделаем всё на одном. Просто для понимания принципа работы. В одном окошке мы будем снимать видео, кодировать и отправлять его по домашней сетке самим себе во второе окошко. Всё по-честному, никаких localhost и адресов 127.0.0.1. Только настоящий сетевой адрес, только хардкор!

В сущности, в дополнение к уже имеющейся программе нам надо добавить:

  1. Изменение UI, то есть добавление второго окна и ассоциированного с ним Surface
  2. Блок кода для получения видео потока по UDP каналу
  3. Процедуру самого декодирования.

Всё это делается легко и просто, но об несколько подводных камней удариться всё равно придется.

Итак, сначала модифицируем макет добавляем второе окно.

activity_main.xml
<?xml version="1.0" encoding="utf-8"?><androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto"    xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools"    android:layout_width="match_parent"    android:layout_height="match_parent"    tools:context=".MainActivity">    <TextureView        android:id="@+id/textureView"        android:layout_width="320dp"        android:layout_height="240dp"        android:layout_marginTop="88dp"        app:layout_constraintEnd_toEndOf="parent"        app:layout_constraintHorizontal_bias="0.494"        app:layout_constraintStart_toStartOf="parent"        app:layout_constraintTop_toTopOf="parent" />    <TextureView        android:id="@+id/textureView3"        android:layout_width="320dp"        android:layout_height="240dp"        android:layout_marginTop="24dp"        app:layout_constraintEnd_toEndOf="parent"        app:layout_constraintHorizontal_bias="0.494"        app:layout_constraintStart_toStartOf="parent"        app:layout_constraintTop_toBottomOf="@+id/textureView" />    <LinearLayout        android:layout_width="165dp"        android:layout_height="40dp"        app:layout_constraintBottom_toBottomOf="parent"        app:layout_constraintEnd_toEndOf="parent"        app:layout_constraintStart_toStartOf="parent"        app:layout_constraintTop_toBottomOf="@+id/textureView3"        app:layout_constraintVertical_bias="0.838">        <Button            android:id="@+id/button1"            android:layout_width="wrap_content"            android:layout_height="36dp"            android:text="вкл" />        <Button            android:id="@+id/button3"            android:layout_width="wrap_content"            android:layout_height="37dp"            android:text="выкл" />    </LinearLayout></androidx.constraintlayout.widget.ConstraintLayout>


Эта часть вообще объяснений не требует. Идем дальше.

Пишем код для получения дэйтаграмм


Тут есть пара тонкостей. Когда мы дэйтаграммы отправляли, то действовали совсем незамысловато. Мы дожидались, когда сработает коллбэк буфера выходных данных onOutputBufferAvailable, а затем легко и просто пихали полученный от него байтовый массив в UDP пакет. Дальше он уже без всякой помощи с нашей стороны уезжал по указанному адресу. Единственное, мы его ещё рубили на килобайтовые блоки (чтобы гарантированно влез в размер MTU), но сейчас это совершенно излишне (и даже, как мы увидим потом, вредно).

В случае же приёма, надо во-первых, самим для этого организовать отдельный поток ожидания поскольку мы не в курсе, когда придёт следующая дэйтаграмма. Во-вторых, надо знать, скурил ли декодер уже пришедший пакет, чтобы в следующий раз предоставить ему новый, а не бывший в употреблении. В-третьих мы, (вроде как) в Java не знаем размер прибывшего пакета. А чтобы положить его в байтовый массив, размер его знать как-бы хочется.

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

В итоге, код оказался несложным:

        Udp_recipient()        {            start();            mNewFrame = false;        }        public void run()        {                         try {                    byte buffer[] = new byte[50000];                    DatagramPacket p = new DatagramPacket(buffer, buffer.length);                    udpSocketIn.receive(p);                    byte bBuffer[] = p.getData();                    outDataForEncoder = new byte[p.getLength()];                     synchronized (outDataForEncoder)                     {                         for (int i = 0; i < outDataForEncoder.length; i++)                         {                             outDataForEncoder[i] = bBuffer[i];                         }                     }                    mNewFrame = true;                } catch (Exception e) {                    Log.i(LOG_TAG, e + "  ");                }             }        }

Итак, поток в наличии. После получения дэйтаграммы устанавливаем флаг NewFrame, чтобы декодер не путался и сбрасываем флаг перед прибытием нового UDP пакета. Здесь неявно предполагается, что декодер скуривает пакеты быстрее, чем они приходят. А это действительно, так и оказалось.

Переходим теперь к самому декодеру


Как и кодер он может работать в двух режимах синхронном и асинхронном. В первом случае вы учреждаете немало потоков, флагов и тщательно следите за тем, чтобы не впасть в состояние гонок (всё ж параллельное). Этот процесс сопровождается кучей стереотипного кода, в просторечии Boiler Plate, и в общем многократно где описан.

Ну, а мы, поскольку кодировали видео асинхронно через коллбэки, то делать шаг назад к синхронности, вроде как, нам не к лицу. Но проблема в том, что примеров асинхронного кода, почему-то намного меньше и основная мысль там такая: я написал, а оно не работает, хелп!.

То ли считается, что там всё тривиально (а оно по сравнению с синхронным вариантом действительно так), то ли правда, оно у всех не работает. Разбираться опять придётся самим.

Итак, сначала учреждаем и инициализируем экземпляр декодера. Это вообще не сложно, поскольку он будет использовать формат кодера и нам только надо установить разрешение выходного окна в пихелях (это мое личное изобретение пихель, так сказать, родним русский и английский). Пока ограничимся скромным 640 на 480. Также надо будет присобачить декодер к Surface этого самого окна, чтобы было куда показывать.

       try {            decoder = MediaCodec.createDecoderByType("video/avc");// H264 декодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету декодека");        }        int width = 480; // ширина видео        int height = 640; // высота видео        MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);        format.setInteger(MediaFormat.KEY_ROTATION,90);        SurfaceTexture texture = mImageViewDown.getSurfaceTexture();        texture.setDefaultBufferSize(480, 640);        mDecoderSurface = new Surface(texture);        decoder.configure(format, mDecoderSurface, null,0);        decoder.setOutputSurface(mDecoderSurface);        decoder.setCallback(new DecoderCallback());        decoder.start();        Log.i(LOG_TAG, "запустили декодер");

Далее обращаемся к коллбэкам. Нужных, как известно, два:

void onInputBufferAvailable(MediaCodec mc, int inputBufferId)void onOutputBufferAvailable(MediaCodec mc, int outputBufferId, )

То есть, при срабатывании первого коллбэка мы в него кладём данные, а при вызове второго вынимаем готовенькое и отправляем на Surface. Может показаться странным, что когда мы делали кодирование (то есть, наоборот из Surface в кодек), то почему-то InputBuffer мы не использовали, а сразу волшебным образом доставали байтовые данные из OutputBuffer. Это мне тоже казалось странным, пока я не прочитал:
When using an input Surface, there are no accessible input buffers, as buffers are automatically passed from the input surface to the codec. Calling dequeueInputBuffer will throw an IllegalStateException, and getInputBuffers() returns a bogus ByteBuffer[] array that MUST NOT be written into.
Короче, автоматически это делается. Ну, и сделали бы при декодировании также. Но нет, придётся самим.

Итак, в метод void (MediaCodec mc, int inputBufferId) я ничтоже сумняшеся прописал:

 private class DecoderCallback extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {                  decoderInputBuffer = codec.getInputBuffer(index);                  decoderInputBuffer.clear();                             if(mNewFrame)                             {                               synchronized (outDataForEncoder)                               { b=outDataForEncoder;  }                             }                             decoderInputBuffer.put(b);                 codec.queueInputBuffer(index, 0, b.length,0, 0);              if(mNewFrame)              new Udp_recipient();        }

Ну, по аналогии, как мы делали в прошлый раз в кодере.

То есть, когда декодер вдруг ощущает, что ему срочно нужны данные, он вызывает этот коллбэк и кладёт наш прибывший udp-пакет (который уже доступен в виде байтового массива) в один из своих буферов под номером index. Там их вроде четыре. Естественно, ничего не заработало. Я ж забыл про onOutputBufferAvailable.

Туда тоже необходимо вставить две строчки:

    @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            {                  {                      decoderOutputBuffer = codec.getOutputBuffer(index);                      codec.releaseOutputBuffer(index, true);                  }            }        }

Причем значение True/False отвечает за то, будет ли содержимое буфера рендерится на Surface или выкинется на помойку.

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



А в логах обнаружились какие-то таинственные:

buffer descriptor with invalid usage bits 0x2000
A resource failed to call release.


Но большой ясности они не прибавляли. Некоторое понимание пришло из прошлых наблюдений. Дело в том, что я сначала не использовал флаг NewFrame (просто руки ещё не дошли) и декодер мог обратится к одному и тому же пришедшему пакету несколько раз (данные-то обрабатываются гораздо быстрее, чем приходят, как уже говорилось). На экране была, понятное дело, совершенная каша, но onInputBufferAvailable работал и работал без перебоев!

Оказалось, что имеет место удивительная вещь. Если коллбэк onInputBufferAvailable, сработал, то вынь да положь ему входные данные. Неважно какие, но положь. Иначе он дохнет. Поэтому пришлось предлагать ему байтовый массив нулевой длины в тех случаях, когда пакет актуальных данных уже был обработан, а новый ещё не пришёл. Как говорится, кому расскажешь не поверят.

Как только поправки были произведены, на Output Surface наконец-то появилось полученное через сеть видео.



Правда, как видно, на нём присутствуют некоторые недостатки. Посмотрев на них какое-то время, я стал в душе догадываться, что это все опять из-за onInputBufferAvailable. Ему снова что-то не нравилось. Оказалось, был не по вкусу байтовый массив. Я тогда про это не догадывался, мне лично не нравилась концепция флага NewFrame. Как-то она не сочеталась с реактивным программированием. Поэтому я решил полученные пакеты не просто класть в массив, а заворачивать этот массив в итоге в байтовый поток ByteArrayOutputStream. И пускай коллбэк, что ему надо, сам оттуда забирает, а конкретно каким образом, это его проблема.

Идея сработала блестяще и на экране я увидел это:

image

Согласитесь, прогресс существенный. Но опять чего-то не хватает. Или чего лишнее?

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

Решение было простым:

поток.reset();

И всё заработало как надо.



Теперь можно было насладиться законченным кодом, как обычно крайне минималистическим:

Листинг main_activity
package com.example.encoderdecoder;import androidx.annotation.RequiresApi;import androidx.appcompat.app.AppCompatActivity;import androidx.core.content.ContextCompat;import android.Manifest;import android.content.Context;import android.content.pm.ActivityInfo;import android.content.pm.PackageManager;import android.graphics.SurfaceTexture;import android.hardware.camera2.CameraAccessException;import android.hardware.camera2.CameraCaptureSession;import android.hardware.camera2.CameraDevice;import android.hardware.camera2.CameraManager;import android.hardware.camera2.CaptureRequest;import android.media.MediaCodec;import android.media.MediaCodecInfo;import android.media.MediaFormat;import android.os.Build;import android.os.Bundle;import android.os.Handler;import android.os.HandlerThread;import android.os.StrictMode;import android.util.Log;import android.view.Surface;import android.view.TextureView;import android.view.View;import android.widget.Button;import android.widget.Toast;import java.io.ByteArrayOutputStream;import java.io.IOException;import java.net.DatagramPacket;import java.net.DatagramSocket;import java.net.InetAddress;import java.net.SocketException;import java.nio.ByteBuffer;import java.util.Arrays;public class MainActivity extends AppCompatActivity {    public static final String LOG_TAG = "myLogs";    public static Surface surface = null;    CameraService[] myCameras = null;    private CameraManager mCameraManager = null;    private final int CAMERA1 = 0;    private Button mButtonOpenCamera1 = null;    private Button mButtonTStopStreamVideo = null;    public static TextureView mImageViewUp = null;    public static TextureView mImageViewDown = null;    private HandlerThread mBackgroundThread;    private Handler mBackgroundHandler = null;    private MediaCodec encoder = null; // кодер    private MediaCodec decoder = null;    byte [] b;    Surface mEncoderSurface; // Surface как вход данных для кодера    Surface mDecoderSurface; // Surface как прием данных от кодера    ByteBuffer outPutByteBuffer;    ByteBuffer decoderInputBuffer;    ByteBuffer decoderOutputBuffer;    byte outDataForEncoder [];    DatagramSocket udpSocket;    DatagramSocket udpSocketIn;    String ip_address = "your target address";// сюда пишете IP адрес телефона куда шлете //видео, но можно и себе    InetAddress address;    int port = 40002;    ByteArrayOutputStream  out;    private void startBackgroundThread() {        mBackgroundThread = new HandlerThread("CameraBackground");        mBackgroundThread.start();        mBackgroundHandler = new Handler(mBackgroundThread.getLooper());    }    private void stopBackgroundThread() {        mBackgroundThread.quitSafely();        try {            mBackgroundThread.join();            mBackgroundThread = null;            mBackgroundHandler = null;        } catch (InterruptedException e) {            e.printStackTrace();        }    }    @RequiresApi(api = Build.VERSION_CODES.M)    @Override    protected void onCreate(Bundle savedInstanceState) {        super.onCreate(savedInstanceState);        StrictMode.ThreadPolicy policy = new StrictMode.ThreadPolicy.Builder().permitAll().build();        StrictMode.setThreadPolicy(policy);        setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);        setContentView(R.layout.activity_main);        Log.d(LOG_TAG, "Запрашиваем разрешение");        if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED                ||                (ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED)        ) {            requestPermissions(new String[]{Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1);        }        mButtonOpenCamera1 = findViewById(R.id.button1);        mButtonTStopStreamVideo = findViewById(R.id.button3);        mImageViewUp = findViewById(R.id.textureView);        mImageViewDown = findViewById(R.id.textureView3);        mButtonOpenCamera1.setOnClickListener(new View.OnClickListener() {            @Override            public void onClick(View v) {                setUpMediaCodec();// инициализируем Медиа Кодек                if (myCameras[CAMERA1] != null) {// открываем камеру                    if (!myCameras[CAMERA1].isOpen()) myCameras[CAMERA1].openCamera();                }            }        });        mButtonTStopStreamVideo.setOnClickListener(new View.OnClickListener() {            @Override            public void onClick(View v) {                if (encoder != null) {                    Toast.makeText(MainActivity.this, " остановили стрим", Toast.LENGTH_SHORT).show();                    myCameras[CAMERA1].stopStreamingVideo();                }            }        });        try {            udpSocket = new DatagramSocket();            udpSocketIn = new DatagramSocket(port);// we changed it to DatagramChannell becouse UDP packets may be different in size            try {            }            catch (Exception e){                Log.i(LOG_TAG, "  создали udp канал");            }            new Udp_recipient();            Log.i(LOG_TAG, "  создали udp сокет");        } catch (                SocketException e) {            Log.i(LOG_TAG, " не создали udp сокет");        }        try {            address = InetAddress.getByName(ip_address);            Log.i(LOG_TAG, "  есть адрес");        } catch (Exception e) {        }        mCameraManager = (CameraManager) getSystemService(Context.CAMERA_SERVICE);        try {            // Получение списка камер с устройства            myCameras = new CameraService[mCameraManager.getCameraIdList().length];            for (String cameraID : mCameraManager.getCameraIdList()) {                Log.i(LOG_TAG, "cameraID: " + cameraID);                int id = Integer.parseInt(cameraID);                // создаем обработчик для камеры                myCameras[id] = new CameraService(mCameraManager, cameraID);            }        } catch (CameraAccessException e) {            Log.e(LOG_TAG, e.getMessage());            e.printStackTrace();        }    }    public class CameraService {        private String mCameraID;        private CameraDevice mCameraDevice = null;        private CameraCaptureSession mSession;        private CaptureRequest.Builder mPreviewBuilder;        public CameraService(CameraManager cameraManager, String cameraID) {            mCameraManager = cameraManager;            mCameraID = cameraID;        }        private CameraDevice.StateCallback mCameraCallback = new CameraDevice.StateCallback() {            @Override            public void onOpened(CameraDevice camera) {                mCameraDevice = camera;                Log.i(LOG_TAG, "Open camera  with id:" + mCameraDevice.getId());                startCameraPreviewSession();            }            @Override            public void onDisconnected(CameraDevice camera) {                mCameraDevice.close();                Log.i(LOG_TAG, "disconnect camera  with id:" + mCameraDevice.getId());                mCameraDevice = null;            }            @Override            public void onError(CameraDevice camera, int error) {                Log.i(LOG_TAG, "error! camera id:" + camera.getId() + " error:" + error);            }        };        private void startCameraPreviewSession() {            SurfaceTexture texture = mImageViewUp.getSurfaceTexture();            texture.setDefaultBufferSize(480, 640);            surface = new Surface(texture);            try {                mPreviewBuilder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);                mPreviewBuilder.addTarget(surface);                mPreviewBuilder.addTarget(mEncoderSurface);                mCameraDevice.createCaptureSession(Arrays.asList(surface, mEncoderSurface),                        new CameraCaptureSession.StateCallback() {                            @Override                            public void onConfigured(CameraCaptureSession session) {                                mSession = session;                                try {                                    mSession.setRepeatingRequest(mPreviewBuilder.build(), null, mBackgroundHandler);                                } catch (CameraAccessException e) {                                    e.printStackTrace();                                }                            }                            @Override                            public void onConfigureFailed(CameraCaptureSession session) {                            }                        }, mBackgroundHandler);            } catch (CameraAccessException e) {                e.printStackTrace();            }        }        public boolean isOpen() {            if (mCameraDevice == null) {                return false;            } else {                return true;            }        }        public void openCamera() {            try {                if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {                    mCameraManager.openCamera(mCameraID, mCameraCallback, mBackgroundHandler);                }            } catch (CameraAccessException e) {                Log.i(LOG_TAG, e.getMessage());            }        }        public void closeCamera() {            if (mCameraDevice != null) {                mCameraDevice.close();                mCameraDevice = null;            }        }        public void stopStreamingVideo() {            if (mCameraDevice != null & encoder != null) {                try {                    mSession.stopRepeating();                    mSession.abortCaptures();                } catch (CameraAccessException e) {                    e.printStackTrace();                }                encoder.stop();                encoder.release();                mEncoderSurface.release();                decoder.stop();                decoder.release();                closeCamera();            }        }    }    private void setUpMediaCodec() {        try {            encoder = MediaCodec.createEncoderByType("video/avc"); // H264 кодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету кодека");        }        {            int width = 480; // ширина видео            int height = 640; // высота видео            int colorFormat = MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface; // формат ввода цвета            int videoBitrate = 2000000; // битрейт видео в bps (бит в секунду)            int videoFramePerSecond = 30; // FPS            int iframeInterval = 1; // I-Frame интервал в секундах            MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);            format.setInteger(MediaFormat.KEY_COLOR_FORMAT, colorFormat);            format.setInteger(MediaFormat.KEY_BIT_RATE, videoBitrate);            format.setInteger(MediaFormat.KEY_FRAME_RATE, videoFramePerSecond);            format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iframeInterval);            encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); // конфигурируем кодек как кодер            mEncoderSurface = encoder.createInputSurface(); // получаем Surface кодера        }        encoder.setCallback(new EncoderCallback());        encoder.start(); // запускаем кодер        Log.i(LOG_TAG, "запустили кодек");        try {            decoder = MediaCodec.createDecoderByType("video/avc");// H264 декодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету декодека");        }        int width = 480; // ширина видео        int height = 640; // высота видео        MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);        format.setInteger(MediaFormat.KEY_ROTATION,90);        SurfaceTexture texture = mImageViewDown.getSurfaceTexture();        texture.setDefaultBufferSize(480, 640);        mDecoderSurface = new Surface(texture);        decoder.configure(format, mDecoderSurface, null,0);        decoder.setOutputSurface(mDecoderSurface);        decoder.setCallback(new DecoderCallback());        decoder.start();        Log.i(LOG_TAG, "запустили декодер");    }    //CALLBACK FOR DECODER    private class DecoderCallback extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {                  decoderInputBuffer = codec.getInputBuffer(index);                  decoderInputBuffer.clear();                                    synchronized (out)                    {                            b =  out.toByteArray();                        out.reset();                          }                            decoderInputBuffer.put(b);                   codec.queueInputBuffer(index, 0, b.length,0, 0);        }        @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            {                  {                      decoderOutputBuffer = codec.getOutputBuffer(index);                      codec.releaseOutputBuffer(index, true);                  }            }        }        @Override        public void onError(MediaCodec codec, MediaCodec.CodecException e) {            Log.i(LOG_TAG, "Error: " + e);        }        @Override        public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {            Log.i(LOG_TAG, "decoder output format changed: " + format);        }    }    private class EncoderCallback extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {            Log.i(LOG_TAG, " входные буфера готовы" );            //        }        @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            outPutByteBuffer = encoder.getOutputBuffer(index);            byte[] outDate = new byte[info.size];            outPutByteBuffer.get(outDate);            try {                DatagramPacket packet = new DatagramPacket(outDate, outDate.length, address, port);                udpSocket.send(packet);            } catch (IOException e) {                Log.i(LOG_TAG, " не отправился UDP пакет");            }            encoder.releaseOutputBuffer(index, false);        }        @Override        public void onError(MediaCodec codec, MediaCodec.CodecException e) {            Log.i(LOG_TAG, "Error: " + e);        }        @Override        public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {          //  Log.i(LOG_TAG, "encoder output format changed: " + format);        }    }    @Override    public void onPause() {        if (myCameras[CAMERA1].isOpen()) {            myCameras[CAMERA1].closeCamera();        }        stopBackgroundThread();        super.onPause();    }    @Override    public void onResume() {        super.onResume();        startBackgroundThread();    }    public class Udp_recipient extends Thread {        Udp_recipient()        {            out = new ByteArrayOutputStream(50000);            start();        }        public void run()        {            while (true)            {                try {                    byte buffer[] = new byte[50000];                    DatagramPacket p = new DatagramPacket(buffer, buffer.length);                    udpSocketIn.receive(p);                    byte bBuffer[] = p.getData();                    outDataForEncoder = new byte[p.getLength()];                     synchronized (outDataForEncoder)                     {                         for (int i = 0; i < outDataForEncoder.length; i++)                         {                             outDataForEncoder[i] = bBuffer[i];                         }                     }                     synchronized (out)                            {out.write(outDataForEncoder);}                } catch (Exception e) {                    Log.i(LOG_TAG, e + "  ");                }            }        }        }    }


Как видно из листинга, мы выкинули из кодера фрагмент, где готовый байтовый массив рубился на кусочки длиной не более килобайта из-за опасения, что они могут не влезть в MTU. Опасения, как уже говорилось, оказались напрасными и даже вредными. Дело в том, (как мне показалось ) что кодер лепит эти массивы уже как некие смысловые единицы и соответственно декодер таким же порядком кладёт их в свои входные буферы. А если у нас килобайт попадает в один буфер, а хвостик в другой? Теперь-то скрывать уже нечего, но на самом деле, видео у меня красиво не показывало даже тогда, когда я учредил ByteArrayOutputStream. Не показывало до тех пор, пока я не выкинул этот злосчастный фрагмент кода.

А вот, когда мы гнали видео поток на компьютер и декодировали его VLC плеером, такой проблемы не было. Скорее всего, не было потому, что плеер предварительно формировал кэш (он же плеер, он больше для просмотра кино, а не живого видео). А в кэше, все эти порубленные килобайты вновь красиво срастались.

Причем из-за отсутствия этого кэша, теперь у нас почти отсутствуют лаги, что легко можно узреть из этого видеофрагмента.



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

Для полноты счастья оставалось только провести эксперименты с доступными разрешением и битрейтом. Я сначала хотел было поставить вполне приличные 1280 х 960, но для этого в кодеке H.264 нужен битрейт 5000-6000 Кбит / с. А при установке такой скорости мой декодер, к сожалению, со своей работой уже не справлялся. Пришлось ограничиться разрешением 640 х 480 (тем, что и так уже было) и подходящим для этого битрейтом 2000 Кбит / с. Потому что при приближении к скорости света к 3 000 000 бит/c, декодер начинает иметь бесконечную массу, валять дурака и отваливаться.

Тем не менее, теперь на руках у нас есть рабочий образцовый код на основании которого можно лепить видео телефон (в следующей статье) и аппаратуру для нейрологических исследований (прямо сейчас).

Сама программа особо не поменяется. Макет вообще трогать не будем. Нам всего лишь надо:

  1. Отключить первую Surface от камеры
  2. Подключить к ней декодер. Грубо говоря, скопировать окно.

Я думал, что задача окажется тривиальной, но как всегда ошибся. Казалось бы, надо просто создать вторую ссылку на экземпляр декодера и оно само всё скопируется во второе окно.
Действительно, если у вас к примеру два текстовых окна и вы хотите вывести в них значение переменной Int, то вы же не вычисляете это Int два раза. Вы просто пишете:

TextView1.setText(Int +  );TextView2.setText(Int +  );

Но видимо, поток данных в Surface не так прост как просто вывод текста. Если пробовать решить вопрос таким путем, то у вас будет выводится окно, которое было инициализировано последним. А первая инициализация исчезнет.

Чего я только не делал, даже textureView окна в макете пытался обозвать одинаково, ничего не помогало. На stockoverflow было на этот счёт мало чего (действительно, кому нафиг надо дублировать видео поток в два одинаковых окна на смартфоне), а в единственном обсуждении, что я нашёл, высказывалось туманное предположение, что один экземпляр Mediac Codec может связываться только с одной Surface.

Выход оказался совсем не элегантным. Надо скопировать видео поток во второе окно? Создавай второй экземпляр декодера, не больше ни меньше. Хорошо, что ещё не заставили второй раз данные по UDP каналу пересылать.

Но, тем тем не менее, при всей своей дубовости метод работает.

Листинг кода main_activity
package com.example.twovideosurfaces;import androidx.annotation.RequiresApi;import androidx.appcompat.app.AppCompatActivity;import androidx.core.content.ContextCompat;import android.Manifest;import android.content.Context;import android.content.pm.ActivityInfo;import android.content.pm.PackageManager;import android.graphics.SurfaceTexture;import android.hardware.camera2.CameraAccessException;import android.hardware.camera2.CameraCaptureSession;import android.hardware.camera2.CameraDevice;import android.hardware.camera2.CameraManager;import android.hardware.camera2.CaptureRequest;import android.media.MediaCodec;import android.media.MediaCodecInfo;import android.media.MediaFormat;import android.os.Build;import android.os.Bundle;import android.os.Handler;import android.os.HandlerThread;import android.os.StrictMode;import android.util.Log;import android.view.Surface;import android.view.TextureView;import android.view.View;import android.widget.Button;import android.widget.Toast;import java.io.ByteArrayOutputStream;import java.io.IOException;import java.net.DatagramPacket;import java.net.DatagramSocket;import java.net.InetAddress;import java.net.SocketException;import java.nio.ByteBuffer;import java.util.Arrays;public class MainActivity extends AppCompatActivity  {    public static final String LOG_TAG = "myLogs";    CameraService[] myCameras = null;    private CameraManager mCameraManager = null;    private final int CAMERA1 = 0;    private Button mOn = null;    private Button mOff = null;    public static TextureView mImageViewUp = null;    public static TextureView mImageViewDown = null;    private HandlerThread mBackgroundThread;    private Handler mBackgroundHandler = null;    private MediaCodec encoder = null; // кодер    private MediaCodec decoder = null;    private MediaCodec decoder2 = null;    byte [] b;    byte [] b2;    Surface mEncoderSurface; // Surface как вход данных для кодера    Surface mDecoderSurface; // Surface как прием данных от кодера    Surface mDecoderSurface2; // Surface как прием данных от кодера    ByteBuffer outPutByteBuffer;    ByteBuffer decoderInputBuffer;    ByteBuffer decoderOutputBuffer;    ByteBuffer decoderInputBuffer2;    ByteBuffer decoderOutputBuffer2;    byte outDataForEncoder [];    static  boolean  mNewFrame=false;    DatagramSocket udpSocket;    DatagramSocket udpSocketIn;    String ip_address = "192.168.50.131";    InetAddress address;    int port = 40002;    ByteArrayOutputStream out =new ByteArrayOutputStream(50000);    ByteArrayOutputStream out2 = new ByteArrayOutputStream(50000);    private void startBackgroundThread() {        mBackgroundThread = new HandlerThread("CameraBackground");        mBackgroundThread.start();        mBackgroundHandler = new Handler(mBackgroundThread.getLooper());    }    private void stopBackgroundThread() {        mBackgroundThread.quitSafely();        try {            mBackgroundThread.join();            mBackgroundThread = null;            mBackgroundHandler = null;        } catch (InterruptedException e) {            e.printStackTrace();        }    }    @RequiresApi(api = Build.VERSION_CODES.M)    @Override    protected void onCreate(Bundle savedInstanceState) {        super.onCreate(savedInstanceState);        StrictMode.ThreadPolicy policy = new StrictMode.ThreadPolicy.Builder().permitAll().build();        StrictMode.setThreadPolicy(policy);        setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);        setContentView(R.layout.activity_main);        Log.d(LOG_TAG, "Запрашиваем разрешение");        if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED                ||                (ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED)        ) {            requestPermissions(new String[]{Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1);        }        mOn = findViewById(R.id.button1);        mOff = findViewById(R.id.button3);        mImageViewUp = findViewById(R.id.textureView);        mImageViewDown = findViewById(R.id.textureView3);        mOn.setOnClickListener(new View.OnClickListener() {            @Override            public void onClick(View v) {                setUpMediaCodec();// инициализируем Медиа Кодек                if (myCameras[CAMERA1] != null) {// открываем камеру                    if (!myCameras[CAMERA1].isOpen()) myCameras[CAMERA1].openCamera();                }            }        });        mOff.setOnClickListener(new View.OnClickListener() {            @Override            public void onClick(View v) {                if (encoder != null) {                    Toast.makeText(MainActivity.this, " остановили стрим", Toast.LENGTH_SHORT).show();                    myCameras[CAMERA1].stopStreamingVideo();                }            }        });        try {            udpSocket = new DatagramSocket();            udpSocketIn = new DatagramSocket(port);// we changed it to DatagramChannell becouse UDP packets may be different in size            try {            }            catch (Exception e){                Log.i(LOG_TAG, "  создали udp канал");            }            new Udp_recipient();            Log.i(LOG_TAG, "  создали udp сокет");        } catch (                SocketException e) {            Log.i(LOG_TAG, " не создали udp сокет");        }        try {            address = InetAddress.getByName(ip_address);            Log.i(LOG_TAG, "  есть адрес");        } catch (Exception e) {        }        mCameraManager = (CameraManager) getSystemService(Context.CAMERA_SERVICE);        try {            // Получение списка камер с устройства            myCameras = new CameraService[mCameraManager.getCameraIdList().length];            for (String cameraID : mCameraManager.getCameraIdList()) {                Log.i(LOG_TAG, "cameraID: " + cameraID);                int id = Integer.parseInt(cameraID);                // создаем обработчик для камеры                myCameras[id] = new CameraService(mCameraManager, cameraID);            }        } catch (CameraAccessException e) {            Log.e(LOG_TAG, e.getMessage());            e.printStackTrace();        }    }    public class CameraService {        private String mCameraID;        private CameraDevice mCameraDevice = null;        private CameraCaptureSession mSession;        private CaptureRequest.Builder mPreviewBuilder;        public CameraService(CameraManager cameraManager, String cameraID) {            mCameraManager = cameraManager;            mCameraID = cameraID;        }        private CameraDevice.StateCallback mCameraCallback = new CameraDevice.StateCallback() {            @Override            public void onOpened(CameraDevice camera) {                mCameraDevice = camera;                Log.i(LOG_TAG, "Open camera  with id:" + mCameraDevice.getId());                startCameraPreviewSession();            }            @Override            public void onDisconnected(CameraDevice camera) {                mCameraDevice.close();                Log.i(LOG_TAG, "disconnect camera  with id:" + mCameraDevice.getId());                mCameraDevice = null;            }            @Override            public void onError(CameraDevice camera, int error) {                Log.i(LOG_TAG, "error! camera id:" + camera.getId() + " error:" + error);            }        };        private void startCameraPreviewSession() {            try {                mPreviewBuilder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_RECORD);                mPreviewBuilder.addTarget(mEncoderSurface);                mCameraDevice.createCaptureSession(Arrays.asList(mEncoderSurface),                        new CameraCaptureSession.StateCallback() {                            @Override                            public void onConfigured(CameraCaptureSession session) {                                mSession = session;                                try {                                    mSession.setRepeatingRequest(mPreviewBuilder.build(), null, mBackgroundHandler);                                } catch (CameraAccessException e) {                                    e.printStackTrace();                                }                            }                            @Override                            public void onConfigureFailed(CameraCaptureSession session) {                            }                        }, mBackgroundHandler);            } catch (CameraAccessException e) {                e.printStackTrace();            }        }        public boolean isOpen() {            if (mCameraDevice == null) {                return false;            } else {                return true;            }        }        public void openCamera() {            try {                if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {                    mCameraManager.openCamera(mCameraID, mCameraCallback, mBackgroundHandler);                }            } catch (CameraAccessException e) {                Log.i(LOG_TAG, e.getMessage());            }        }        public void closeCamera() {            if (mCameraDevice != null) {                mCameraDevice.close();                mCameraDevice = null;            }        }        public void stopStreamingVideo() {            if (mCameraDevice != null & encoder != null) {                try {                    mSession.stopRepeating();                    mSession.abortCaptures();                } catch (CameraAccessException e) {                    e.printStackTrace();                }                encoder.stop();                encoder.release();                mEncoderSurface.release();                decoder.stop();                decoder.release();                decoder2.stop();                decoder2.release();                closeCamera();            }        }    }    private void setUpMediaCodec() {        try {            encoder = MediaCodec.createEncoderByType("video/avc"); // H264 кодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету кодека");        }        {            int width = 640; // ширина видео            int height = 480; // высота видео            int colorFormat = MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface; // формат ввода цвета            int videoBitrate = 2000000; // битрейт видео в bps (бит в секунду)            int videoFramePerSecond = 30; // FPS            int iframeInterval = 1; // I-Frame интервал в секундах            MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);            format.setInteger(MediaFormat.KEY_COLOR_FORMAT, colorFormat);            format.setInteger(MediaFormat.KEY_BIT_RATE, videoBitrate);            format.setInteger(MediaFormat.KEY_FRAME_RATE, videoFramePerSecond);            format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iframeInterval);            encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); // конфигурируем кодек как кодер           mEncoderSurface = encoder.createInputSurface(); // получаем Surface кодера        }        encoder.setCallback(new EncoderCallback());        encoder.start(); // запускаем кодер        Log.i(LOG_TAG, "запустили кодек");        try {            decoder = MediaCodec.createDecoderByType("video/avc");// H264 декодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету декодека");        }        int width = 480; // ширина видео        int height = 640; // высота видео        MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);        format.setInteger(MediaFormat.KEY_ROTATION,90);        SurfaceTexture texture= mImageViewUp.getSurfaceTexture();        mDecoderSurface = new Surface(texture);        decoder.configure(format, mDecoderSurface, null,0);        decoder.setOutputSurface(mDecoderSurface);        decoder.setCallback(new DecoderCallback());        decoder.start();        Log.i(LOG_TAG, "запустили декодер");        try {            decoder2 = MediaCodec.createDecoderByType("video/avc");// H264 декодек        } catch (Exception e) {            Log.i(LOG_TAG, "а нету декодека");        }        int width2 = 480; // ширина видео        int height2 = 640; // высота видео        MediaFormat format2 = MediaFormat.createVideoFormat("video/avc", width2, height2);        format2.setInteger(MediaFormat.KEY_ROTATION,90);        SurfaceTexture texture2= mImageViewDown.getSurfaceTexture();        mDecoderSurface2 = new Surface(texture2);        decoder2.configure(format2, mDecoderSurface2, null,0);        decoder2.setOutputSurface(mDecoderSurface2);        decoder2.setCallback(new DecoderCallback2());        decoder2.start();        Log.i(LOG_TAG, "запустили декодер");    }    private class DecoderCallback2 extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {            decoderInputBuffer2 = codec.getInputBuffer(index);            decoderInputBuffer2.clear();            synchronized (out2)            {                b2 =  out2.toByteArray();                out2.reset();            }            decoderInputBuffer2.put(b2);            codec.queueInputBuffer(index, 0, b2.length,0, 0);            if (b2.length!=0)            {                //   Log.i(LOG_TAG, b.length + " декодер вход  "+index );            }        }        @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            {                {                    decoderOutputBuffer2 = codec.getOutputBuffer(index);                    codec.releaseOutputBuffer(index, true);                }            }        }        @Override        public void onError(MediaCodec codec, MediaCodec.CodecException e) {            Log.i(LOG_TAG, "Error: " + e);        }        @Override        public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {            Log.i(LOG_TAG, "decoder output format changed: " + format);        }    }    //CALLBACK FOR DECODER    private class DecoderCallback extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {            decoderInputBuffer = codec.getInputBuffer(index);            decoderInputBuffer.clear();            synchronized (out)            {                b =  out.toByteArray();                out.reset();            }            decoderInputBuffer.put(b);            codec.queueInputBuffer(index, 0, b.length,0, 0);                 if (b.length!=0)            {               //  Log.i(LOG_TAG, b.length + " декодер вход  "+index );            }        }        @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            {                {                    decoderOutputBuffer = codec.getOutputBuffer(index);                    codec.releaseOutputBuffer(index, true);                }            }        }        @Override        public void onError(MediaCodec codec, MediaCodec.CodecException e) {            Log.i(LOG_TAG, "Error: " + e);        }        @Override        public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {            Log.i(LOG_TAG, "decoder output format changed: " + format);        }    }    private class EncoderCallback extends MediaCodec.Callback {        @Override        public void onInputBufferAvailable(MediaCodec codec, int index) {            Log.i(LOG_TAG, " входные буфера готовы" );        }        @Override        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo info) {            outPutByteBuffer = encoder.getOutputBuffer(index);            byte[] outDate = new byte[info.size];            outPutByteBuffer.get(outDate);            try {                //  Log.i(LOG_TAG, " outDate.length : " + outDate.length);                DatagramPacket packet = new DatagramPacket(outDate, outDate.length, address, port);                udpSocket.send(packet);            } catch (IOException e) {                Log.i(LOG_TAG, " не отправился UDP пакет");            }            encoder.releaseOutputBuffer(index, false);        }        @Override        public void onError(MediaCodec codec, MediaCodec.CodecException e) {            Log.i(LOG_TAG, "Error: " + e);        }        @Override        public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {            //  Log.i(LOG_TAG, "encoder output format changed: " + format);        }    }    @Override    public void onPause() {        if (myCameras[CAMERA1].isOpen()) {            myCameras[CAMERA1].closeCamera();        }        stopBackgroundThread();        super.onPause();    }    @Override    public void onResume() {        super.onResume();        startBackgroundThread();    }    public class Udp_recipient extends Thread {        Udp_recipient() {            start();            //    Log.i(LOG_TAG, "запустили прием данных по udp");        }        public void run() {            while (true) {                try {                    byte buffer[] = new byte[50000];                    DatagramPacket p = new DatagramPacket(buffer, buffer.length);                    udpSocketIn.receive(p);                    byte bBuffer[] = p.getData();                    outDataForEncoder = new byte[p.getLength()];                    synchronized (outDataForEncoder) {                        for (int i = 0; i < outDataForEncoder.length; i++) {                            outDataForEncoder[i] = bBuffer[i];                        }                    }                    mNewFrame = true;                    synchronized (out)                    {out.write(outDataForEncoder);}                    synchronized (out2)                    {out2.write(outDataForEncoder);}                } catch (Exception e) {                    Log.i(LOG_TAG, e + "hggh ");                }            }        }    }}       



Теперь в таргет адресе первого смартофона:

String ip_address = " допустим 192.168.50.131";

указываем адрес его визави и наоборот. Запускаем приложения, надеваем VR-очки и вперёд, двигать науку нейробиологию!
Подробнее..

Рисуем светом длинная выдержка на Android

21.05.2021 22:21:42 | Автор: admin

Всем привет, меня зовут Дмитрий, и я Android-разработчик в компании MEL Science. Сегодня я хочу рассказать, как можно реализовать поддержку длинной выдержки на смартфонах, да так, чтобы получающуюся картинку можно было наблюдать прямо в процессе создания. А для заинтересовавшихся в конце статьи я подготовил ссылку на тестовое приложение - чтобы вы могли сами сделать крутое фото с длинной выдержкой.

Длинная выдержка

Выдержка - термин из мира фотографии, который определяет время открытия затвора при съемке. Чем дольше открыт затвор, тем дольше свет экспонирует светочувствительную матрицу. Проще говоря, делает фотографию более яркой. Современные фотоаппараты используют выдержки длинной в 1/2000 cекунды, что позволяет получить освещенную, но при этом не пересвеченную фотографию. Длинная выдержка подразумевает открытие затвора на секунду и больше. При верно выбранной сцене это позволяет получать фантастические фотографии, способные запечатлеть движение света в объективе камеры. Причем фотографировать можно что угодно: ночные улицы с мчащимися машинами или маятник, с укрепленным на нем фонариком, позволяющим выписывать фигуры Лиссажу. А можно вообще рисовать светом самому и получать целые картины-фотографии.

Улицы города, сфотографированные с использованием длинной выдержкиУлицы города, сфотографированные с использованием длинной выдержки

Улицы города, сфотографированные с использованием длинной выдержки

Теория

Для создания эффекта длинной выдержки можно использовать два подхода:

  • аппаратный - состоит в управлении физическим открытием и закрытием затвора

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

Главным недостатком аппаратного подхода является отсутствие возможности наблюдать за процессом появления фотографии онлайн - результат будет виден лишь после закрытия затвора и формирования изображения. Нарисовать что-либо светом человеку без опыта в таком режиме вряд ли удастся. Еще одним недостатком становится ограничение смартфонов на максимальное время выдержки - на Android оно составляет 30 секунд.

Программная эмуляция позволяет избавиться от всех недостатков ценой усложнения кода.

Практика

Для реализации работы с камерой смартфона будем использовать API CameraX. Это обусловлено ее гибкостью и лаконичностью. Также для программного подхода нам потребуется OpenGL ES для работы с изображениями. Данный выбор был сделан так как, это позволит работать напрямую с изображениями в видео памяти и обеспечить минимальную задержку при записи, так как вся обработка изображений происходит в реальном времени.

Аппаратный подход

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

Для тонкой настройки камеры, используем Camera2Interop который позволяет устанавливать флаги настройки камеры вручную, как в Camera2API. Для активации длинной выдержки помимо установки времени выдержки необходимо также отключить автоэкспозицию, т.к. иначе выдержка будет управляться камерой самостоятельно.

val imageCaptureBuilder = ImageCapture.Builder()Camera2Interop.Extender(imageCaptureBuilder).apply {   setCaptureRequestOption(    CaptureRequest.CONTROL_AE_MODE,    CaptureRequest.CONTROL_AE_MODE_OFF  )  setCaptureRequestOption(    CaptureRequest.SENSOR_EXPOSURE_TIME,    EXPOSURE_TIME_SEC * NANO_IN_SEC  )}

Кстати, для каждой камеры диапазон допустимого интервала выдержки может быть разным, чтобы его узнать необходимо запросить cameraCharacteristics

val manager = getSystemService(CAMERA_SERVICE) as CameraManagerfor (cameraId in manager.cameraIdList) {  val chars = manager.getCameraCharacteristics(cameraId)  val range = chars.get(CameraCharacteristics.SENSOR_INFO_EXPOSURE_TIME_RANGE)    Log.e("CameraCharacteristics", "Camera $cameraId range: ${range.toString()}")}

Программный подход

Для начала определимся с общей идеей нашей реализации.

  1. Нам потребуется буффер для хранения формирующегося изображения, а также регулярно обновляемое изображение с камеры.

  2. Каждый новый кадр будем объединять с хранящимся в буффере, обрабатывая попиксельно и оставляя в буфере пиксель с наибольшей яркостью.

  3. Для того чтобы придать нашим картинкам эффект постепенного исчезновения света можем долго неизменяемые пиксели постепенно затемнять.

Я не буду приводить здесь весь код рендеринга, так как это займет слишком много места. Для желающих код можно найти здесь. Представлю лишь блок схему, объясняющую последовательность действий для получения очередного кадра на экране.

Как видно из схемы, основная магия происходит при объединении 2х кадров: сохраненного в фреймбуфере и полученного с камеры. Рассмотрим шейдер для этой задачи подробнее.

#extension GL_OES_EGL_image_external : requireprecision mediump float;uniform mat4 stMatrix;uniform texType0 tex_sampler;uniform texType1 old_tex_sampler;varying vec2 v_texcoord;void main() {        vec4 color = texture2D(tex_sampler, (stMatrix * vec4(v_texcoord.xy, 0, 1)).xy);    vec4 oldColor = texture2D(old_tex_sampler, v_texcoord);      float oldBrightness = oldColor.r * 0.2126 + oldColor.g * 0.7152 + oldColor.b * 0.0722 + oldColor.a;     float newBrightness = color.r * 0.2126 + color.g * 0.7152 + color.b * 0.0722 + color.a;  // объединяем пиксели}

Работа шейдера состоит из нескольких этапов:

  1. К текстуре камеры мы применяем матрицу для получения верной ориентации изображения.

  2. Затем вычисляем яркость пикселя на обоих кадрах

  3. Объединяем пиксели

Для того чтобы понять, как правильно объединить пиксели, необходимо вспомнить принцип работы длинной выдержки - длинная выдержка позволяет свету попадать на матрицу камеры длительное время и сохраняться до конца съемки. То есть, если в течение процесса фотографии, в разных местах кадра будет появляться свет, то он должен оставаться в кадре до конца. При этом, длительность освещения пикселя не важна, т.к. однажды попавший в кадр свет сохранится до завершения съемки, даже если все остальное время свет на этот пиксель попадать не будет.

Очевидным в таком случае является накопление света за счет сложения значений с последовательных кадров. Однако такое решение позволит снимать лишь в условиях минимальной освещенности и будет приводить к быстрому засвету пикселя. Во избежание данных проблем в качестве способа объединения пикселей была выбрана функция максимума. Она позволяет обновить значение пикселя только, если новый кадр содержит более освещенный пиксель в данной позиции.

Тогда объединение пикселей будет выглядеть вот так:

if (newBrightness > oldBrightness) {  gl_FragColor = color;} else {  gl_FragColor = oldColor;}

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

Длинная выдержкаДлинная выдержка

Длинная выдержка

Однако любая ошибка при рисовании требует перезапуска камеры, т.к. один раз попавший на нее свет уже нельзя стереть! Такое поведение приемлемо при выполнении каких-то заранее спланированных фотографий, но что если мы хотим просто рисовать светом и сохранять изображение, лишь когда нам действительно понравился результат? Перезапускать постоянно камеру совсем неудобно. Значит свет все-таки должен пропадать через какое то время. Этого можно добиться с помощью постепенного затухания ярких пикселей. Чтобы добиться такого эффекта достаточно просто на каждом новом шаге добавлять к каждому пикселю немного черного цвета (чтобы сохранять корректность картинки мы будем добавлять не черный цвет, а просто более темный пиксель из доступных - это позволит и эффект угасания получить и сохранить гамму цветов). Тогда объединение пикселей будет выглядеть следующим образом

if (newBrightness > oldBrightness) {    gl_FragColor = mix(color, oldColor, 0.01);} else {   gl_FragColor = mix(oldColor, color, 0.01);}

Вот несколько примеров с разными коэффициентами и временем затухания света.

Коэффициент 0.001Коэффициент 0.001

Коэффициент 0.001

Коэффициент 0.01Коэффициент 0.01

Коэффициент 0.01

Коэффициент 0.5Коэффициент 0.5

Коэффициент 0.5

Заключение

 Вот несколько примеров, что умеет получившееся приложение. Все таки я не художник :( А что нарисуете вы? Вот несколько примеров, что умеет получившееся приложение. Все таки я не художник :( А что нарисуете вы?

Вот несколько примеров, что умеет получившееся приложение. Все таки я не художник :( А что нарисуете вы?

На этом на сегодня все. Для желающих попробовать самому полный код приложения и apk можно найти здесь.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru