AVFoundation — пишем простейшую фотокамеру

от автора

Hello World!

Всем привет, меня зовут Эмиль. Я младший iOS разработчик в «3Д Платформа» (джуниор пишет статью, дада я) и несколько месяцев назад я столкнулся с камерой на AVFoundation: мне нужно было добавить камере опциональный зум с 1.0х до 0.5х, если камера поддерживает такой зум. Материалов я нашел очень много, попрактиковался на тестовом проекте, выверил лучшую формулу для зума и интегрировал решение в прод. После выполнения задачи я заметил, что не прочитал ни одного материала на русском, кроме одной статьи на Хабре, датированной 2013 годом. Собственно, это наблюдение и навело меня на мысль о написании своей собственной статьи об AVFoundation — камере. Я решил начать с проекта, на базе которого мы сегодня с вами изучим возможности AVFoundation на лоне работы с камерой (а вообще там можно еще и со звуком поработать). Это моя первая статья, но я не буду просить вас отнестись к ней снисходительно. Думаю, этот материал будет полезен новичкам и джунам, которые никогда не сталкивались с AVFoundation и не знают, какая информация по этой теме реально годна.

Ну что ж, поехали.

Полный проект: https://github.com/Wtclrsnd/RealTrapCamera

Навигация

  • Что такое AVFoundation

  • Пишем UI

  • AVCaptureSession, inputs, outputs, AVCaptureDevice

  • PreviewLayer

  • Переключение между фронтальной и задней камерами

  • Просим у Тимоши разрешение на съемку

  • AVCapturePhotoCaptureDelegate — получение и сохранение фото

  • PinchToZoom

  • Бонус: Haptics по нажатию кнопки

  • Заключение и источники

Что такое AVFoundation?

Процитирую Apple: AVFoundation — это фреймворк для работы с аудиовизуальными медиафайлами на iOS, macOS, watchOS и tvOS. Благодаря AVFoundation можно воспроизводить, создавать и редактировать файлы QuickTime и файлы MPEG-4, воспроизводить потоки HLS и встраивать мощную мультимедийную функциональность в свои приложения.

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

Источник: https://medium.com/@divya.nayak/learning-avfoundation-part-1-c761aad183ad
Источник: https://medium.com/@divya.nayak/learning-avfoundation-part-1-c761aad183ad

Под AVFoundation «лежат» CoreAudio, CoreMedia — названия говорят сами за себя. А также CoreAnimation для представления иерархии видео и презентационного слоя.

Углубимся в тему съемок фото. Представляю вам основные классы AVFoundation, использующиеся для написания камер:

  • AVCaptureDevice — класс, являющийся прямым API к камере устройства

  • AVCaptureDeviceInput — проводит данные от камеры

  • AVCaptureOutput — абстрактный класс, отвечающий за вывод картинки на экран. В этой статье мы будем использовать его сабкласс — AVCapturePhotoOutput

  • AVCaptureSession — обеспечивает связь между инпутом и аутпутом камеры и отвечает за работу камеры в целом.

  • AVCaptureVideoPreviewLayer — сабкласс CALayer, выводящий на экран видео изображение с нашего девайса

Также сегодня мы будем использовать следующие вспомогательные классы:

  •  AVCaptureDevice.DiscoverySession — позволяет подключить специфический CaptureDevice для данного устройства — двойные и тройные камеры, или только одну из них

  • AVCapturePhotoSettings — настройки камеры для конкретного снимка

Пишем UI

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

Пользовательский интерфейс нашей камеры
Пользовательский интерфейс нашей камеры

Верхний и нижний бары и все UI элементы выделены в отдельные классы — BottomBarView, LastPhotoView, CaptureImageButton и TopBarView. А также вынес свой основной цвет для элементов в отдельный файл (Lavanda.swift). Для кнопок поворота камеры и вспышки я использовал SFSymbols и столкнулся с особенностью в их использовании — размер изображений, импортированных из SF, нужно настраивать точно как шрифты.

Обратите внимание на аргумент withConfiguration

button.setImage(UIImage(systemName: "arrow.triangle.2.circlepath", withConfiguration: UIImage.SymbolConfiguration.init(pointSize: 25)), for: .normal)

