iOSアプリで画面上のQRコード部分のみ輝度を上げ(Metal, EDRレンダリング)

WWDC 2022で発表されたMetalの新機能では、EDRを使って画面画像の一部分だけを明るく(現在設定されている画面の明るさ以上)することができます。

部屋の明かりを消して最小限の明るさの画面

背景

QRコード決済画面を表示する場合、通常、画面全体の輝度が高くなり(夜間に開くと気分が悪くなることがある。。。)

また、アプリ側で輝度の設定と復元を手動で行う必要があります。

WWDC 2022で公開された新機能

Metalフレームワークの新しいダイナミックEDR(Extended Dynamic Range)レンダリングサポートを使用すると、

  • QRコード画像のみを最大輝度で表示(ユーザーの現在の画面輝度設定に関係なく)
  • 画面の他の部分はユーザーが設定した明るさを維持
  • 画面表示の明るさを変更する必要がない
  • さらに、EDRを使えば、QRコードも画面の最大輝度より明るくすることができるので、より読み取りやすくなります。

この記事では、この新機能をUIKitとSwiftUIの両方で使用することについて説明します。

実装

EDRはHDRと似ています。画像に保存されている輝度値を利用して、特定の画素を他の画素よりも明るくするものです。iPhoneの場合、輝度値が1より大きいと、現在設定されている画面の明るさよりも明るく表示されます。

QRコードの CIImage は、明るさの値を画面がサポートする最大値に設定して作成することができます。
そして、この画像をMetalフレームワークを使ってレンダリングし、iPhoneの画面のEDR機能を利用できるようにします。

GitHubリポジトリ

まずは試してみたいという方は、以下のソースコードをiPhoneで実行してみてください。

効果をよりよく確認するために iPhoneの画面の明るさを最低にし、アプリを実行してください。

シミュレータでは、EDRはサポートされていません。

https://github.com/mszpro/QR-Code-EDR?ref=mszpro.com

EDR対応テスト

上記のように、EDRは画像上の特定のピクセルを、現在設定されている画面の明るさよりも明るく表示することができます。

デバイスがそれをサポートしているかどうかは、UIScreen.main.currentEDRHeadroom または view.window?.screen.currentEDRHeadroom を使用してテストする必要があります。値が>1である場合、その端末はEDRをサポートしています。

ステップ1.QRコードの作成

まず、CIFilter の一種である CIQRCodeGenerator を用いてQRを作成する。

inputMessage でQRコードの文字列内容を指定します。

// ここでのコードは、ステップ6で使用されます

guard let qrFilter = CIFilter(name: "CIQRCodeGenerator") else {
    return nil
}

let qrCodeContent = "testing-highlighted-qr"
let inputData = qrCodeContent.data(using: .utf8)
qrFilter.setValue(inputData, forKey: "inputMessage")
qrFilter.setValue("H", forKey: "inputCorrectionLevel")

guard let image = qrFilter.outputImage else {
    return nil
}

let sizeTransform = CGAffineTransform(scaleX: 10, y: 10)
let qrImage = image.transformed(by: sizeTransform)

ソースファイルに表示

ステップ2.画面の最大輝度を計算する

iPhoneは、それぞれ最大輝度の値が異なります。
また、環境やバッテリー残量によって変化することもあります。

ヘッドルーム headroom と呼ばれる、iPhoneの最大輝度値を取得する

// ここでのコードは、ステップ6で使用されます

let screen = view.window?.screen
var headroom = CGFloat(1.0)
if #available(iOS 16.0, *) {
    headroom = screen?.currentEDRHeadroom ?? 1.0
}

コンテンツが明るすぎるのが嫌な場合は、min で現在のヘッドルームと固定値(8など)の間の最小値を取得することができます。

ステップ3.明るさの塗りつぶしレイヤーを生成する

ここで、上記で算出した明るさの最大値で塗りつぶしレイヤーを生成します。
ここでの色は通常の色とは異なり、明るさを表すもので、1.0より大きくすることができますので、ご注意ください。

// ここでのコードは、ステップ6で使用されます

let maxRGB = headroom
guard let EDR_colorSpace = CGColorSpace(name: CGColorSpace.extendedLinearSRGB),
      let maxFillColor = CIColor(red: maxRGB, green: maxRGB, blue: maxRGB,
                                 colorSpace: EDR_colorSpace) else {
    return nil
}
let fillImage = CIImage(color: maxFillColor)

ソースファイルに表示

ステップ4.QR画像と輝度レイヤーを合成する

