目次

目次

【iOS】SwiftUIとCoreImageを使って、EXIF情報を表示した画像をシェアする

アバター画像
永田駿平
アバター画像
永田駿平
最終更新日2023/09/01 投稿日2023/09/01

はじめに

こんにちは、最近NewJeans沼にどっぷりハマっている永田です。

Cool With You, Cookieが特にお気に入りで、Coke STUDIO SUPERPOP JAPAN 2023も参戦予定でとても楽しみです。

さて、今回はLiitというアプリをコピーしてみた中で学んだことを書き記そうと思います。

開発環境

本記事で扱うコードは以下の環境で動作することを想定しています。

  • Xcode 14.3.1
  • Swift 5.8.1
  • iOS 16.0+

Liit とは

Liitとは写真編集アプリの一種です。

さまざまなフィルターやフレームなどが用意されており、簡単に写真を編集できます。

その中でも写真のメタデータ(EXIF)を表示できるフレームがX(ex-Twitter)の写真家たちの間で流行っていました。

Liit で編集した画像

普段画像データをこねくり回すようなコードを書くことがないので、写真からEXIF情報を取得する方法を知らずかなり興味を惹かれました。

その方法さえわかれば自作できるのではと考え、勉強がてらLiitをコピーしてみることにしました。

実装する機能

まずは実装する機能の洗い出しを行いました。 ざっくり以下の5つに分けて実装を行います。

  • 写真ライブラリから写真を選択する
  • 写真からEXIFをパースする
  • 写真とEXIFをフレームに入れて表示する
  • フレームを画像化する
  • 画像をシェアする

これらのそれぞれについて、簡単なサンプルコードと共に実装方法をご紹介します。

実装の流れ

写真ライブラリから写真を選択する

ここではPhotosPickerを使用しました。

iOS 16以降で使えるSwiftUIの Viewで、UIKitにあるPHPickerViewControllerUIImagePickerControllerと同等の機能を持っています。

以下のように Viewbody内に配置することで、写真ピッカーを表示させるボタンを表示できます。

import PhotosUI
import SwiftUI

struct ContentView: View {
    @State private var pickedPhoto: PhotosPickerItem?

    var body: some View {
        PhotosPicker(
            selection: $pickedPhoto,
            matching: .images,
            photoLibrary: .shared()
        ) {
            Text("Select a photo")
        }
    }
}

写真からEXIFをパースする

ここではCIImage.properties を使用しました。

これは写真のメタデータを格納する [String: Any]のディクショナリで、使用できるキーはImageIOのEXIF Dictionary Keysに定義されています。

以下は、 PhotosPickerで取得した画像から「焦点距離」を取得する例です。 特定のキーを用いて propertiesから値を取り出しています。

func parseFocalLength(from pickedPhoto: PhotosPickerItem) async -> Int? {
    guard
        let imageData = try? await pickedPhoto.loadTransferable(type: Data.self),
        let properties = CIImage(data: imageData)?.properties,
        let exif = properties[kCGImagePropertyExifDictionary as String] as? [String: Any],
        let focalLength = exif[kCGImagePropertyExifFocalLength as String] as? Int
    else {
        return nil
    }

    return focalLength
}

ただし、このメタデータをパースする処理にはしんどいポイントが何箇所もあります。たとえば、

  • キーごとに取得できる値の型がドキュメントに書かれていない
  • キーによってはディクショナリが入れ子になっている場合がある
  • 入れ子になっているディクショナリに対してしか使えないキーも多く存在する
  • ↑のようなキーたちが、どのキーから取得したディクショナリに対して使うものなのかがドキュメントに書かれていない

などなど、自力で色々調査しながら実装する必要があり、かなりめんどくさいです。 楽曲のメタデータもそうですが、このあたりのドキュメントが適当すぎるのはどうにかならないものか。。。

自分は、パースのロジックを ImageMetadataParserというクラスに集約させ使用することにしました(具体的な実装は本記事の末尾に記載しています)。

写真とEXIFをフレームに入れて表示する

ここではCombineを使用したMVVMパターンで実装をしています。

ObservableObjectなViewModelを作成し、pickedPhotoが変わるたびにEXIFをパースしています。 パースした結果のEXIFデータを @Publishedcode>@Published</codeで定義しておき、その変更をViewで監視しています。

import Combine
import PhotosUI
import SwiftUI

@MainActor
final class ContentViewModel: ObservableObject {
    @Published var pickedPhoto: PhotosPickerItem?
    @Published var showFocalLengthIn35mmFilm = false

