iosswiftavfoundationdevice-orientation

iOS Camera didFinishProcessingPhoto always giving random wrong orientations


I have created the below code example which reproduces the entire issue.

Note that I have the iOS "Rotation lock" setting off in control centre.

Testing on iPhone 13 running iOS 17.

The image orientation which is being set in imageViewForOutput is always wrong.

When I take a photo in normal portrait, the image is flipped horizontally (aka the right is appearing on left side and left is appearing on right side). The orientation is being printed as 6.

When I turn my device 90 degree clockwise and take a pic, the image being set is rotated 90 degree anti-clockwise. The orientation is being printed as 3.

When I turn my device 90 degree anti-clockwise and take a pic, the image being set is upside down. The orientation is being printed as 1.

I have no idea what is making such random changes to orientation. I have followed other stack overflow posts and am making sure I am setting the orientation changes on the photoOutput.

import UIKit
import SnapKit
import AVFoundation

class ViewController: UIViewController, AVCapturePhotoCaptureDelegate {
    
    var clickerButton = UIButton()
    var flipCameraButton = UIButton()
    var frontCameraDeviceInput: AVCaptureDeviceInput?
    var backCameraDeviceInput: AVCaptureDeviceInput?
    let captureSession = AVCaptureSession()
    let photoOutput = AVCapturePhotoOutput()
    let viewForCameraPreview = UIView()
    var previewLayer : AVCaptureVideoPreviewLayer?
    var imageViewForOutput = UIImageView()

    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .black
        
        view.addSubview(viewForCameraPreview)
        viewForCameraPreview.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
        
        let padding = 15.0
        
        let buttonSize = 70.0
        
        
        clickerButton.addTarget(self, action: #selector(clickPhoto(sender:)), for: .touchUpInside)
        view.addSubview(clickerButton)
        clickerButton.snp.makeConstraints { make in
            make.size.equalTo(buttonSize)
            make.centerX.equalToSuperview()
            make.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom).inset(padding)
        }
        clickerButton.layer.cornerRadius = buttonSize / 2
        clickerButton.clipsToBounds = true
        clickerButton.layer.borderColor = UIColor.white.cgColor
        clickerButton.layer.borderWidth = 5
        
        flipCameraButton.addTarget(self, action: #selector(flipCamera(sender:)), for: .touchUpInside)
        flipCameraButton.setTitle("F", for: .normal)
        flipCameraButton.titleLabel?.font = UIFont.systemFont(ofSize: 50, weight: .bold)
        flipCameraButton.setTitleColor(.white, for: .normal)
        view.addSubview(flipCameraButton)
        flipCameraButton.snp.makeConstraints { make in
            make.size.equalTo(buttonSize)
            make.right.equalTo(clickerButton.snp.left).inset(-padding)
            make.centerY.equalTo(clickerButton)
        }
        
        imageViewForOutput.layer.borderColor = UIColor.white.cgColor
        imageViewForOutput.layer.borderWidth = 2
        imageViewForOutput.contentMode = .scaleAspectFit
        view.addSubview(imageViewForOutput)
        imageViewForOutput.snp.makeConstraints { make in
            make.size.equalTo(200)
            make.top.equalTo(view.safeAreaLayoutGuide.snp.top).offset(padding)
            make.left.equalTo(view.safeAreaLayoutGuide.snp.left).offset(padding)
        }
        
        configureCameras()
        whichCameraStuff()
        startCamera()
    }
    
    @objc func flipCamera(sender: UIButton) {
        sender.isSelected = !sender.isSelected
        whichCameraStuff()
    }