ここで、生成されたQRコードに明るさの値を与えて、最終的な画像を生成します。
これを行うには、QR画像 qrImage をマスクとして使用し、塗りつぶしレイヤー fillImage(最大輝度のレイヤー)を切り取ります。

// ここでのコードは、ステップ6で使用されます

let maskFilter = CIFilter.blendWithMask()

maskFilter.maskImage = qrImage
maskFilter.inputImage = fillImage

guard let combinedImage = maskFilter.outputImage else {
    return nil
}

return combinedImage.cropped(to: CGRect(x: 0, y: 0,
                                width: 300 * scaleFactor,
                                height: 300 * scaleFactor))

ソースファイルに表示

ステップ5.レンダラーをセットアップする

さて、CIImage をレンダリングするレンダラーを作る必要があります。
そして、レンダラーの設定で、EDRを使用するように指示します。

まず、MTKViewDelegate 型に準拠した Renderer クラスを作成する。
この Renderer クラスは、Metalビュー MTKView のデリゲートになります
また、Metal で画像をレンダリングし、CIImage で作業するための適切なフレームワークをインポートする必要があります。

import Metal
import MetalKit
import CoreImage

class Renderer: NSObject, MTKViewDelegate, ObservableObject {
    
    let imageProvider: (_ contentScaleFactor: CGFloat, _ headroom: CGFloat) -> CIImage? // 画像データを提供する呼び出し側デリゲート関数
    
    public let device: MTLDevice? = MTLCreateSystemDefaultDevice()
    
    let commandQueue: MTLCommandQueue?
    let renderContext: CIContext? // 名前、キャッシュ環境設定、低電力設定の設定
    let renderQueue = DispatchSemaphore(value: 3) // 新しいフレームを描画する前に、前のレンダリングが完了するのを待つために使用される
    
    init(imageProvider: @escaping (_ contentScaleFactor: CGFloat, _ headroom: CGFloat) -> CIImage?) {
        self.imageProvider = imageProvider
        self.commandQueue = self.device?.makeCommandQueue()
        if let commandQueue {
            self.renderContext = CIContext(mtlCommandQueue: commandQueue,
                                       options: [.name: "QR-Code-Renderer",
                                                 .cacheIntermediates: true,
                                                 .allowLowPower: true])
        } else {
            self.renderContext = nil
        }
        super.init()
    }
    
    func draw(in view: MTKView) {
        // ToDo
    }
    
    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
        // 描画可能なサイズや向きの変更に対応する。
    }
    
}

ソースファイルに表示

renderContext 変数には、ログでのデバッグを容易にするため、レンダラーの名前を設定します。
renderQueue 変数には、最大値 3 の DispatchSemaphore を設定します。

ディスパッチセマフォ DispatchSemaphoreの使用

これは、プログラムが次のレンダリングを開始する前に、前のレンダリングが完了するのを待つためのツールです。

前のコマンドが終了するのを待つには、次のようにします。

_ = renderQueue.wait(timeout: DispatchTime.distantFuture) は、前のタスクが終了するまで、プログラムは次のコードの行の実行を停止します。

前のコマンドが終了したことをシステムに知らせるには、self.renderQueue.signal() を実行します。

draw 関数を完成させる

次に、コンテンツを描画する func draw(in view: MTKView) 関数に取り組みます。

class Renderer: NSObject, MTKViewDelegate, ObservableObject {
    
    // ... 上記のコードは、変数とinit関数です。 //
    
