- 投稿日:2021-12-05T20:39:23+09:00
iOSアプリでSPMを用いたマルチモジュール構成を試してみた
近年のiOSアプリ開発では、アプリの規模が大きいアプリも増え、複数人による並行開発を安定的に行うべく、マルチモジュール構成を採用するアプリも増えてきたと思います。 特にマルチモジュールの流れはAndroidの方が先行していた印象で、2018年頃からその流れが大きくなってきた印象でした。 iOSDCなどカンファレンスのセッションなどを聞いていると、大規模アプリほどその課題にあたっているチームが多いようです。 従来のマルチモジュール構成を行うには、XcodeからFrameworkプロジェクトを作成し、ライブラリとしてメインのアプリプロジェクトに追加していくというものでした。 アプリと、ライブラリ間の依存関係にはXcode上で手動で行う方法や、CocoaPodsなどパッケージ管理ツールを使う方法もあります。 そんな中、iOSDC 2021の@d_dateさんのセッションで、Swift Package Managerを用いたマルチモジュール構成の紹介がありました。 今回この記事では、こちらの発表内容を参考にしつつ、実際にSPMでマルチモジュール構成を取ると、どのようなメリット、デメリットがあるか、サンプルアプリを作り検証してみました。 iOS15もリリースされ、2021年の年末からサポートOSをiOS14/iPadOS14以降とするアプリも多いかと思いますので、iOS14/iPadOS14以降をターゲットとしました。 また、アプリはFull SwiftUIで作成し、iOS14から利用できるコンポーネントを使ったアプリとしていますので、これからSwiftUIを勉強していく方にも参考にしていただけると思います。 2021/12現在の開発環境 Mac Book Pro 16-inch 2019(2.4GHz 8-Core Intel Core i9, 32GB 2667MHz DDR4) macOS Big Sur v11.6 Xcode v12.5.1 サンプルアプリ GitHubのSearch APIを利用した、リポジトリを検索&表示するアプリです。 iOS Home Search WebContent iPadOS 全体のモジュール構成とアーキテクチャは以下のようになっており、MVVM+Clean Architedtureを採用しました。 Feature Modulesという4つの画面モジュールグループと、Core Modulesというそれ以外のモジュールグループとなっています。 Module Type Module Name Description App App アプリエントリポイントを持つのモジュール。Rootのみ依存を持つ。 Feature Modules Root TabViewを持つViewで、HomeとSearchの画面の依存を持つ。 〃 Home Home画面のモジュール。WebContent、ViewComponents、Repositoriesに依存を持つ。 〃 Search Search画面のモジュール。WebContent、ViewComponents、Repositoriesに依存を持つ。 〃 WebContent WebView画面のモジュール。ViewComponentsに依存を持つ。 Core Modules ViewComponents 共通で使われる画面コンポーネントを集めたモジュール。今回はImageライブラリNukeUIを利用しているコンポーネントがあるため、NukeUIの依存を持つ。 〃 Repositories APIアクセスやローカルデータアクセスを抽象化したクラスを集めたモジュール。GitHubAPIRequestの依存を持つ。 〃 GitHubAPIRequest GitHubAPIのリクエストクラスやレスポンスEntityを集めたモジュール。APIClientの依存を持つ。 〃 APIClient APIClientを含むモジュール Xcode上のプロジェクトツリーはこのような見た目になっています。 SPMモジュールの作成の仕方 File > New > Swift Package...を選択します。 そうすると、ダイアログが表示されますので、名前、ディレクトリ、追加プロジェクト、プロジェクトツリーのグループを指定するだけです。 それでは、ここからは、SPMマルチモジュール構成のプロジェクトのメリット、デメリット、課題などをまとめていきます。 SPMマルチモジュールによるメリット メリット1: プロジェクトファイルのコンフリクト問題からの開放 ここで注目したいのが、SPMで追加した場合、"ディレクトリの参照"としてプロジェクトツリーに追加されるという点です。(フォルダが青) 通常Xcodeのファイルはプロジェクトファイル(project.pbxproj)にツリー構成が書き込まれ、ファイルの追加、削除、移動を行うとプロジェクトファイルも自動更新され、複数人で開発する場合のコンフリクトの要因となり、アプリ開発者は悩まされてきました。 その解決策として、XcodeGenが登場し、自動的にディレクトリとpbxprojファイルを同期させるアプローチが生まれました。 それに対し、SPMのディレクトリ参照となるため、SPMモジュールのルートディレクトリだけがpbxprojファイルに記述され、そこから配下のファイルはpbxprojファイルに依存を持ちません。 従って、SPMモジュール内のファイルの追加、削除、移動をしてもpbxprojファイルのコンフリクトは発生しなくなります。 エントリポイントのAppモジュールはRootモジュールだけ依存を持ち、以下のコードのみとなります。 App/Main.swift import SwiftUI import Root @main struct Main: App { var body: some Scene { WindowGroup { RootView() } } } メリット2: Package.swiftによる依存管理 各モジュールがどのモジュールに依存するかが、Package.swiftで管理できるため、とても明確でシンプルになります。 FeatureModules/Home/Package.swift // swift-tools-version:5.3 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "Home", platforms: [ .iOS(.v14), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( name: "Home", targets: ["Home"]), ], dependencies: [ // Dependencies declare other packages that this package depends on. // .package(url: /* package url */, from: "1.0.0"), .package(path: "WebContent"), .package(path: "../CoreModules/ViewComponents"), .package(path: "../CoreModules/Repositories"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "Home", dependencies: [ "WebContent", "ViewComponents", "Repositories", ]), .testTarget( name: "HomeTests", dependencies: ["Home"]), ] ) また、Package.swiftに変更を加えると、Xcode自動的に構文チェックをしてくれ、エラーや警告を表示してくれます。 このあたり、Frameworkを作成して、依存管理する場合に比べ、ビルドしなくても依存問題を検知できるのは大きなメリットだと思います。 メリット3: Frameworkに比べ管理ファイルや変更点が少ない ここで、従来からあるFramework追加の場合と比較してみましょう。 新しくTargetの追加からFrameworkを選び、アプリのライブラリとして追加していきます。 Frameworkの追加 こうした場合、Swiftコード以外にもヘッダーファイル(.h)、Info.plistのファイルがあり、pbxprojファイルにも大量のツリー構成が挿入されます。 こういった余計なファイルや、コンフリクトの要因となるファイルから脱却でき、pbxprojファイルをシンプルに保つことができます。 メリット4: モジュール単位のビルド時間 これは、Frameworkの場合でも恩恵を受けられるメリットですが、モジュール単位でビルドができるため、フルビルドに比べ修正した個別のモジュールを選んだビルドは早くなりますので、大規模アプリになるほど恩恵は大きくなると思います。 ここからは、SPMマルチモジュールによるデメリットを考えていきたいと思います。 SPMマルチモジュールによるデメリット デメリット1: SPMの学習コスト 当然Package.swiftの書き方を学ぶ必要があるため、シングルモジュールに比べると追加の学習コストとなると思います。 デメリット2: CI環境の変更 マルチモジュールになり、テストコードもモジュール単位となるため、既存のCIがある場合、各モジュールごとのテストも実行するように変更が必要となってくるでしょう。 と2つほど上げてみましたが、逆にそれ以外のデメリットはなさそうという印象です。 XcodeGenなどに比べても管理ファイルがPackage.swiftだけなので、今後のXcodeのアップデートによるメンテナンスコストの影響も抑えられそうです。 最後に今回アプリを開発していて、分かってきたSPMの懸念点とSwiftUIの懸念点をお伝えしたいと思います。 懸念点 懸念点1: Swift Packgeのモジュールを追加した際、Xcodeが怪しい挙動をする場合がある 新たにSwift Packageを追加した際、XcodeのSchemeが突如表示されなくなったり、それまで通っていたimport Foo が No such moduleのエラーになることがありました。 こういう場合は一度Xcodeを立ち上げ直すと直ったり、変更したファイルをgit上で戻して追加し直すなどやっていると解消したりしました。 SPMあたりはまだ若干Xcodeが不安定な感じがします。 懸念点2: SwiftUIプレビューは頻繁に壊れる これはSPMマルチモジュールとは別の問題なのですが、今回作成したFull SwiftUIのアプリのコード量でもプレビューはかなり不安定でした。 ViewComponentsあたりの軽いモジュールは安定しやすいですが、画面全体のモジュールになってくると、エラーがよく発生し、クリーンをしてみる、実機ビルドをしてみる、Xcodeを再起動してみるといったことを頻繁に繰り返す必要がありました。 また、プレビュー時のビルドはモジュールがキャッシュされているような感じはなく、フルビルドがよく走っている印象です。 そのため、もし既存のアプリで画面単位でのSwiftUIプレビューの安定化を期待しても、変わらない可能性が高いと思われます。 もしかしたら今後のXcodeのバージョンアップで改善されるかもしれません。(改善してほしい...) 最後に 今回サンプルアプリを作成するにあたり、主に以下の技術書、資料を参考にさせていただきました。 もし何か間違いや、ご意見、感想などありましたらコメント頂けたら嬉しいです。
- 投稿日:2021-12-05T12:37:36+09:00
[SwiftUI]クラス・構造体とSwiftUIのコンポーネントの命名が被ってしまった場合
クラス名や構造体の命名が、SwiftUIであらかじめ用意されているViewの命名と被ってしまった場合、 SwiftUI.使いたいViewで指定するで解決。 struct Menu { let label: String let icon: Image } struct MenuView: View { var body: some View { VStack{ SwiftUI.Menu { // SwiftUI.Menuを追記 ForEach(0..<5){ index in Button(action: {}) { Text("index: \(index)") } } } label: { Image(systemName: "plus") } } } } NGな場合 以下のように、構造体やViewプロトコルに準拠させた自作のView構造体を作成する場合、再宣言が無効ですとエラーメッセージが吐き出されるされる。 Invalid redeclaration of 'MenuView' struct MenuView: View { var body: some View { VStack{ Menu { ForEach(0..<5){ index in Button(action: {}) { Text("index: \(index)") } } } label: { Image(systemName: "plus") } } } } struct MenuView: View { var body: some View { Text("menuView") } } しかしながら、以下のような今回の事例では、異なるエラーメッセージが吐き出される struct Menu { let label: String let icon: Image } struct MenuView: View { var body: some View { VStack{ Menu { ForEach(0..<5){ index in Button(action: {}) { Text("index: \(index)") } } } label: { Image(systemName: "plus") } } } } 以下のエラーメッセージが発生する Generic struct 'VStack' requires that 'Menu' conform to 'View' Static method 'buildBlock' requires that 'Menu' conform to 'View' Trailing closure passed to parameter of type 'Decoder' that does not accept a closure Viewプロトコルに準拠させたViewの命名が被った場合は、このメッセージが表示されると思い込んでいたので、構造体同士がかぶっているとは、全く気づかなかった。。。 同じInvalid redeclaration of 'MenuView'で統一して欲しい。。。
- 投稿日:2021-12-05T12:03:29+09:00
iOS 15で実現するPiP動画のコメント表示
こんにちは。ogukeiです。社内ではiOS版ニコニコ生放送アプリを開発しています。 先々月にiOS版ニコニコ生放送アプリのリリース5.27.0で、ピクチャ・イン・ピクチャ(PiP)機能で視聴時にコメントが流れるようになりました。本機能はiOS 15で初めて使えるようになった新APIを活用しています。今回はそのリリースの背景についてご紹介します。主にコメントを安定した動作で表示するための最適化について触れています。 PiP機能とは 他アプリを使用しながら小窓で動画を再生できる機能です。iOS版ニコニコ生放送アプリでは昨年プレミアム会員の方を対象に本機能をリリースしました1。 しかしリリース当初表示できたのはコメント無しの動画でした。OSが提供するAPIの制限によって基本的には動画に手を加えることができませんでした。動画にコメントが流れるというのはニコニコのサービスとして非常に重要なので何としてもコメント表示を実現したい思いがありました。 なぜPiPのコメント表示がそんなに難しいのか 動画の上にコメントを重ねるだけじゃん、と開発当初考えていたのですが、これがめちゃくちゃ難しいです。PiPではOSが提供するAPIでできることが限られているためです。当時基本的にできることは動画ソース元URLをOS標準のプレイヤーに指定するだけでした。つまり動画をクライアントサイドで自由に加工できませんでした。 イメージとしては以下のようなコードです。 // ここで動画ソースURLを指定するだけ let player = AVPlayer(playerItem: AVPlayerItem(URL: ...)) let playerLayer = AVPlayerLayer(player: player) let controller = AVPictureInPictureController(playerLayer: playerLayer) 当時よくユーザーの方からもPiPにコメントが欲しいというご意見がありました。なんとかできないか開発チームで試行錯誤を続けていましたが結局これという手は見つかりませんでした。ちなみに試した中で最も有力だったのはHLSのプロキシサーバーをクライアント内部で立ててセグメントファイルを動的に生成する方法でした2。もっとも黒魔術すぎて導入は現実的ではなかったです。 iOS 15の新機能 悪戦苦闘のさなか、突如として悲願の新機能がWWDC2021で公開されました3。 AVPictureInPictureController が AVSampleBufferDisplayLayer をサポートしたことで任意の動画をPiPに表示できるようになりました4。 画像を表す CMSampleBuffer を連続的にAVSampleBufferDisplayLayer に与えると動画が再生されます。CMSampleBuffer が持つ画像は自由に加工できます。 let displayLayer = AVSampleBufferDisplayLayer() let controller = AVPictureInPictureController(contentSource: .init(sampleBufferDisplayLayer: displayLayer)) // 任意の画像を渡せる! displayLayer.enqueue(sampleBuffer) コメントの描画 さてここまで来れば後は実装だけになります。まずコメントの描画に関する実装についてご紹介したいと思います。 ニコニコ生放送アプリではどうやってコメントを描画しているのかというと社内にコメント描画ライブラリがありそれを用いています。そのライブラリは社内ではCommeDawara(こめだわら)と呼ばれています。先人たちによって作成されたこのライブラリはコメントの表示タイミングに合わせてコメントの位置を調整し適切にコメントを描画してくれます。PiPでもそのライブラリを用いることでアプリ内UIと同様の一貫性のあるコメント描画を実現できます。 次にPiPでコメントを描画する上で重要なことはリアルタイム処理の動作パフォーマンスです。例えば最新のiPadではProMotionディスプレイに対応しており120Hzのリフレッシュレートで画面が更新されることがあります。つまり動画1フレームの画像にコメントを合成する処理は 8.3ms 以内に完了しなければなりません。処理が遅いと動画やコメントがカクついて見えてしまいます。いかにスムーズなリアルタイム処理を実現するか工夫が必要でした。実装を踏まえていくつかその取り組みをご紹介します。 文字列の描画 CommeDawaraからは現在の画面上に存在すべきコメントの文字列とサイズ、位置が得られます。これらの情報を用いてコメントの文字列を描画していきます。 struct Comment { let bounds: CGRect let text: NSAttributedString } let comments: [Comment] = commeDawaraRenderer.comments(at: time) 映像に文字列を直接描画してみる CGContextで映像のCVPixelBufferを描画ターゲットに設定してNSAttributedStringをその上に描画します。 let context = CGContext(data: videoPixelBuffer, width: width, height: height, ...) UIGraphicsPushContext(context) for comment in comments { comment.text.draw(at: comment.bounds.origin) } UIGraphicsPopContext(context) しかし実行時間を計測したところこの方法では問題があることが分かりました。文字列を1つ描画するのにおよそ 1.0ms±0.5ms かかります。特に文字列に影が付いているせいか処理時間がかかるようでした。番組によっては数十個のコメントが同時に画面に表示されるため容易に1フレームで処理できる時間を超えてしまいます。 文字列の描画結果をキャッシュする そこでCore Animationの shouldRasterize5 に着想を得てコメントの文字列をビットマップにキャッシュする方法を採用しました。この方法ではコメントが画面に追加されたときに一度だけ文字列の描画を行ってそれ以降のフレームはそのビットマップを再利用することで高速化を目指します。 func makeCommentTextPixelBuffer(string: NSAttributedString) -> CVPixelBuffer { let pixelBuffer = CVPixelBufferCreate(...) let context = CGContext(data: pixelBuffer, ...) UIGraphicsPushContext(context) context.saveGState() context.translateBy(x: 0, y: CGFloat(height)) context.scaleBy(x: 1.0, y: -1.0) string.draw(at: .zero) context.restoreGState() UIGraphicsPopContext(context) return pixelBuffer } 以下のようなビットマップが得られます。コメント自体は描画できました。次は動画とコメントを合成するステップに移ります。 動画とコメントの合成 iOSで動きのある描画を行うにはCore Animationを用いる方法が代表的です。画面更新のタイミングを取得できるタイマーの役割を持っている CADisplayLink を用いて毎フレーム処理を行います。 func createDisplayLink() { let displayLink = CADisplayLink(target: self, selector: #selector(step)) displayLink.add(to: .current, forMode: .defaultRunLoopMode) } func step(displayLink: CADisplayLink) { // ここに毎フレームの処理 } 毎フレームの処理を step メソッドに記述します。ここで次の画面更新(VSync)の時刻を displayLink.targetTimestamp で取得できます6。次のVSyncのタイミングに間に合うように映像とコメントを合成するレンダリングを行います。例えばリフレッシュレートが120Hzの場合以下の図のようになります。 動画フレームの非同期取得 動画フレームは AVPlayerItemVideoOutput#copyPixelBuffer(forItemTime:) で取得できます。処理速度を計測してみるとおよそ 8ms±3ms でした。動画フレームのコピーにかかるコストが大きいことが分かります7。 画面更新ごとに動画フレームを取得すると8.3msのbudgetを超えてしまいます。そこで可能な限りの頻度で非同期に動画フレームを取得します。動画は60FPS以下のものがほとんどです。画面ほど頻繁に更新する必要はそうそうありません。 フレームバッファ 動画とコメントを合成するためには合成した結果を格納するメモリが必要です。しかし1280x720などの大きな画像を画面更新ごとに毎回メモリ確保して破棄するのは効率があまりよくありません。そのため必要なだけバッファをあらかじめメモリ確保しておき再利用することで最適化を図ります。一般的にダブルバッファリングと呼ばれる手法を参考にしています。以下の図に例えばフレームバッファが3つあったときの再利用の流れを示します。 Metalを用いた合成処理 最後にあらかじめ描画されたコメントのビットマップを動画フレームと合成します。合成処理には速度を重視してMetalを採用しました8。また負荷をGPUに分散させて文字列描画のためのCPUリソースをできるだけ確保するためでもあります。 合成の際 CVMetalTextureCacheCreateTextureFromImage を用いることでMetalからCVPixelBufferを操作できます。iOSではUnified Memory ModelがサポートされておりCPUとGPUでメモリを共有しています9。両者間でデータのコピーが不要になるケースがあります。 描画されたコメントは半透明ビットマップに保存されています。アルファブレンドによってコメント付き画像を生成します。これにはMetal Compute Pipelineを用いました。 #include <metal_stdlib> using namespace metal; // @see https://en.wikipedia.org/wiki/Alpha_compositing kernel void sourceOver(texture2d<half, access::read> inputs [[texture(0)]], texture2d<half, access::read_write> outputs [[texture(1)]], constant int4& offset [[buffer(0)]], uint2 gid [[thread_position_in_grid]]) { // ... int2 target = gid.xy + offset.xy; half4 source = inputs.read(gid); half4 destination = outputs.read(target); half3 color = source.rgb + destination.rgb * (1.0 - source.a); outputs.write(half4(color, 1.0), target); } 全部まとめる 動きました!端末によっては全くカクつかないことはないのですが、コメントの多い番組でもヌルヌル安定した動作を実現できました。 全体コードイメージ func step(displaylink: CADisplayLink) { let targetTimestamp = displayLink.targetTimestamp backgroundQueue.async { // 映像フレームの取得 let itemTime = videoOutput.itemTime(forHostTime: targetTimestamp) if videoOutput.hasNewPixelBuffer(forItemTime: itemTime) { self.videoPixelBuffer = videoOutput.copyPixelBuffer(forItemTime: itemTime) } } backgroundQueue.async { // フレームバッファの取得 let frameBuffer = frameBufferPool.acquireCurrentFrame() // 合成 let sampleBuffer = renderer.render(frameBuffer: frameBuffer, video: self.videoPixelBuffer, comments: self.comments, displayAt: targetTimestamp) displayLayer.enqueue(sampleBuffer) } } // コメントの追加 func addComment(comment: Comment) { commentRenderingBackgroundQueue.async { let pixelBuffer = makeCommentTextPixelBuffer(string: comment.text) self.comments += CommentCache(comment, pixelBuffer) } } リリース 本機能は複数人(2人)で開発する少し特殊な体制を取りました。描画部分とアプリ実装部分にタスクを分割しました。おかげでiOS 15の新APIが発表されてから迅速に実装を終えることができました。 当初iOS 15のリリースと同時に本機能をリリースする心づもりでした。もっともXcode 13へのアップデートに伴う動作確認の準備の見通しが甘くリリースまでに時間がかかってしまいました。しかし新しいAPIを活用した機能をここまでスピーディに開発できたことやその環境があることに心が躍る思いです。 まとめ iOS 15のPiPで動画にコメントが流れるようになりました。様々な最適化を施しました。優れた動作パフォーマンスは安定した視聴に欠かせないものです。PiPにとどまらず、さらにスムーズな視聴ができる環境をつくるべく改善を重ねていきます。 https://blog.nicovideo.jp/niconews/144810.html ↩ チーム内勉強会でメンバーが開発したデモが実際に動いていて凄かったです ↩ https://developer.apple.com/videos/play/wwdc2021/10290/ ↩ https://developer.apple.com/documentation/avkit/avpictureinpicturecontroller/contentsource/3750329-init ↩ https://developer.apple.com/documentation/quartzcore/calayer/1410905-shouldrasterize ↩ https://developer.apple.com/videos/play/wwdc2021/10147/ ↩ おそらく動画の内部ピクセル表現がYCbCrでRGBAへの変換処理が走っているのではないでしょうか ↩ Core Imageのcomposited(over:)を用いる方法もありますがメモリ管理が難しく見送りました ↩ https://developer.apple.com/documentation/metal/setting_resource_storage_modes/choosing_a_resource_storage_mode_in_ios_and_tvos ↩
- 投稿日:2021-12-05T11:06:10+09:00
TensorFlowLiteのモデルをiOSでつかう【機械学習】
iOSでtfliteモデルが使えたら便利ですよね。 基本的には、 CocoaPodsでTensorFlowLiteを追加して、 あとはInterpreterがモデルの初期化、画像から入力テンソルの作成、推論、をクラスメソッドでしてくれます。 TensorFlowのexampleプロジェクトのモデル推論に必要な部分を抜粋・解説した内容です。 (コードは公式のものほとんどそのまま) 基本手順 TensorFlowLiteをインポート Cocoa PodでTensorFlowLitePodを追加(pod install)します。 use_frameworks! pod 'TensorFlowLiteSwift' import TensorFlowLiteSwift TensorFlowLiteモデルをXcodeプロジェクトにドロップしてバンドルします。 ラベルを使う場合はラベルファイルもドロップしてバンドルします。 モデルの初期化 guard let modelPath = Bundle.main.path(forResource: "mobilenet_quant_v1_224", ofType: "tflite") else { print("Failed to load the model."); return nil } var options = InterpreterOptions() options.threadCount = 1 do { // Interpreter(通訳者)として初期化 interpreter = try Interpreter(modelPath: modelPath, options: options) // 入力テンソルのためにメモリを割り当てる try interpreter.allocateTensors() } catch let error { print("Failed to create the interpreter with error: \(error.localizedDescription)") return nil } クラス・ラベルもStringの配列として読み込んでおきます。 guard let fileURL = Bundle.main.url(forResource: "labels", withExtension: "txt") else { fatalError("Labels file not found in bundle. Please add a labels.") } do { let contents = try String(contentsOf: fileURL, encoding: .utf8) self.labels = contents.components(separatedBy: .newlines) } catch { fatalError("Labels file cannot be read.") } 入力の準備 モデルの入力フォーマットに合わせたCVPixelBufferを入力します。 // モデルのパラメーター例 let batchSize = 1 let inputChannels = 3 let inputWidth = 224 let inputHeight = 224 TensorFlow公式のesampleプロジェクトのPixelBuffer変換メソッドを使うために、kCMPixelFormat_32BGRA形式の設定にします。 videoDataOutput.videoSettings = [ String(kCVPixelBufferPixelFormatTypeKey) : kCMPixelFormat_32BGRA] PixelBufferを正方形にクロップします。 extension CVPixelBuffer { /** Returns thumbnail by cropping pixel buffer to biggest square and scaling the cropped image to model dimensions. */ func centerThumbnail(ofSize size: CGSize ) -> CVPixelBuffer? { let imageWidth = CVPixelBufferGetWidth(self) let imageHeight = CVPixelBufferGetHeight(self) let pixelBufferType = CVPixelBufferGetPixelFormatType(self) assert(pixelBufferType == kCVPixelFormatType_32BGRA) let inputImageRowBytes = CVPixelBufferGetBytesPerRow(self) let imageChannels = 4 let thumbnailSize = min(imageWidth, imageHeight) CVPixelBufferLockBaseAddress(self, CVPixelBufferLockFlags(rawValue: 0)) var originX = 0 var originY = 0 if imageWidth > imageHeight { originX = (imageWidth - imageHeight) / 2 } else { originY = (imageHeight - imageWidth) / 2 } // Finds the biggest square in the pixel buffer and advances rows based on it. guard let inputBaseAddress = CVPixelBufferGetBaseAddress(self)?.advanced( by: originY * inputImageRowBytes + originX * imageChannels) else { return nil } // Gets vImage Buffer from input image var inputVImageBuffer = vImage_Buffer( data: inputBaseAddress, height: UInt(thumbnailSize), width: UInt(thumbnailSize), rowBytes: inputImageRowBytes) let thumbnailRowBytes = Int(size.width) * imageChannels guard let thumbnailBytes = malloc(Int(size.height) * thumbnailRowBytes) else { return nil } // Allocates a vImage buffer for thumbnail image. var thumbnailVImageBuffer = vImage_Buffer(data: thumbnailBytes, height: UInt(size.height), width: UInt(size.width), rowBytes: thumbnailRowBytes) // Performs the scale operation on input image buffer and stores it in thumbnail image buffer. let scaleError = vImageScale_ARGB8888(&inputVImageBuffer, &thumbnailVImageBuffer, nil, vImage_Flags(0)) CVPixelBufferUnlockBaseAddress(self, CVPixelBufferLockFlags(rawValue: 0)) guard scaleError == kvImageNoError else { return nil } let releaseCallBack: CVPixelBufferReleaseBytesCallback = {mutablePointer, pointer in if let pointer = pointer { free(UnsafeMutableRawPointer(mutating: pointer)) } } var thumbnailPixelBuffer: CVPixelBuffer? // Converts the thumbnail vImage buffer to CVPixelBuffer let conversionStatus = CVPixelBufferCreateWithBytes( nil, Int(size.width), Int(size.height), pixelBufferType, thumbnailBytes, thumbnailRowBytes, releaseCallBack, nil, nil, &thumbnailPixelBuffer) guard conversionStatus == kCVReturnSuccess else { free(thumbnailBytes) return nil } return thumbnailPixelBuffer } static func buffer(from image: UIImage) -> CVPixelBuffer? { let attrs = [ kCVPixelBufferCGImageCompatibilityKey: kCFBooleanTrue, kCVPixelBufferCGBitmapContextCompatibilityKey: kCFBooleanTrue ] as CFDictionary var pixelBuffer: CVPixelBuffer? let status = CVPixelBufferCreate(kCFAllocatorDefault, Int(image.size.width), Int(image.size.height), kCVPixelFormatType_32BGRA, attrs, &pixelBuffer) guard let buffer = pixelBuffer, status == kCVReturnSuccess else { return nil } CVPixelBufferLockBaseAddress(buffer, []) defer { CVPixelBufferUnlockBaseAddress(buffer, []) } let pixelData = CVPixelBufferGetBaseAddress(buffer) let rgbColorSpace = CGColorSpaceCreateDeviceRGB() guard let context = CGContext(data: pixelData, width: Int(image.size.width), height: Int(image.size.height), bitsPerComponent: 8, bytesPerRow: CVPixelBufferGetBytesPerRow(buffer), space: rgbColorSpace, bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue) else { return nil } context.translateBy(x: 0, y: image.size.height) context.scaleBy(x: 1.0, y: -1.0) UIGraphicsPushContext(context) image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height)) UIGraphicsPopContext() return pixelBuffer } } PixelBufferを3チャネルにします(VImageで)。TensorFlow公式のexampleプロジェクトからの引用です。 import Accelerate ... private func rgbDataFromBuffer( _ buffer: CVPixelBuffer, byteCount: Int, isModelQuantized: Bool ) -> Data? { CVPixelBufferLockBaseAddress(buffer, .readOnly) defer { CVPixelBufferUnlockBaseAddress(buffer, .readOnly) } guard let sourceData = CVPixelBufferGetBaseAddress(buffer) else { return nil } let width = CVPixelBufferGetWidth(buffer) let height = CVPixelBufferGetHeight(buffer) let sourceBytesPerRow = CVPixelBufferGetBytesPerRow(buffer) let destinationChannelCount = 3 let destinationBytesPerRow = destinationChannelCount * width var sourceBuffer = vImage_Buffer(data: sourceData, height: vImagePixelCount(height), width: vImagePixelCount(width), rowBytes: sourceBytesPerRow) guard let destinationData = malloc(height * destinationBytesPerRow) else { print("Error: out of memory") return nil } defer { free(destinationData) } var destinationBuffer = vImage_Buffer(data: destinationData, height: vImagePixelCount(height), width: vImagePixelCount(width), rowBytes: destinationBytesPerRow) let pixelBufferFormat = CVPixelBufferGetPixelFormatType(buffer) switch (pixelBufferFormat) { case kCVPixelFormatType_32BGRA: vImageConvert_BGRA8888toRGB888(&sourceBuffer, &destinationBuffer, UInt32(kvImageNoFlags)) case kCVPixelFormatType_32ARGB: vImageConvert_ARGB8888toRGB888(&sourceBuffer, &destinationBuffer, UInt32(kvImageNoFlags)) case kCVPixelFormatType_32RGBA: vImageConvert_RGBA8888toRGB888(&sourceBuffer, &destinationBuffer, UInt32(kvImageNoFlags)) default: // Unknown pixel format. return nil } let byteData = Data(bytes: destinationBuffer.data, count: destinationBuffer.rowBytes * height) if isModelQuantized { return byteData } // Not quantized, convert to floats let bytes = Array<UInt8>(unsafeData: byteData)! var floats = [Float]() for i in 0..<bytes.count { floats.append(Float(bytes[i]) / 255.0) } return Data(copyingBufferOf: floats) } 上記メソッドのためのextension extension Data { /// Creates a new buffer by copying the buffer pointer of the given array. /// /// - Warning: The given array's element type `T` must be trivial in that it can be copied bit /// for bit with no indirection or reference-counting operations; otherwise, reinterpreting /// data from the resulting buffer has undefined behavior. /// - Parameter array: An array with elements of type `T`. init<T>(copyingBufferOf array: [T]) { self = array.withUnsafeBufferPointer(Data.init) } } extension Array { /// Creates a new array from the bytes of the given unsafe data. /// /// - Warning: The array's `Element` type must be trivial in that it can be copied bit for bit /// with no indirection or reference-counting operations; otherwise, copying the raw bytes in /// the `unsafeData`'s buffer to a new array returns an unsafe copy. /// - Note: Returns `nil` if `unsafeData.count` is not a multiple of /// `MemoryLayout<Element>.stride`. /// - Parameter unsafeData: The data containing the bytes to turn into an array. init?(unsafeData: Data) { guard unsafeData.count % MemoryLayout<Element>.stride == 0 else { return nil } #if swift(>=5.0) self = unsafeData.withUnsafeBytes { .init($0.bindMemory(to: Element.self)) } #else self = unsafeData.withUnsafeBytes { .init(UnsafeBufferPointer<Element>( start: $0, count: unsafeData.count / MemoryLayout<Element>.stride )) } #endif // swift(>=5.0) } } 推論 推論実行します。 let outputTensor: Tensor do { let inputTensor = try interpreter.input(at: 0) // PixelBufferを3チャネルのDataに guard let rgbData = rgbDataFromBuffer( pixelBuffer, byteCount: batchSize * inputWidth * inputHeight * inputChannels, isModelQuantized: inputTensor.dataType == .uInt8 ) else { print("Failed to convert the image buffer to RGB data."); return nil } // Data を Tensorに. try interpreter.copy(rgbData, toInputAt: 0) // 推論実行 try interpreter.invoke() // 出力 outputTensor = try interpreter.output(at: 0) } catch let error { print("Failed to invoke the interpreter with error: \(error.localizedDescription)") ;return } 出力の取得 出力がuInt8だったら、Floatに直します。 let results: [Float] switch outputTensor.dataType { case .uInt8: guard let quantization = outputTensor.quantizationParameters else { print("No results returned because the quantization values for the output tensor are nil.") return } let quantizedResults = [UInt8](outputTensor.data) results = quantizedResults.map { quantization.scale * Float(Int($0) - quantization.zeroPoint) } case .float32: results = [Float32](unsafeData: outputTensor.data) ?? [] default: print("Output tensor data type \(outputTensor.dataType) is unsupported for this example app.") return } 結果はFloatの配列です。 今回の画像認識の場合、クラスラベル全てについての信頼度として返ってきます。 たとえば、1000クラスの場合は、1000個のFloatです。 // ラベル番号と信頼度のtupleの配列を作る [(labelIndex: Int, confidence: Float)] let zippedResults = zip(labels.indices, results) // 信頼度の高い順に並べ替え、上位一個の個数取得 let sortedResults = zippedResults.sorted { $0.1 > $1.1 }.prefix(1) let label = labels[sortedResults[0].0] let confidence = sortedResults[0].1 print("label:\(label)\nconfidence:\(confidence)") } label:spotlight confidence:0.84118 GitHubサンプル TensorFlowのexampleプロジェクトのモデル推論に必要な部分を抜粋・解説した内容です。 (コードは公式のものほとんどそのまま) ? フリーランスエンジニアです。 お仕事のご相談こちらまで rockyshikoku@gmail.com Core MLやARKitを使ったアプリを作っています。 機械学習/AR関連の情報を発信しています。 Twitter Medium