    func configureCameras(){
        if let frontCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) {
            frontCameraDeviceInput = try? AVCaptureDeviceInput(device: frontCamera)
        }
        if let backCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
            backCameraDeviceInput = try? AVCaptureDeviceInput(device: backCamera)
        }
        photoOutput.setPreparedPhotoSettingsArray([AVCapturePhotoSettings(format: [AVVideoCodecKey : AVVideoCodecType.jpeg])], completionHandler: nil)
        if captureSession.canAddOutput(photoOutput) {
            captureSession.addOutput(photoOutput)
        }
    }
    
    func whichCameraStuff(){
        captureSession.beginConfiguration()
        
        if flipCameraButton.isSelected {
            if let b = backCameraDeviceInput, captureSession.inputs.contains(b) {
                captureSession.removeInput(b)
            }
            if let f = frontCameraDeviceInput, !captureSession.inputs.contains(f) {
                captureSession.addInput(f)
            }
        } else {
            if let f = frontCameraDeviceInput, captureSession.inputs.contains(f) {
                captureSession.removeInput(f)
            }
            if let b = backCameraDeviceInput, !captureSession.inputs.contains(b) {
                captureSession.addInput(b)
            }
        }
        
        captureSession.commitConfiguration()
    }
    
    func startCamera(){
        DispatchQueue.global().async {
            self.captureSession.startRunning()
            self.addCameraPreviewIfNeeded()
        }
    }
    
    func addCameraPreviewIfNeeded(){
        DispatchQueue.main.async {
            if self.previewLayer == nil {
                self.previewLayer = AVCaptureVideoPreviewLayer(session: self.captureSession)
                if let pl = self.previewLayer {
                    pl.videoGravity = AVLayerVideoGravity.resizeAspectFill
                    pl.connection?.automaticallyAdjustsVideoMirroring = false
                    self.viewForCameraPreview.layer.insertSublayer(pl, at: 0)
                    pl.frame = self.viewForCameraPreview.bounds
                    self.updateVideoOrientation()
                }
            }
            self.previewLayer?.isHidden = false
        }
    }
    
    func updateVideoOrientation() {
        guard let pl = self.previewLayer, let connection = pl.connection else {
            return
        }
        guard connection.isVideoOrientationSupported else {
            return
        }

        let videoOrientation = UIDevice.current.orientation.asCaptureVideoOrientation

        if connection.videoOrientation == videoOrientation {
            print("no change to videoOrientation")
            return
        }
        
        print("videoOrientation: \(videoOrientation)")
        previewLayer?.connection?.videoOrientation = videoOrientation
        photoOutput.connection(with: AVMediaType.video)?.videoOrientation = videoOrientation
        previewLayer?.frame = viewForCameraPreview.bounds
        previewLayer?.removeAllAnimations()
    }
    
    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)

        coordinator.animate(alongsideTransition: nil, completion: { [weak self] (context) in
            DispatchQueue.main.async(execute: {
                self?.updateVideoOrientation()
            })
        })
    }
    
    @objc func clickPhoto(sender: UIButton) {
        guard captureSession.isRunning else {
            return
        }
        let settings = AVCapturePhotoSettings()
        settings.flashMode = .off
        photoOutput.capturePhoto(with: settings, delegate: self)
    }
    
    func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
        if let e = error {
            print("Error: \(e)")
        } else if let cgImage = photo.cgImageRepresentation() {
            let orientation = photo.metadata[kCGImagePropertyOrientation as String] as! NSNumber
            let imageOrientation = UIImage.Orientation(rawValue: orientation.intValue)!
            print("orientation: \(orientation), imageOrientation: \(imageOrientation)")
            let image = UIImage(cgImage: cgImage, scale: 1, orientation: imageOrientation)
            imageViewForOutput.image = image
        }
    }
}

extension UIDeviceOrientation {
    var asCaptureVideoOrientation: AVCaptureVideoOrientation {
        switch self {
        case .landscapeLeft: return .landscapeRight
        case .landscapeRight: return .landscapeLeft
        case .portraitUpsideDown: return .portraitUpsideDown
        default: return .portrait
        }
    }
}

Solution

  • I figured out the solution. I used the photo.fileDataRepresentation() in didFinishProcessingPhoto and then I didn't have to deal with orientation metadata at all.

    func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
        if let e = error {
            print("Error: \(e)")
        } else if let photoData = photo.fileDataRepresentation(), let photoImage = UIImage(data: photoData) {
            imageViewForOutput.image = photoImage
        }
    }
    

    Note that one must make sure the following is present when orientation change occurs OR at least right before a photo is taken. Otherwise it won't work. For me, I had it in my updateVideoOrientation function previously already:

    photoOutput.connection(with: AVMediaType.video)?.videoOrientation = videoOrientation