    func draw(in view: MTKView) {
        
        guard let commandQueue else { return }

        // wait for previous render to complete
        _ = renderQueue.wait(timeout: DispatchTime.distantFuture)
        
        if let commandBuffer = commandQueue.makeCommandBuffer() {
            
            // コマンドが完了したら、次のフレームをレンダリングできるようにキューに通知する。
            commandBuffer.addCompletedHandler { (_ commandBuffer)-> Swift.Void in
                self.renderQueue.signal()
            }
            
            if let drawable = view.currentDrawable {
                
                let drawSize = view.drawableSize
                let contentScaleFactor = view.contentScaleFactor
                let destination = CIRenderDestination(width: Int(drawSize.width),
                                                      height: Int(drawSize.height),
                                                      pixelFormat: view.colorPixelFormat,
                                                      commandBuffer: commandBuffer,
                                                      mtlTextureProvider: { () -> MTLTexture in
                    return drawable.texture
                })
                
                // サポートされる最大EDR値(ヘッドルーム)を計算する
                var headroom = CGFloat(1.0)
                if #available(iOS 16.0, *) {
                    headroom = view.window?.screen.currentEDRHeadroom ?? 1.0
                }
                
                // デリゲート関数から表示するCI画像を取得します。
                guard var image = self.imageProvider(contentScaleFactor, headroom) else {
                    return
                }

                // ビューの可視領域で画像を中央に配置します。
                let iRect = image.extent
                let backBounds = CGRect(x: 0,
                                        y: 0,
                                        width: drawSize.width,
                                        height: drawSize.height)
                let shiftX = round((backBounds.size.width + iRect.origin.x - iRect.size.width) * 0.5)
                let shiftY = round((backBounds.size.height + iRect.origin.y - iRect.size.height) * 0.5)
                image = image.transformed(by: CGAffineTransform(translationX: shiftX, y: shiftY))
                
                // 画像が透明の場合、背景を指定する
                image = image.composited(over: .gray)
                
                // テクスチャーデスティネーションにレンダリングするタスクを開始します。
                guard let renderContext else { return }
                _ = try? renderContext.startTask(toRender: image, from: backBounds,
                                                  to: destination, at: CGPoint.zero)
                
                // レンダリング結果を表示し、レンダリングタスクをコミットする
                commandBuffer.present(drawable)
                commandBuffer.commit()
            }
        }
    }
    
    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
        // 描画可能なサイズや向きの変更に対応する。
    }
    
}

ソースファイルに表示

上記のコードでは、
まず renderQueue.wait 関数を使用して、前のレンダリングが完了するのを待ちます。
次に、コマンドバッファを取得し、描画のサイズを指定します。
HDRで提供できる最大の明るさ(ヘッドルーム)を計算します。
そして、CI画像オブジェクトをメタルビュー内でレンダリングし、レンダリング画像を中央に配置する。

ステップ6.レンダラーを初期化する

さて、QRコードと輝度値を含む生成したCIImage(手順1~手順4のコード)と、Rendererクラス(手順5)を元に、レンダラーを初期化します。

let renderer = Renderer(imageProvider: { (scaleFactor: CGFloat, headroom: CGFloat) -> CIImage? in
    
    // QRコード画像を生成する
    guard let qrFilter = CIFilter(name: "CIQRCodeGenerator") else {
        return nil
    }
    
    let qrCodeContent = "testing-highlighted-qr"
    let inputData = qrCodeContent.data(using: .utf8)
    qrFilter.setValue(inputData, forKey: "inputMessage")
    qrFilter.setValue("H", forKey: "inputCorrectionLevel")
    
    guard let image = qrFilter.outputImage else {
        return nil
    }
    
    let sizeTransform = CGAffineTransform(scaleX: 10, y: 10)
    let qrImage = image.transformed(by: sizeTransform)
    
    // 空白の塗りつぶし画像を生成する
    let maxRGB = headroom
    let maxFillColor = CIColor(red: maxRGB, green: maxRGB, blue: maxRGB,
                               colorSpace: CGColorSpace(name: CGColorSpace.extendedLinearSRGB)!)!
    let fillImage = CIImage(color: maxFillColor)
    
    // マスクフィルターを使って、最終的なQRコード画像を作成します。
    let maskFilter = CIFilter.blendWithMask()
    maskFilter.maskImage = qrImage
    maskFilter.inputImage = fillImage
    
    // ハイライトレイヤーとQR画像を合成する
    guard let combinedImage = maskFilter.outputImage else {
        return nil
    }
    return combinedImage.cropped(to: CGRect(x: 0, y: 0,
                                            width: 512.0 * scaleFactor,
                                            height: 384.0 * scaleFactor))
})

ソースファイルに表示

ステップ7.メタルビューでQRコードを表示する

それでは、メタルビューにQRコードを表示してみましょう。

@StateObject var renderer: Renderer // 変数

let metalView = MTKView(frame: .zero, device: renderer.device)

// MetalKitを通じてCore Animationに、ビューの再描画頻度を提案する。
metalView.preferredFramesPerSecond = 10

// Core Imageがメタルコンピュートパイプラインを使用してビューにレンダリングできるようにします。
metalView.framebufferOnly = false
metalView.delegate = renderer

if let layer = metalView.layer as? CAMetalLayer {
    // SDRより大きな値をサポートする色空間でEDRを有効にする。
    if #available(iOS 16.0, *) {
        layer.wantsExtendedDynamicRangeContent = true
    }
    layer.colorspace = CGColorSpace(name: CGColorSpace.extendedLinearDisplayP3)
    // レンダービューがEDRのピクセル値をサポートしていることを確認する。
    metalView.colorPixelFormat = MTLPixelFormat.rgba16Float
}

