ondelva

← Archive

iOS 카메라 개발기 - 프리뷰와 사진은 서로 반대로 누워 온다

같은 카메라에서 나온 프리뷰 버퍼와 사진 버퍼가 180° 달랐다. 추측으로 두 번 틀리고 나서 8×8 루마 격자로 재서 확정했다.

egg3분 읽기

요즘 iOS 카메라 앱을 하나 만들고 있다. 앱 소개는 나중으로 미루고, 만들면서 플랫폼이 생각과 다르게 굴었던 것들을 몇 편에 나눠 적어두려고 한다.

측정은 전부 iPhone 17 Pro, iOS 26.6.1 한 대에서 했다. 기종이나 OS가 바뀌면 숫자는 달라질 수 있다.

첫 편은 방향. 카메라 앱을 만들면 제일 먼저 부딪히고, 아무래도 제일 오래 붙잡게 되는 문제인 것 같다.

프리뷰와 사진은 커넥션이 다르다

화면에 보여줄 프레임은 AVCaptureVideoDataOutput에서, 셔터를 누른 사진은 AVCapturePhotoOutput에서 받는다. 같은 카메라인데 출력이 둘이고 커넥션도 따로라 방향도 따로 논다.

사진 버퍼는 전면이든 후면이든 EXIF orientation 6, 즉 시계 방향 90°로 온다. 그런데 프리뷰 버퍼는 거기서 180°가 더 돌아 있었다. 결국 전면 카메라는 프리뷰를 반시계로, 사진을 시계로 세워야 했다.

물론 처음부터 알았던 건 아니고, 감으로 방향을 정했다가 두 번 틀렸다.

8×8 루마 격자 비교

세 번째에는 감을 버리고 재보기로 했다. 같은 장면에서 프리뷰 프레임 한 장과 사진 한 장을 받아 둘 다 8×8 루마 격자로 줄이고, 한쪽을 0°, 90°, 180°, 270°로 돌려가며 다른 쪽과 차이를 본다.

Y 평면을 8×8 블록 평균으로 줄이면 격자가 되고, 두 버퍼 비율이 달라도 격자로 줄이고 나면 상관없다. 뼈대는 이 정도.

// 시계 방향 90°
func rotate(_ g: [[Float]]) -> [[Float]] {
    let n = g.count
    return (0..<n).map { r in (0..<n).map { c in g[n - 1 - c][r] } }
}

func diff(_ a: [[Float]], _ b: [[Float]]) -> Float {
    zip(a.joined(), b.joined()).map { abs($0 - $1) }.reduce(0, +) / Float(a.count * a.count)
}

var g = previewGrid
for deg in [0, 90, 180, 270] {
    print(deg, diff(g, photoGrid))
    g = rotate(g)
}

180°에서 0.151, 그다음으로 가까운 게 0.668. 네 배 넘게 차이가 나서 더 볼 것도 없었다.

화면을 들여다보면서 뒤집힌 건지 아닌지 고민하던 것보다 훨씬 빨랐다. 숫자 64개 비교라 코드도 몇 줄 안 된다.

전면에서는 90을 걸어도 무시된다

커넥션에 videoRotationAngle = 90을 걸면 버퍼가 세로로 올 줄 알았다. 그런데 전면 프레임은 1608×1206, 가로 그대로 왔다. 에러도 없이.

그래서 회전했는지를 설정값 대신 버퍼 모양으로 판단하게 바꿨다. width가 height보다 크면 아직 누워 있는 거다. AVCaptureDevice.RotationCoordinator가 알려주는 각도도 이 판단에는 쓸 수 없었다.

다행히 크롭·회전·거울을 셰이더의 uv 변환 행렬 하나에 몰아둔 상태였다. 커넥션이 설정을 먹든 말든 버퍼 모양만 보고 행렬을 고르면 되니 코드 경로도 하나로 끝났다.

카메라 전환 직후의 잔상

카메라를 전환하면 몇 프레임 동안 거울과 회전이 반대로 그려졌다. 후면으로 돌아올 때는 180° 뒤집힌 잔상이 한 번 번쩍이기도 했다.

원인은 전후면을 메인 스레드의 position 값으로 판단한 것. 메인 쪽 값은 이미 바뀌었는데 캡처 큐에는 아직 옛 카메라 프레임이 들어오고 있었던 것 같다. 전후면 값을 캡처 큐에서 프레임과 함께 넘기게 하니 사라졌다.

프레임을 해석할 때 쓰는 정보는 그 프레임과 같이 받는 편이 맞는 듯 싶다.

전면 버퍼는 EXIF와 상관없이 거울

사진 EXIF에 미러가 아니라고 적혀 있어도 전면 버퍼 자체는 거울로 온다. 메타데이터만 믿고 거울 처리를 건너뛰면 좌우가 뒤집힌다.

저장할 때 orientation 6이 따라왔다

사진 버퍼는 센서 방향 그대로 4032×3024로 오고, 방향은 메타데이터로만 따라온다. 셰이더로 다시 렌더하면 그 정보가 사라지니 픽셀을 직접 세워서 저장했다.

여기서 또 걸렸다. 원본 EXIF를 그대로 복사했더니 orientation 6까지 따라왔고, 뷰어가 이미 세운 사진을 한 번 더 돌려서 90° 누운 사진이 됐다.

var metadata = photo.metadata
metadata[kCGImagePropertyOrientation as String] = 1   // 픽셀을 이미 세웠다

사진첩 사진까지 눕혀버렸다

“버퍼가 가로면 세운다”는 센서 버퍼에만 통하는 규칙이었다. 나중에 사진첩에서 불러온 사진에 같은 함수를 태웠더니 멀쩡한 가로 사진이 누워버렸다.

입력이 센서에서 왔는지 사진첩에서 왔는지 함수가 알고 있어야 했다. 함수 이름에 그 전제가 안 드러나 있었으니 잘못 부른 것도 결국 나..

돌아보면 방향 버그로 날린 시간은 코드보다 감으로 방향을 찍은 데서 더 많이 나온 것 같다. 루마 격자 비교를 첫날 만들었으면 두 번은 안 틀렸을 텐데. 다음부터는 헷갈리면 일단 재고 본다.

다음 편은 설정을 넣었는데 에러도 없이 다른 값으로 바뀌어 있던 이야기. 해상도와 줌이다.

Filed under 개발

ShareXFacebookLinkedIn