    @Published private(set) var exif: ExifData?

    private var cancellables = Set<AnyCancellable>()

    init() {
        $pickedPhoto
            .receive(on: DispatchQueue.main)
            .sink { _ in
                Task { @MainActor in
                    await self.parse()
                }
            }
            .store(in: &cancellables)
    }

    private func parse() async {
        guard
            let imageData = try? await pickedPhoto?.loadTransferable(type: Data.self),
            let parser = ImageMetadataParser(data: imageData)
        else {
            return
        }
        exif = .init(
            imageData: imageData,
            cameraMaker: parser.parse(for: \.cameraMaker),
            cameraModel: parser.parse(for: \.cameraModel),
            lensModel: parser.parse(for: \.lensModel),
            focalLength: parser.parse(for: \.focalLength),
            focalLengthIn35mmFilm: parser.parse(for: \.focalLengthIn35mmFilm),
            fNumber: parser.parse(for: \.fNumber),
            exposureTime: parser.parse(for: \.exposureTime).map(Fraction.init(number:)),
            iso: parser.parse(for: \.isoSpeedRatings)?.first
        )
    }
}

フレームを画像化する

ここではImageRendererを使用しました。

iOS 16以降で使えるSwiftUIのAPIで、SwiftUIの Viewを画像化できます。

このような extensionを定義しておくことで、任意のViewsnapshot()メソッドを呼ぶことで簡単に画像を取得できます。

extension View {
    @MainActor
    func snapshot(scale: CGFloat) -> UIImage? {
        let renderer = ImageRenderer(content: self)
        renderer.scale = scale
        return renderer.uiImage
    }
}

画像をシェアする

ここではShareLinkを使用しました。

iOS 16以降で使えるSwiftUIの Viewで、UIKitにあったUIActivityViewControllerと同等の機能を持っています。

以下のように Viewbody内に配置することで、シェアシートを表示するボタンを表示できます。

import SwiftUI

struct ContentView: View {
    // 中略
    var body: some View {
        // 中略
        ShareLink(
            "画像をシェアする",
            item: image,
            preview: .init(
                "Share ExiFrame Image",
                image: image
            )
        )
    }
}

完成

これらの技術を組み合わせて、LiitのようにEXIF情報を表示した画像をシェアできるアプリが完成しました。

完成したアプリのキャプチャ
書き出した画像

完成品のコードは以下に記載しておきます。 UIKitと比べてかなり少ないコード量で実装できている印象を受けました。

ContentViewModel.swift
import Combine
import PhotosUI
import SwiftUI

@MainActor
final class ContentViewModel: ObservableObject {
    @Published var pickedPhoto: PhotosPickerItem?
    @Published var showFocalLengthIn35mmFilm = false

    @Published private(set) var exif: ExifData?

    private var cancellables = Set<AnyCancellable>()

    init() {
        $pickedPhoto
            .receive(on: DispatchQueue.main)
            .sink { _ in
                Task { @MainActor in
                    await self.parse()
                }
            }
            .store(in: &cancellables)
    }

    private func parse() async {
        guard
            let imageData = try? await pickedPhoto?.loadTransferable(type: Data.self),
            let parser = ImageMetadataParser(data: imageData)
        else {
            return
        }
        exif = .init(
            imageData: imageData,
            cameraMaker: parser.parse(for: \.cameraMaker),
            cameraModel: parser.parse(for: \.cameraModel),
            lensModel: parser.parse(for: \.lensModel),
            focalLength: parser.parse(for: \.focalLength),
            focalLengthIn35mmFilm: parser.parse(for: \.focalLengthIn35mmFilm),
            fNumber: parser.parse(for: \.fNumber),
            exposureTime: parser.parse(for: \.exposureTime).map(Fraction.init(number:)),
            iso: parser.parse(for: \.isoSpeedRatings)?.first
        )
    }
}
ContentView.swift
import Combine
import PhotosUI
import SwiftUI

struct ContentView: View {
    @StateObject private var viewModel = ContentViewModel()
    @Environment(\.displayScale) private var displayScale

    private var exifImage: ExifImage {
        .init(
            exif: viewModel.exif,
            showFocalLengthIn35mmFilm: viewModel.showFocalLengthIn35mmFilm
        )
    }