上記のコードでは、
新しいMetalビュー MTKView を初期化し、
リフレッシュレート(フレーム毎秒)を10に設定し、
Renderer インスタンスに delegate を設定し、
EDR(Extended Dynamic Range)を使用するように設定しています。

UIKitを使用している場合は、このビューをそのままUIKitのビューに追加することができます view.addSubview(metalView)

SwiftUI互換のメタルレンダリングビューを作る

アダプターを使うことで、SwiftUIと互換性のあるメタルビューを作ることもできます。

import SwiftUI
import MetalKit

struct MetalView: ViewRepresentable {
    
    @StateObject var renderer: Renderer
    /// - Tag: MakeView
    func makeView(context: Context) -> MTKView {
        let view = MTKView(frame: .zero, device: renderer.device)

        // MetalKitを通じてCore Animationに、ビューの再描画頻度を提案する。
        view.preferredFramesPerSecond = 10

        // Core Imageがメタルコンピュートパイプラインを使用してビューにレンダリングできるようにします。
        view.framebufferOnly = false
        view.delegate = renderer

        if let layer = view.layer as? CAMetalLayer {
            // SDRより大きな値をサポートする色空間でEDRを有効にする。
            if #available(iOS 16.0, *) {
                layer.wantsExtendedDynamicRangeContent = true
            }
            layer.colorspace = CGColorSpace(name: CGColorSpace.extendedLinearDisplayP3)
            // レンダービューがEDRのピクセル値をサポートしていることを確認する。
            view.colorPixelFormat = MTLPixelFormat.rgba16Float
        }
        return view
    }
    
    func updateView(_ view: MTKView, context: Context) {
        configure(view: view, using: renderer)
    }
    
    private func configure(view: MTKView, using renderer: Renderer) {
        view.delegate = renderer
    }
}

ソースファイルに表示

SwiftUIビューでレンダラーを使用する

さて、初期化されたレンダラーのインスタンスと、上で作成した互換性のあるビューアダプターの両方を使用して、ハイライトされたQRコード画像を表示する単一のSwiftUIビューを作成することができます。

import SwiftUI
import CoreImage.CIFilterBuiltins

/// - Tag: ContentView
struct ContentView: View {
    var body: some View {
        // 独自のレンダラーを持つメタルビューを作成します。
        let renderer = Renderer(imageProvider: { (scaleFactor: CGFloat, headroom: CGFloat) -> CIImage? in
            
            // QRコード画像を生成する
            guard let qrFilter = CIFilter(name: "CIQRCodeGenerator") else {
                return nil
            }
            
            let qrCodeContent = "testing-highlighted-qr"
            let inputData = qrCodeContent.data(using: .utf8)
            qrFilter.setValue(inputData, forKey: "inputMessage")
            qrFilter.setValue("H", forKey: "inputCorrectionLevel")
            
            guard let image = qrFilter.outputImage else {
                return nil
            }
            
            let sizeTransform = CGAffineTransform(scaleX: 10, y: 10)
            let qrImage = image.transformed(by: sizeTransform)
            
            // 空白の塗りつぶし画像を生成する
            let maxRGB = headroom
            let maxFillColor = CIColor(red: maxRGB, green: maxRGB, blue: maxRGB,
                                       colorSpace: CGColorSpace(name: CGColorSpace.extendedLinearSRGB)!)!
            let fillImage = CIImage(color: maxFillColor)
            
            // マスクフィルターを使って、最終的なQRコード画像を作成します。
            let maskFilter = CIFilter.blendWithMask()
            maskFilter.maskImage = qrImage
            maskFilter.inputImage = fillImage
            
            // ハイライトレイヤーとQR画像を合成する
            guard let combinedImage = maskFilter.outputImage else {
                return nil
            }
            return combinedImage.cropped(to: CGRect(x: 0, y: 0,
                                            width: 512.0 * scaleFactor,
                                            height: 384.0 * scaleFactor))
        })

        MetalView(renderer: renderer)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

ソースファイルに表示

ここで、iPhoneで実行すると、QRコードだけがハイライトされるのが見えます。

また、異なる塗りつぶしレイヤーを生成することで、画像の異なる部分を強調することができます。また、明るさの値を1より小さくすることで、特定の部分をより暗くすることができます。


お読みいただきありがとうございました。

☺️ Twitter @MszPro
🐘 Mastodon @[email protected]