とらのメモ

IT関係やガジェットについての雑記

Swift5で無音カメラを作る

概要

qiita.com

勉強目的で上記の記事をベースに、 リファクタリングしたり、機種ごとの機能に対応させつつ書きました。

ベースの撮影、保存箇所については私は何もしてません、何も。

主な変更箇所

ズーム機能

iPhone11以降のiPhoneには超広角カメラが実装されています。

超広角カメラ対応機種

  • 14 Pro / 14 Pro Max
  • 14 / 14 Plus
  • 13 Pro / 13 Pro Max
  • 13 / 13 mini
  • 12 Pro / 12 Pro Max
  • 12 / 12 mini
  • 11 Pro / 11 Pro Max
  • 11

超広角カメラ+望遠カメラ対応機種

  • 14 Pro / 14 Pro Max
  • 13 Pro / 13 Pro Max
  • 12 Pro / 12 Pro Max
  • 11 Pro / 11 Pro Max

AVCaptureDevice.DiscoverySessionを使って、使用している端末が条件を満たしているかを確認できます。

let deviceTypes: [AVCaptureDevice.DeviceType]で、複数の条件をリストにして入力していますが、 トリプル→デュアル→ワイド(単眼)のような順番で記載をしないと、トリプルカメラに対応しているのにデュアルが適用されて望遠カメラが使えなくなるということが起きます。