    var body: some View {
        GeometryReader { geometry in
            VStack {
                exifImage

                PhotosPicker(
                    selection: $viewModel.pickedPhoto,
                    matching: .images,
                    photoLibrary: .shared()
                ) {
                    Text("Select a photo")
                }

                Toggle(
                    "35mm換算する",
                    isOn: $viewModel.showFocalLengthIn35mmFilm
                )
                if let image = exifImage
                    .frame(width: geometry.size.width)
                    .snapshot(scale: displayScale)
                    .map(Image.init(uiImage:)) {
                    ShareLink(
                        "画像をシェアする",
                        item: image,
                        preview: .init(
                            "Share ExiFrame Image",
                            image: image
                        )
                    )
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .padding()
        }
    }
}

extension View {
    @MainActor
    func snapshot(scale: CGFloat) -> UIImage? {
        let renderer = ImageRenderer(content: self)
        renderer.scale = scale
        return renderer.uiImage
    }
}
ExifImage.swift
import SwiftUI

struct ExifImage: View {
    private var exif: ExifData?
    private var showFocalLengthIn35mmFilm: Bool

    private let margin = CGFloat(16)

    init(
        exif: ExifData?,
        showFocalLengthIn35mmFilm: Bool
    ) {
        self.exif = exif
        self.showFocalLengthIn35mmFilm = showFocalLengthIn35mmFilm
    }

    var body: some View {
        Group {
            VStack(spacing: 4) {
                Image(uiImage: image)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .padding(.bottom, margin)
                Group {
                    HStack {
                        Text(cameraMaker)
                        Text(cameraModel)
                    }
                    .foregroundColor(Color.black)
                    Text(lensModel)
                        .foregroundColor(Color.gray)
                }
                HStack {
                    Text(focalLength)
                    Text(fNumber)
                    Text(shutterSpeed)
                    Text(iso)
                }
                .foregroundColor(Color.black)
            }
            .bold()
            .padding(margin)
        }
        .background(Color.white)
    }
}

private extension ExifImage {
    var image: UIImage {
        exif?.imageData.flatMap(UIImage.init(data:)) ?? .filled()
    }

    var cameraMaker: String {
        exif?.cameraMaker ?? "Unknown Maker"
    }

    var cameraModel: String {
        exif?.cameraModel ?? "Unknown Camera"
    }

    var lensModel: String {
        exif?.lensModel ?? "Unknown Lens"
    }

    var focalLength: String {
        let focalLength = {
            if showFocalLengthIn35mmFilm {
                return exif?.focalLengthIn35mmFilm ?? .zero
            } else {
                return exif?.focalLength ?? .zero
            }
        }()
        return "\(focalLength)mm"
    }

    var fNumber: String {
        "f/" + .init(format: "%.1f", exif?.fNumber ?? .zero)
    }

    var shutterSpeed: String {
        "\(exif?.exposureTime?.fractionalExpression ?? "0")s"
    }

    var iso: String {
        "ISO\(exif?.iso ?? .zero)"
    }
}

extension UIImage {
    static func filled(with color: UIColor = .black) -> UIImage {
        let rect = CGRect(
            origin: .zero,
            size: .init(width: 1, height: 1)
        )
        return UIGraphicsImageRenderer(size: rect.size)
            .image {
                $0.cgContext.setFillColor(color.cgColor)
                $0.fill(rect)
            }
    }
}
ImageMetadataParser.swift
import CoreImage

struct ImageMetadataParser {
    private let imageMetadataKeys = ImageMetadataKeys.shared
    private let properties: [String: Any]

    init?(data: Data) {
        guard let properties = CIImage(data: data)?.properties else {
            return nil
        }
        self.properties = properties
    }

    func parse<T>(for keyPath: KeyPath<ImageMetadataKeys, ImageMetadataKey<T>>) -> T? {
        let key = imageMetadataKeys[keyPath: keyPath]
        let targetDictionary: [String: Any] = {
            guard let parentDictionaryKey = key.parentDictionaryKey else {
                return properties
            }
            return parse(from: properties, for: parentDictionaryKey.keyName) ?? .init()
        }()
        return parse(from: targetDictionary, for: key.keyName)
    }

    private func parse<T>(
        from dictionary: [String: Any],
        for keyName: CFString
    ) -> T? {
        dictionary[keyName as String] as? T
    }
}

extension ImageMetadataParser {
    struct ImageMetadataDictionaryKey {
        let keyName: CFString
    }

    struct ImageMetadataKey<T> {
        let keyName: CFString
        let parentDictionaryKey: ImageMetadataDictionaryKey?
    }

    struct ImageMetadataKeys {
        static let shared = Self()

        private init() {
        }

        private enum MetadataDictionary {
            static let exif = ImageMetadataDictionaryKey(keyName: kCGImagePropertyExifDictionary)
            static let auxiliaryExif = ImageMetadataDictionaryKey(keyName: kCGImagePropertyExifAuxDictionary)
            static let tiff = ImageMetadataDictionaryKey(keyName: kCGImagePropertyTIFFDictionary)
        }

        // MARK: Lens Information
        let lensMaker = ImageMetadataKey<String>(
            keyName: kCGImagePropertyExifLensMake,
            parentDictionaryKey: MetadataDictionary.exif
        )
        let lensModel = ImageMetadataKey<String>(
            keyName: kCGImagePropertyExifLensModel,
            parentDictionaryKey: MetadataDictionary.exif
        )

        // MARK: Camera Information
        let cameraMaker = ImageMetadataKey<String>(
            keyName: kCGImagePropertyTIFFMake,
            parentDictionaryKey: MetadataDictionary.tiff
        )
        let cameraModel = ImageMetadataKey<String>(
            keyName: kCGImagePropertyTIFFModel,
            parentDictionaryKey: MetadataDictionary.tiff
        )

        // MARK: Camera Settings
        /// 焦点距離
        let focalLength = ImageMetadataKey<Int>(
            keyName: kCGImagePropertyExifFocalLength,
            parentDictionaryKey: MetadataDictionary.exif
        )
        /// 焦点距離(35mm換算)
        let focalLengthIn35mmFilm = ImageMetadataKey<Int>(
            keyName: kCGImagePropertyExifFocalLenIn35mmFilm,
            parentDictionaryKey: MetadataDictionary.exif
        )
        /// F値
        let fNumber = ImageMetadataKey<Double>(
            keyName: kCGImagePropertyExifFNumber,
            parentDictionaryKey: MetadataDictionary.exif
        )
        /// シャッタースピード
        let shutterSpeed = ImageMetadataKey<Double>(
            keyName: kCGImagePropertyExifShutterSpeedValue,
            parentDictionaryKey: MetadataDictionary.exif
        )
        /// 露光時間
        let exposureTime = ImageMetadataKey<Double>(
            keyName: kCGImagePropertyExifExposureTime,
            parentDictionaryKey: MetadataDictionary.exif
        )
        /// ISO感度
        let isoSpeedRatings = ImageMetadataKey<[Int]>(
            keyName: kCGImagePropertyExifISOSpeedRatings,
            parentDictionaryKey: MetadataDictionary.exif
        )
    }
}
Data.swift
struct ExifData {
    let imageData: Data?
    let cameraMaker: String?
    let cameraModel: String?
    let lensModel: String?
    let focalLength: Int?
    let focalLengthIn35mmFilm: Int?
    let fNumber: Double?
    let exposureTime: Fraction?
    let iso: Int?
}

/// 分数を表現する構造体
struct Fraction {
    /// 分子
    let numerator: Int
    /// 分母
    let denominator: Int

    var fractionalExpression: String {
        "\(numerator)/\(denominator)"
    }
}

extension Fraction {
    // NOTE: https://stackoverflow.com/questions/35895154/decimal-to-fraction-conversion-in-swift
    init(number: Double) {
        let precision = 1.0E-6
        var x = number
        var a = x.rounded(.down)
        var (h1, k1, h, k) = (1, 0, Int(a), 1)

        while x - a > precision * Double(k) * Double(k) {
            x = 1.0/(x - a)
            a = x.rounded(.down)
            (h1, k1, h, k) = (h, k, h1 + Int(a) * h, k1 + Int(a) * k)
        }
        self.init(numerator: h, denominator: k)
    }
}

終わりに

今回は以下の技術を用いてLiitのコピーアプリを作ってみました。

  • SwiftUI
    • PhotosPicker
    • ImageRenderer
    • ShareLink
  • Combine
  • CoreImage
    • CIImage
  • ImageIO
    • EXIF Dictionary Keys

画像データをこねくりまわすなど、普段の業務では関わりの少ない分野に触れられたのが新鮮で楽しかったです。

SwiftUIの新しいAPIにもいくつか触れることができ、SwiftUIもここ数年でかなり便利になってきていることを実感できました。 OSのサポートラインの問題はどうしてもついてまわりますが、新規のアプリ等では積極的に導入していきたいと思います。

また気になるアプリが出てきたらコピーしながら勉強しようかと思います。

参考文献

アバター画像

永田駿平

iOSアプリを作っています
音楽とガジェットが好きです

目次