После написания и сетапа баров на контроллере, нам требуется провести делегат от кнопок съемки и смены фронтальной и тыльной камер:

protocol BottomBarDelegate: AnyObject {          func switchCamera()      func takePhoto() }

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

init(cameraService: CameraService) {          self.cameraService = cameraService          super.init(nibName: nil, bundle: nil) }

Вызовем контроллер с сервисом в ините через метод SceneDelegate, запустим приложение и увидим, что все готово! Таки перейдем к самому интересному — логике сервиса.

AVCaptureSession, inputs, outputs, AVCaptureDevice

Зайдем в CameraService. Первое что нам нужно будет сделать — настроить вводы (инпуты) выводы (аутпуты) и саму сессию. Добавим нужные проперти в начало класса:

    private var captureDevice: AVCaptureDevice?     private var backCamera: AVCaptureDevice?     private var frontCamera: AVCaptureDevice?      private var backInput: AVCaptureInput!     private var frontInput: AVCaptureInput!     private let cameraQueue = DispatchQueue(label: "com.shpeklord.CapturingModelQueue")      private var startZoom: CGFloat = 2.0     private let zoomLimit: CGFloat = 10.0      private var backCameraOn = true      weak var delegate: CameraServiceDelegate?      let captureSession = AVCaptureSession()     let photoOutput = AVCapturePhotoOutput()

captureDeviceтекущая камера, с которой мы получаем изображение в данный момент. Может быть задней или фронтальной.

backCamera — наша задняя камера, которую мы будем сетапить через DiscoverySession чуть позже

frontCameraфронтальная камера

backInputинпут для задней камеры

frontInputинпут для фронтальной камеры

captureSessionсессия, которую мы сейчас будем конфигурировать

photoOutputаутпут, с которого мы будем получать изображение

Подсасы оценят
Подсасы оценят

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

Далее займемся подключением девайса. Тут в игру вступает DiscoverySession! Наша задача — подключить к приложению именно тот девайс, который поддерживается нашим устройством. Если вы используете приложение на iPhone Pro, то вы хотите получить тройную камеру. На не-pro телефоне понадобится камера с ультрашириком, а на более старых моделях с одним шириком или шириком/телевиком, вы получите соответствующий AVCaptureDevice. Благодаря DiscoverySession мы можем быстро определить тип камеры на устройстве и непосредственно подключить его к аутпуту. Виды камер:

Источник - https://developer.apple.com/documentation/avfoundation/avcapturedevice/devicetype
Источник — https://developer.apple.com/documentation/avfoundation/avcapturedevice/devicetype

Функция поиска нужной камеры выглядит так:

private func currentDevice() -> AVCaptureDevice? {          let discoverySession = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInTripleCamera, .builtInDualWideCamera, .builtInDualCamera, .builtInWideAngleCamera],                                                                  mediaType: .video,                                                                  position: .back)         guard let device = discoverySession.devices.first          else {             return nil         }         if device.deviceType == .builtInDualCamera || device.deviceType == .builtInWideAngleCamera {             startZoom = 1.0 // об этом чуть позже         }          return device  }

Обратите внимание на строку номер 3: мы расположили возможные девайсы в определенном порядке. Если используется iPhone Pro, первой в массиве окажется тройная камера. iPhone 11-14 — камера с ультрашириком. iPhone X/XS — получим камеру с телевиком. А на последнем месте расположилась простая широкоугольная камера, доступная в каждом девайсе. Также хочу отметить, что 2 и 3 камеры доступны для iPhone Pro.

Теперь, когда мы получили CaptureDevice, можно начать настройку инпутов:

private func setupInputs() {          backCamera = currentDevice() // получаем актуальный девайс задней камеры         frontCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) // подключаем фронталку          guard let backCamera = backCamera,               let frontCamera = frontCamera         else {             return         }          do {             backInput = try AVCaptureDeviceInput(device: backCamera)              guard captureSession.canAddInput(backInput) else {                 return             }              frontInput = try AVCaptureDeviceInput(device: frontCamera)              guard captureSession.canAddInput(frontInput) else {                 return             }         } catch {             fatalError("could not connect camera")         }          captureDevice = backCamera // сетапим заднюю камеру          captureSession.addInput(backInput) // добавляем инпут к сессии         if backCamera.deviceType == .builtInDualWideCamera || backCamera.deviceType == .builtInTripleCamera {             updateZoom(scale: startZoom) // об этом чуть позже         }   }

Сетапим аутпут:

private func setupOutput() {          guard captureSession.canAddOutput(photoOutput) else {             return         }          photoOutput.isHighResolutionCaptureEnabled = true         photoOutput.maxPhotoQualityPrioritization = .balanced         captureSession.addOutput(photoOutput) }

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

Функция сетапа сессии:

private func setupAndStartCaptureSession() {          cameraQueue.async { [weak self] in             self?.captureSession.beginConfiguration() // открываем сессию для конфигурации             if let canSetSessionPreset = self?.captureSession.canSetSessionPreset(.photo), canSetSessionPreset {                 self?.captureSession.sessionPreset = .photo             } // делаем пресет для фотографий             self?.captureSession.automaticallyConfiguresCaptureDeviceForWideColor = true // ставим возможность использования цветового пространства RGB нашей камерой              self?.setupInputs() // опаньки, что-то знакомое ;)             self?.setupOutput()                self?.captureSession.commitConfiguration()             self?.captureSession.startRunning() // тот самый блокирующий вызов         } }

Теперь вызовем setupAndStartCaptureSession() в ините нашего CameraManager. До получения превью осталось всего лишь несколько шагов.

PreviewLayer

Пожалуй, самая простая часть сетапа камеры — вывод превью. Зайдем в CamViewController и добавим новый приватный метод.

private func setupPreviewLayer() {         let previewLayer = AVCaptureVideoPreviewLayer(session: cameraService.captureSession) as AVCaptureVideoPreviewLayer          previewLayer.frame = view.bounds         previewLayer.videoGravity = .resizeAspectFill         view.layer.addSublayer(previewLayer) } 

Из незнакомого и интересного здесь можно заметить проперти videoGravity, отвечающая за отображение видео в нашем леере. Настройки аналогичны contentMode в UIVew. Мы выбрали настройку .resizeAspectFill для того чтобы леер занимал всю площадь экрана.

Переключение между фронтальной и задней камерами

Помните булеву переменную проперти CameraService? Настало ее время. Как вы уже могли понять по названию, она отвечает за актуальную на данный момент камеру. Переключение между основной и фронтальной камерой осуществляется переключением инпутов. Внимание на код:

func switchCameraInput() {         captureSession.beginConfiguration()         if backCameraOn {             captureSession.removeInput(backInput)             captureSession.addInput(frontInput)             captureDevice = frontCamera             backCameraOn = false         } else {             captureSession.removeInput(frontInput)             captureSession.addInput(backInput)             captureDevice = backCamera             backCameraOn = true             updateZoom(scale: startZoom) // об этом чуть позже         }          photoOutput.connections.first?.videoOrientation = .portrait         photoOutput.connections.first?.isVideoMirrored = !backCameraOn         captureSession.commitConfiguration() }

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

extension CamViewController: BottomBarDelegate {      func switchCamera() {         cameraService.switchCameraInput()     } }

Данная функция вызывается из делегата нижнего бара по нажатию кнопки смены камер.

Просим у Тимоши разрешение на съемку

Как мы будем делать и сохранять фото, если наше приложение не имеет прав на использование камеры и галереи? Нужно запросить разрешение на съемку у iOS. Делается это довольно просто — через info.plist

Нам нужны два разрешения. Внимание на скриншот.

Далее, нам нужно прописать запрос разрешения на съемку у пользователя:

private func checkPermissions() {         let cameraAuthStatus =  AVCaptureDevice.authorizationStatus(for: AVMediaType.video)         switch cameraAuthStatus {         case .authorized:             return         case .denied:             abort()         case .notDetermined:             AVCaptureDevice.requestAccess(for: AVMediaType.video, completionHandler:                                             { (authorized) in                 if(!authorized){                     abort()                 }             })         case .restricted:             abort()         @unknown default:             fatalError()         } }

AVCapturePhotoCaptureDelegate — получение и сохранение фото

Мы плавно приближаемся к финалу. На очереди подключение делегата фотокамеры. Добавим следующий код в конец CameraService:

extension CameraService: AVCapturePhotoCaptureDelegate {      func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {         guard error == nil else {             print("Fail to capture photo: \(String(describing: error))")             return         }         guard let imageData = photo.fileDataRepresentation() else {             return         }         guard let image = UIImage(data: imageData) else {             return         }          DispatchQueue.main.async {             self.delegate?.setPhoto(image: image) // сетим фото на превью нижнего бара             UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) // сохраняем сделанное фото в галерею         }     } }

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

func takePhoto() {         let photoSettings = AVCapturePhotoSettings()         photoSettings.isHighResolutionPhotoEnabled = true         photoSettings.flashMode = topBar.isTorchOn ? .on : .off         cameraService.photoOutput.capturePhoto(with: photoSettings, delegate: cameraService) }

Поговорим об AVCapturePhotoSettings. Этот класс отвечает за настройки нашей камеры в момент нажатия на кнопку. На каждый вызов камеры требуется новый экземпляр AVCapturePhotoSettings. Переиспользовать настройки нельзя. В данном случае мы сетим высокое разрешение картинки и вкл/выкл вспышки, за состояние которой отвечает булева переменная в топ баре. Далее мы вызываем метод capturePhoto и прокидываем туда наши настройки и камера сервис в качестве ответственного объекта. Вызов capturePhoto триггерит метод photoOutput в сервисе, откуда мы и получаем фото.

PinchToZoom

Настало время поставить нашей камере возможность приближать и отдалять. Для этого нам понадобится UIPinchGestureRecognizer. Поставим его в CamViewController:

private func setupZoomRecognizer() {         let zoomRecognizer = UIPinchGestureRecognizer()         zoomRecognizer.addTarget(self, action: #selector(didPinch(_:)))         view.addGestureRecognizer(zoomRecognizer) }  @objc private func didPinch(_ recognizer: UIPinchGestureRecognizer) {         if recognizer.state == .changed {             cameraService.setZoom(scale: recognizer.scale)         } }

didPinch обращается к сервису. Посмотрим имплементацию setZoom и приватный updateZoom в сервисе:

func setZoom(scale: CGFloat) {         guard let zoomFactor = captureDevice?.videoZoomFactor else {             return         }         var newScaleFactor: CGFloat = 0          newScaleFactor = (scale < 1.0         ? (zoomFactor - pow(zoomLimit, 1.0 - scale))         : (zoomFactor + pow(zoomLimit, (scale - 1.0) / 2.0)))          newScaleFactor = minMaxZoom(zoomFactor * scale)         updateZoom(scale: newScaleFactor) }  private func minMaxZoom(_ factor: CGFloat) -> CGFloat { min(max(factor, 1.0), zoomLimit) }  private func updateZoom(scale: CGFloat) {         do {             defer { captureDevice?.unlockForConfiguration() }             try captureDevice?.lockForConfiguration()             captureDevice?.videoZoomFactor = scale         } catch {             print(error.localizedDescription)         } }

Здесь и вступают в игру startZoom и zoomLimit.При сете камеры мы ставили startZoom на 2.0, если камера обладала ультрашириком и 1.0 во всех остальных случаях. Дело в том что камеры с ультрашириком ставят зум 0.5 (в коде 1.0) по умолчанию, а нам бы хотелось открывать камеру на зуме 1.0.

Код выставления зума весьма прост: мы получаем скейл рекогнайзера, определяем в какую сторону был сделан жест (меньше 1.0 — жест на уменьшение) и в зависимости от характера жеста, производим рассчет нового фактора. Дальше мы ограничиваем новый фактор функцией minMaxZoom и вызываем более низкоурвневую функцию updateZoom, которая принимает в себя новый фактор и безопасно сетит его девайсу. Собственно, на этом все.

Бонус: Haptics по нажатию кнопки

Как же пользователь поймет что он сделал фото? Можно сделать анимацию, но мы сегодня собрались не за этим. Я решил оповещать пользователя о факте съемки тактильным откликом устройства. В коде кнопки затвора и смены камер добавим следующие команды:

let generator = UIImpactFeedbackGenerator(style: .medium) generator.impactOccurred()

.medium для кнопки затвора и .light для смены камер. Подробнее о Haptics можно почитать здесь.

Заключение и источники

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

Источники:


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


Комментарии

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

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