Всем привет, меня зовут Дмитрий, и я 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 CameraManager
for (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()}")
}
Программный подход
Для начала определимся с общей идеей нашей реализации.
Нам потребуется буффер для хранения формирующегося изображения, а также регулярно обновляемое изображение с камеры.
Каждый новый кадр будем объединять с хранящимся в буффере, обрабатывая попиксельно и оставляя в буфере пиксель с наибольшей яркостью.
Для того чтобы придать нашим картинкам эффект постепенного исчезновения света можем долго неизменяемые пиксели постепенно затемнять.
Я не буду приводить здесь весь код рендеринга, так как это займет слишком много места. Для желающих код можно найти здесь. Представлю лишь блок схему, объясняющую последовательность действий для получения очередного кадра на экране.
Как видно из схемы, основная магия происходит при объединении 2х кадров: сохраненного в фреймбуфере и полученного с камеры. Рассмотрим шейдер для этой задачи подробнее.
#extension GL_OES_EGL_image_external : require
precision 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;
// объединяем пиксели
}
Работа шейдера состоит из нескольких этапов:
К текстуре камеры мы применяем матрицу для получения верной ориентации изображения.
Затем вычисляем яркость пикселя на обоих кадрах
Объединяем пиксели
Для того чтобы понять, как правильно объединить пиксели, необходимо вспомнить принцип работы длинной выдержки - “длинная выдержка позволяет свету попадать на матрицу камеры длительное время и сохраняться до конца съемки”. То есть, если в течение процесса фотографии, в разных местах кадра будет появляться свет, то он должен оставаться в кадре до конца. При этом, длительность освещения пикселя не важна, т.к. однажды попавший в кадр свет сохранится до завершения съемки, даже если все остальное время свет на этот пиксель попадать не будет.
Очевидным в таком случае является накопление света за счет сложения значений с последовательных кадров. Однако такое решение позволит снимать лишь в условиях минимальной освещенности и будет приводить к быстрому засвету пикселя. Во избежание данных проблем в качестве способа объединения пикселей была выбрана функция максимума. Она позволяет обновить значение пикселя только, если новый кадр содержит более освещенный пиксель в данной позиции.
Тогда объединение пикселей будет выглядеть вот так:
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);
}
Вот несколько примеров с разными коэффициентами и временем затухания света.
Заключение
На этом на сегодня все. Для желающих попробовать самому полный код приложения и apk можно найти здесь.