func setupCamera(_ cameraPosition: AVCaptureDevice.Position){
// AVCaptureSession: キャプチャに関する入力と出力の管理
session = AVCaptureSession()
// sessionPreset: キャプチャ・クオリティの設定
session.sessionPreset = .high
let deviceTypes: [AVCaptureDevice.DeviceType] = [
.builtInTripleCamera,
.builtInDualWideCamera,
.builtInDualCamera,
.builtInWideAngleCamera,
]
let discoverySession = AVCaptureDevice.DiscoverySession(deviceTypes: deviceTypes, mediaType: .video, position: cameraPosition)
let devices = discoverySession.devices
guard !devices.isEmpty else { fatalError("デバイスがありません.")}
// 背面カメラを取得
camera = devices.first
// カメラからの入力データ
do {
input = try AVCaptureDeviceInput(device: camera) as AVCaptureDeviceInput
} catch let error as NSError {
print(error)
}

ズームの上限下限は端末によって範囲が異なるので、定数ではなく取得するように変更しました。

@objc func pinchedGesture(gestureRecgnizer: UIPinchGestureRecognizer) {
do {
try camera.lockForConfiguration()
// ズームの最大値
let maxZoomScale: CGFloat = camera.maxAvailableVideoZoomFactor
// ズームの最小値
let minZoomScale: CGFloat = camera.minAvailableVideoZoomFactor
...

左下に倍率を表示してタップすると光学ズームの倍率へ切り替わる機能を追加しました。 望遠カメラ(6.0x〜)はフォーカスが近くのものにあっていると広角カメラのままになります。

こちらもデバイスによってズーム倍率が異なるので var virtualDeviceSwitchOverVideoZoomFactors: [NSNumber] { get }で、使用している仮想キャプチャデバイスの倍率を取得しています。

13pro(3眼)だと、[2, 6]のような結果が返ってきますが、 ドキュメントを見ると、最小値の倍率1は省略された状態で返ってくるのが正しいようです。

developer.apple.com

    func setupFixedZoomButton() {
var children: [UIMenuElement] = [
UIAction(title: "1.0x", image: UIImage(systemName: "camera.aperture"), handler: { _ in
self.changeFixedZoom(1)
})]
let zoomList = camera.virtualDeviceSwitchOverVideoZoomFactors
zoomList.forEach { mag in
let zoomStr = String(format: "%.1fx", mag.floatValue)
children.append(
UIAction(title: zoomStr, image: UIImage(systemName: "camera.aperture"), handler: { _ in
self.changeFixedZoom(mag)
self.zoomButton.setTitle(zoomStr, for: .normal)
}))
}
let items = UIMenu(options: .displayInline, children: children)
zoomButton.menu = UIMenu(title: "", children: [items])
zoomButton.showsMenuAsPrimaryAction = true
}

使用しているカメラの切り替わりですが トリプルカメラでオートフォーカスしている場合、 - 1.0 〜 2.0xは超広角カメラ - 2.0 〜 6.0xは広角カメラ - 6.0x 〜 ピントが遠くにあっている場合は望遠カメラ

という形で切り替わる仕様みたいです。

こんな感じで光学ズームの倍率をプルダウンで表示して、選択できるようにしてます。 6.0xは、フォーカスによっては広角カメラのデジタルズームになるだけなので注意。 固定で望遠カメラにしたい場合は別に処理を書く必要がありそうです。

前後切り替え

フロントカメラとバックカメラの切り替え機能を追加しました。 .backがメインのカメラ側になります。

func reverseCameraPosition() {
self.session?.stopRunning()
self.session?.inputs.forEach { input in
self.session?.removeInput(input)
}
self.session?.outputs.forEach { output in
self.session?.removeOutput(output)
}
// prepare new camera preview
let newCameraPosition: AVCaptureDevice.Position = self.camera?.position == .front ? .back : .front
setupCamera(newCameraPosition)
let newVideoLayer: AVCaptureVideoPreviewLayer = AVCaptureVideoPreviewLayer(session: self.session)
newVideoLayer.frame = self.view.bounds
newVideoLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill
self.videoPreviewLayer = newVideoLayer
self.view.layer.replaceSublayer(self.videoPreviewLayer!, with: newVideoLayer)
oldZoomScale = 1.0
}

全体のコード

開発環境

  • macOS Montrey 12.6
  • Xcode Version 14.0.1 (14A400)

動作環境

import UIKit
import AVFoundation
import Photos
class CameraViewController: UIViewController, AVCaptureVideoDataOutputSampleBufferDelegate, UIGestureRecognizerDelegate {
var input:AVCaptureDeviceInput!
var output:AVCaptureVideoDataOutput!
var session:AVCaptureSession!
var camera:AVCaptureDevice!
var imageView:UIImageView!
var zoomButton:UIButton!
var videoPreviewLayer:AVCaptureVideoPreviewLayer?
var fixedZoomList: [NSNumber] = []
var oldZoomScale: CGFloat = 1.0
override func viewDidLoad() {
super.viewDidLoad()
PHPhotoLibrary.requestAuthorization({_ in })
// 画面タップでピントをあわせる
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(CameraViewController.tappedScreen(gestureRecognizer:)))
let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(CameraViewController.pinchedGesture(gestureRecgnizer:)))
// デリゲートをセット
tapGesture.delegate = self
// Viewにタップ、ピンチのジェスチャーを追加
self.view.addGestureRecognizer(tapGesture)
self.view.addGestureRecognizer(pinchGesture)
//下側の写真撮るようのview
let underView = UIView(frame: CGRect(origin: CGPoint(x: 0,y :0), size: CGSize(width: self.view.frame.size.width, height:self.view.frame.size.height/8)))
underView.center = CGPoint(x: self.view.frame.size.width/2, y: self.view.frame.size.height-underView.frame.size.height/2)
underView.backgroundColor = UIColor.black.withAlphaComponent(0.4)
self.view.addSubview(underView)
//シャッターボタンを追加
let shutterButton = UIButton(frame: CGRect(origin: CGPoint(x: 0,y :0), size: CGSize(width: underView.frame.size.height-15, height: underView.frame.size.height-15)))
shutterButton.center = CGPoint(x: underView.frame.size.width/2, y: underView.frame.size.height/2)
shutterButton.backgroundColor = UIColor.white.withAlphaComponent(0)
shutterButton.layer.masksToBounds = true
shutterButton.layer.cornerRadius = shutterButton.frame.size.width/2
shutterButton.layer.borderColor = UIColor.white.cgColor
shutterButton.layer.borderWidth = 6
shutterButton.addTarget(self, action: #selector(tapedShutterButton(sender:)), for: .touchUpInside)
underView.addSubview(shutterButton)
let shutterShadowView = UIView(frame: CGRect(origin: CGPoint(x: 0, y: 0), size: CGSize(width: shutterButton.frame.size.height-18, height: shutterButton.frame.size.height-18)))
shutterShadowView.center = CGPoint(x: shutterButton.frame.size.width/2, y: shutterButton.frame.size.height/2)
shutterShadowView.backgroundColor = UIColor.white
shutterShadowView.layer.masksToBounds = true
shutterShadowView.layer.cornerRadius = shutterShadowView.frame.size.width/2
shutterShadowView.isUserInteractionEnabled = false
shutterButton.addSubview(shutterShadowView)
// MARK: カメラ切り替えボタン
let reverseButton = UIButton()
reverseButton.setImage(UIImage(systemName: "arrow.triangle.2.circlepath.camera.fill"), for: .normal)
reverseButton.imageView?.tintColor = .white
reverseButton.setTitleColor(.white, for: .normal)
reverseButton.sizeToFit()
reverseButton.center = CGPoint(x: (underView.frame.size.width+shutterButton.center.x+shutterButton.frame.size.width/2)/2,
y: underView.frame.size.height/2)
reverseButton.addTarget(self, action: #selector(onTapedReverseCamera(sender:)), for: .touchUpInside)
underView.addSubview(reverseButton)
zoomButton = UIButton()
zoomButton.setTitleColor(UIColor.white, for: .normal)
zoomButton.setTitle("1.0 x", for: .normal)
zoomButton.sizeToFit()
zoomButton.center = CGPoint(x: 40,
y: underView.frame.size.height/2)
zoomButton.titleLabel?.lineBreakMode = .byCharWrapping
underView.addSubview(zoomButton)
}
override func viewWillAppear(_ animated: Bool) {
// スクリーン設定
setupDisplay()
// カメラの設定
setupCamera(.back)
setupFixedZoomButton()
}
// メモリ解放
override func viewDidDisappear(_ animated: Bool) {
// camera stop メモリ解放
session.stopRunning()
for output in session.outputs {
session.removeOutput(output)
}
for input in session.inputs {
session.removeInput(input)
}
session = nil
camera = nil
}
func setupDisplay(){
//スクリーンの幅
let screenWidth = UIScreen.main.bounds.size.width;
//スクリーンの高さ
let screenHeight = UIScreen.main.bounds.size.height;
// カメラからの映像を映すimageViewの作成
if let iv = imageView {
//以前のimageViewがあれば剥がしておく(imageViewが残っていないか確認最初は入ってない)
iv.removeFromSuperview()
}
imageView = UIImageView()
//縦横比ちゃんとする
imageView.contentMode = .scaleAspectFit
//サイズ合わせて追加
imageView.frame = CGRect(x: 0.0, y: 0.0, width: screenWidth ,height: screenHeight)
view.addSubview(imageView)
view.sendSubviewToBack(imageView)
}
func setupCamera(_ cameraPosition: AVCaptureDevice.Position){
// AVCaptureSession: キャプチャに関する入力と出力の管理
session = AVCaptureSession()
// sessionPreset: キャプチャ・クオリティの設定
session.sessionPreset = .high
let deviceTypes: [AVCaptureDevice.DeviceType] = [
.builtInTripleCamera,
.builtInDualWideCamera,
.builtInDualCamera,
.builtInWideAngleCamera,
]
let discoverySession = AVCaptureDevice.DiscoverySession(deviceTypes: deviceTypes, mediaType: .video, position: cameraPosition)
let devices = discoverySession.devices
guard !devices.isEmpty else { fatalError("デバイスがありません.")}
// 背面カメラを取得
camera = devices.first
// カメラからの入力データ
do {
input = try AVCaptureDeviceInput(device: camera) as AVCaptureDeviceInput
} catch let error as NSError {
print(error)
}
// 入力をセッションに追加
if(session.canAddInput(input)) {
session.addInput(input)
}
// AVCaptureVideoDataOutput:動画フレームデータを出力に設定
output = AVCaptureVideoDataOutput()
// 出力をセッションに追加
if(session.canAddOutput(output)) {
session.addOutput(output)
}
// ピクセルフォーマットを 32bit BGR + A とする
output.videoSettings = nil
// フレームをキャプチャするためのサブスレッド用のシリアルキューを用意
output.setSampleBufferDelegate(self, queue: DispatchQueue.main)
//画面が90度回転してしまう対策
let connection  = output.connection(with: .video)
connection?.videoOrientation = .portrait
output.alwaysDiscardsLateVideoFrames = true
DispatchQueue.global(qos: .background).async {
self.session.startRunning()
}
// deviceをロックして設定
do {
try camera.lockForConfiguration()
// フレームレート
camera.activeVideoMinFrameDuration = CMTimeMake(value: 1, timescale: 30)
camera.unlockForConfiguration()
} catch _ {
}
}
func setupFixedZoomButton() {
var children: [UIMenuElement] = [
UIAction(title: "1.0x", image: UIImage(systemName: "camera.aperture"), handler: { _ in
self.changeFixedZoom(1)
})]
let zoomList = camera.virtualDeviceSwitchOverVideoZoomFactors
zoomList.forEach { mag in
let zoomStr = String(format: "%.1fx", mag.floatValue)
children.append(
UIAction(title: zoomStr, image: UIImage(systemName: "camera.aperture"), handler: { _ in
self.changeFixedZoom(mag)
self.zoomButton.setTitle(zoomStr, for: .normal)
}))
}
let items = UIMenu(options: .displayInline, children: children)
zoomButton.menu = UIMenu(title: "", children: [items])
zoomButton.showsMenuAsPrimaryAction = true
}
// 新しいキャプチャの追加で呼ばれる
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection){
// キャプチャしたsampleBufferからUIImageを作成
let image:UIImage = self.captureImage(sampleBuffer: sampleBuffer)
// カメラの画像を画面に表示
DispatchQueue.main.async() {
self.imageView.image = image
}
}
// sampleBufferからUIImageを作成
func captureImage(sampleBuffer:CMSampleBuffer) -> UIImage{
let imageBuffer: CVPixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)!
let ciimage : CIImage = CIImage(cvPixelBuffer: imageBuffer)
let image : UIImage = self.convert(cmage: ciimage)
return image
}
// Convert CIImage to CGImage
func convert(cmage:CIImage) -> UIImage{
let context:CIContext = CIContext.init(options: nil)
let cgImage:CGImage = context.createCGImage(cmage, from: cmage.extent)!
let image:UIImage = UIImage.init(cgImage: cgImage)
return image
}
// タップイベント.
@objc func tapedShutterButton(sender: UIButton) {
let settings = AVCapturePhotoSettings()
settings.flashMode = .off
takeStillPicture()
self.imageView.alpha = 0.4
UIView.animate(withDuration: 0.5, animations: {
self.imageView.alpha = 1
})
}
func takeStillPicture(){
if var _:AVCaptureConnection = output.connection(with: AVMediaType.video){
// アルバムに追加
UIImageWriteToSavedPhotosAlbum(self.imageView.image!, self, nil, nil)
}
}
@objc func onTapedReverseCamera(sender: UIButton) {
self.reverseCameraPosition()
}
func reverseCameraPosition() {
self.session?.stopRunning()
self.session?.inputs.forEach { input in
self.session?.removeInput(input)
}
self.session?.outputs.forEach { output in
self.session?.removeOutput(output)
}
// prepare new camera preview
let newCameraPosition: AVCaptureDevice.Position = self.camera?.position == .front ? .back : .front
setupCamera(newCameraPosition)
let newVideoLayer: AVCaptureVideoPreviewLayer = AVCaptureVideoPreviewLayer(session: self.session)
newVideoLayer.frame = self.view.bounds
newVideoLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill
self.videoPreviewLayer = newVideoLayer
self.view.layer.replaceSublayer(self.videoPreviewLayer!, with: newVideoLayer)
oldZoomScale = 1.0
}
let focusView = UIView()
@objc func tappedScreen(gestureRecognizer: UITapGestureRecognizer) {
let tapCGPoint = gestureRecognizer.location(ofTouch: 0, in: gestureRecognizer.view)
focusView.frame.size = CGSize(width: 120, height: 120)
focusView.center = tapCGPoint
focusView.backgroundColor = UIColor.white.withAlphaComponent(0)
focusView.layer.borderColor = UIColor.white.cgColor
focusView.layer.borderWidth = 2
focusView.alpha = 1
imageView.addSubview(focusView)
UIView.animate(withDuration: 0.5, animations: {
self.focusView.frame.size = CGSize(width: 80, height: 80)
self.focusView.center = tapCGPoint
}, completion: { Void in
UIView.animate(withDuration: 0.5, animations: {
self.focusView.alpha = 0
})
})
self.focusWithMode(focusMode: AVCaptureDevice.FocusMode.autoFocus, exposeWithMode: AVCaptureDevice.ExposureMode.autoExpose, atDevicePoint: tapCGPoint, motiorSubjectAreaChange: true)
}
@objc func pinchedGesture(gestureRecgnizer: UIPinchGestureRecognizer) {
do {
try camera.lockForConfiguration()
// ズームの最大値
let maxZoomScale: CGFloat = camera.maxAvailableVideoZoomFactor
// ズームの最小値
let minZoomScale: CGFloat = camera.minAvailableVideoZoomFactor
// 現在のカメラのズーム度
var currentZoomScale: CGFloat = camera.videoZoomFactor
// ピンチの度合い
let pinchZoomScale: CGFloat = gestureRecgnizer.scale
// ピンチアウトの時、前回のズームに今回のズーム-1を指定
// 例: 前回3.0, 今回1.2のとき、currentZoomScale=3.2
if pinchZoomScale > 1.0 {
currentZoomScale = oldZoomScale+pinchZoomScale-1
} else {
currentZoomScale = oldZoomScale-(1-pinchZoomScale)*oldZoomScale
}
// 最小値より小さく、最大値より大きくならないようにする
if currentZoomScale < minZoomScale {
currentZoomScale = minZoomScale
}
else if currentZoomScale > maxZoomScale {
currentZoomScale = maxZoomScale
}
// 画面から指が離れたとき、stateがEndedになる。
if gestureRecgnizer.state == .ended {
oldZoomScale = currentZoomScale
}
camera.videoZoomFactor = currentZoomScale
zoomButton.setTitle(String(format: "%.1fx", currentZoomScale), for: .normal)
camera.unlockForConfiguration()
} catch {
// handle error
return
}
}
func changeFixedZoom(_ num: NSNumber) {
try? camera.lockForConfiguration()
camera.videoZoomFactor = CGFloat(truncating: num)
oldZoomScale = CGFloat(truncating: num)
camera.focusMode = .autoFocus
camera.isSmoothAutoFocusEnabled = true
camera.unlockForConfiguration()
}
func focusWithMode(focusMode : AVCaptureDevice.FocusMode, exposeWithMode expusureMode :AVCaptureDevice.ExposureMode, atDevicePoint point:CGPoint, motiorSubjectAreaChange monitorSubjectAreaChange:Bool) {
DispatchQueue(label: "session queue").async {
let device : AVCaptureDevice = self.input.device
do {
try device.lockForConfiguration()
if(device.isFocusPointOfInterestSupported && device.isFocusModeSupported(focusMode)){
device.focusPointOfInterest = point
device.focusMode = focusMode
}
if(device.isExposurePointOfInterestSupported && device.isExposureModeSupported(expusureMode)){
device.exposurePointOfInterest = point
device.exposureMode = expusureMode
}
device.isSubjectAreaChangeMonitoringEnabled = monitorSubjectAreaChange
device.unlockForConfiguration()
} catch let error as NSError {
print(error.debugDescription)
}
}
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
}

Info.plistに権限のチェックも入れておきましょう。 書いてないと強制終了します。

  • Privacy - Photo Library Usage Description
  • Privacy - Camera Usage Description

終わりに

無音カメラアプリ リジェクトで検索すると、大量ヒットするのですが 日本のAppStoreでは、審査時に弾かれて表向きは無音カメラはリリースできないそうです。

じゃあ既存の無音カメラと銘打ってるアプリは一体...???

以上です。