酢ろぐ!

カレーが嫌いなスマートフォンアプリプログラマのブログ。

CoreML 向けの mlmodel モデルで物体検出/物体識別が可能なラベルの一覧を取得する

CoreMLでは画像識別/オブジェクト検出をおこなう機械学習モデルは .mlmodel または .mlpackage ファイルである。モデルファイルをダブルクリックするとXcodeが開き、下図のようにメタデータが表示される。

ここに表示されている Class Labels が検出または識別可能なオブジェクトの一覧であるが一括でコピペすることができない。

テキストファイルで Class Labelsの一覧が欲しいので 100個までは手作業で入力あるいは一行ずつコピペしたが、画像識別モデルで有名な MobileNet v2 はラベルが1,000個もありおおよそ現実的ではないと判断して、モデルのメタデータを参照する方法を調べた。

mlmodel ファイルで検出または識別可能なラベルの一覧を取得する

任意の mlmodel ファイルにおいて、オブジェクト検出/画像識別が可能なラベルの一覧を取得する。

if let mlModel = try? MobileNetV2FP16(configuration: MLModelConfiguration()).model {
    let classLabels = mlModel.modelDescription.classLabels
    
    print("### \(mlModel.configuration.modelDisplayName!)\n")
    classLabels?.forEach({ label in
        print("\(label)")
    })
    print("\n")
}

上記のサンプルコードを実行すると以下のリストが得られる。

### MobileNetV2FP16

background
tench, Tinca tinca
goldfish, Carassius auratus
great white shark, white shark, man-eater, man-eating shark, Carcharodon carcharias
tiger shark, Galeocerdo cuvieri
hammerhead, hammerhead shark
... 以下略

iOS 16 の場合、SwiftUI の List で セクションヘッダー上部にマージンが入ってしまう

iOS 15 / iOS 16 の場合、SwiftUI の List で セクションヘッダーに top padding (top margin?) が入ってしまう問題がある。

ヘッダー上部にマージンが入る現象自体は iOS 15 の時にも発生していたが UITableView.appearance().sectionHeaderTopPadding = 0 で解決できた。iOS 16 からは List の内部実装が UITableView から UICollectionView に変わったようで、iOS 16 ではこの対策は使えなくなった。

この手の問題は、たとえば List から UITableView を取り出す SwiftUI-Introspect などを使えばもっと簡単に解決するのかもしれないが、Apple の都合で内部実装が変わってしまう現状を鑑みると適切な対応ではないだろう。

本記事ではそれっぽい対応策を記載しているが、iOS 15 の場合とは異なり実践では使えないかもしれない。

結論からいえば、過去のデザイン(iOS 14 での表示)に拘らずに、iOSのバージョンごとの SwiftUI でのデフォルト表示に任せるのが一番良いだろう。

iOS 15 で SwiftUI の List で セクションヘッダー上部にマージンが入ってしまう

Xcode 13 登場時に話題になった。SwiftUI だけの問題ではなく UITableView への変更だったので UITableViewController を継承した画面にも下記のようなコードをたくさん埋め込んだ。

    override func viewDidLoad() {
        super.viewDidLoad()
//...
        if #available(iOS 15.0, *) {
            tableView.sectionHeaderTopPadding = 0
        }
    }

一方、SwiftUI の List ではどのように対策したかといえば、前述したように UITableView.appearance().sectionHeaderTopPadding = 0 を実行して対策した。

struct ListView: View {
    
    var body: some View {
        List {
            Section {
                Text("🍎")
                Text("🍊")
                Text("🍓")
            } header: {
                Text("Red")
                    .listRowInsets(EdgeInsets())
                    .frame(maxWidth: .infinity)
                    .background(Color.red)
            }
            Section {
                Text("🥒")
                Text("🥬")
                Text("🫑")
            } header: {
                Text("Green")
                    .listRowInsets(EdgeInsets())
                    .frame(maxWidth: .infinity)
                    .background(Color.green)
            }
        }
        .navigationTitle("iOS \(UIDevice.current.systemVersion)")
        .navigationBarTitleDisplayMode(.inline)
        .listStyle(.plain)
        .onAppear {
            if #available(iOS 15.0, *) {
                UITableView.appearance().sectionHeaderTopPadding = 0
            }
        }
    }
}

iOS 16 で SwiftUI の List で セクションヘッダー上部にマージンが入ってしまう

単に見た目を合わせるだけであれば「How to remove section header top padding in SwiftUI Plain List with iOS16 - Stack Overflow」を参考にして対応することができる。

var layoutConfig = UICollectionLayoutListConfiguration(appearance: .plain)
layoutConfig.headerMode = .supplementary
layoutConfig.headerTopPadding = 0

let listLayout = UICollectionViewCompositionalLayout.list(using: layoutConfig)
UICollectionView.appearance().collectionViewLayout = listLayout

コード全体は以下の通り。

struct ListView: View {
    
    var body: some View {
        List {
            Section {
                Text("🍎")
                Text("🍊")
                Text("🍓")
            } header: {
                Text("Red")
                    .listRowInsets(EdgeInsets())
                    .frame(maxWidth: .infinity)
                    .background(Color.red)
            }
            Section {
                Text("🥒")
                Text("🥬")
                Text("🫑")
            } header: {
                Text("Green")
                    .listRowInsets(EdgeInsets())
                    .frame(maxWidth: .infinity)
                    .background(Color.green)
            }
        }
        .navigationTitle("iOS \(UIDevice.current.systemVersion)")
        .navigationBarTitleDisplayMode(.inline)
        .listStyle(.plain)
        .onAppear {
            if #available(iOS 16.0, *) {
                var layoutConfig = UICollectionLayoutListConfiguration(appearance: .plain)
                layoutConfig.headerMode = .supplementary
                layoutConfig.headerTopPadding = 0

                let listLayout = UICollectionViewCompositionalLayout.list(using: layoutConfig)
                UICollectionView.appearance().collectionViewLayout = listLayout
            } else if #available(iOS 15.0, *) {
                UITableView.appearance().sectionHeaderTopPadding = 0
            }
        }
    }
}

懸念点

ただこの方法はあまり筋がよくない。たとえば SmartNews のような上部タブがあるアプリの場合、上部タブはおそらく UICollectionView を使って実装されている。UICollectionView.appearance() に対しての変更処理をおこなっているので、競合をおこして上部タブの表示が狂ってしまうだろう。

Xcode 14.0 と iOS 16 シミュレータの使用時、AdMob SDKの初期化をおこなうとセキュリティワーニングが表示される

アプリの起動時に紫色の警告が表示されるようになった。

調査したところ、Xcode 14.0 と iOS 16 シミュレータの使用時に AdMob SDKの初期化をおこなうと、このセキュリティワーニングが表示されるようだ。

AdMob SDKの初期化をおこなうとセキュリティワーニングが表示される

Xcode 14.0 と iOS 16 シミュレータ を使用している場合、アプリ起動時に GADMobileAds.sharedInstance().start(completionHandler: nil) を実行すると下記のようなエラーが発生する。

[Security] This method should not be called on the main thread as it may lead to UI unresponsiveness.
(意訳) [セキュリティ] このメソッドは、UIが応答しなくなる可能性があるため、メインスレッドで呼び出すべきではありません。

アプリでの実装は、2022/09/24時点の「スタートガイド  |  iOS  |  Google Developers」で提示されているサンプルコードと同じである。

import GoogleMobileAds

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

  func application(_ application: UIApplication,
      didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {

    GADMobileAds.sharedInstance().start(completionHandler: nil)

    return true
  }

}

どうしてこのセキュリティワーニングが表示されるのか?

セキュリティワーニングが表示されている理由として、1ヶ月前に投稿された「Xcode: Method called om main thread causing unresponsiveness」に回答を見つけた。

I just want to circle back on this. The team mentioned that this is a bug with Xcode 14 beta 6 / WebKit and that Apple will need to fix it.
(意訳) この件に関して周知したいと思います。これは Xcode 14 beta 6 / WebKit のバグであり、Apple が修正する必要があると言及しました。

さらに Apple のフォーラム「Xcode 14 & iOS 16 purple warni… | Apple Developer Forums」に以下のように推測している方がいた。

I'm going to assume someone on the Xcode team chose to add this warning in the event a developer doesn't account for slow loading or not loading at all an initial wkwebview when the app first launches? Any other theories? Is it safe to ignore the warning?
(意訳) Xcodeチームの誰かが、開発者がアプリを最初に起動したときに、読み込みが遅いことや、最初のwkwebviewを全く読み込まないことを考慮しない場合に、この警告を追加することにしたのだと推測しています。他の説はありますか?警告を無視しても大丈夫ですか?

これらのことからセキュリティワーニングが表示される理由を推測した。

  • AdMob SDKの初期化時にプリロードがおこなわれる
  • プリロードとは、WebKitでのコンテンツの読み込みが発生
  • Xcode 14.0 ではアプリ起動時にWebKit の読み込みを許さない

Apple に認識されているこの問題であるが、2022/09/24現在の Xcode 14.0 Release でも発生しているし、Xcode 14.1 Beta にも今のところ WebKit に関する修正は現在のところリリースノートに明記されていない。

Apple側で修正されるのかGoogle側で回避されるのかわからないが、いちアプリ開発者としては現状問題は発生していないので成り行きを見守ることとする。

Swiftで 背景色に応じた見やすい文字色を求める

ユーザーがタグ色を選択できるプロダクトの場合、ユーザーが決めた色の上にテキストを配置しなければいけない。たとえば、下図の GitHub のタグのような場合である。

背景色に応じて、視認しやすいテキストカラーが利用されていることがわかる。

  • enhancement の背景色は #b5f0f2 で、テキスト色は黒色
  • task の背景色は #2b76db で、テキスト色は白色

当初は YCbCr や HSV の輝度を使って判断しようと考えていたが、調査を進めたところ、背景色に応じた見やすい文字色を求めるには WCAG 2.0 を利用するのが良いことがわかった。

背景色に応じて見やすい文字色を求める

元ネタの C# 版を Swift版に書き換えた。

import UIKit

struct ColorUtils {

    static func relativeLuminance(_ color: CGColor) -> CGFloat {
        let sRGBColorSpace = CGColorSpace(name: CGColorSpace.extendedSRGB)!
        let sRGB = color.converted(
            to: sRGBColorSpace,
            intent: .defaultIntent,
            options: nil)!
            .components!
        let adjusted = sRGB.map { (c) -> CGFloat in
            if c <= 0.03928 {
                return c / 12.92
            } else {
                return pow((c + 0.055)/1.055, 2.4)
            }
        }
        return 0.2126 * adjusted[0] +
            0.7152 * adjusted[1] +
            0.0722 * adjusted[2]
    }

    static func foregroundTextColor(_ background: UIColor) -> UIColor {
        // 背景色の相対輝度
        let background = relativeLuminance(background.cgColor)

        // 黒文字と白文字の判定に使う相対輝度境界値
        let border: CGFloat = 0.17912878474779

        // 背景色の相対輝度が境界値以上なら黒文字、未満なら白文字
        return background >= border ? UIColor.black : UIColor.white
    }
}

参考記事

iOS/macOSでFFmpegを使ってh264エンコードすると画質が悪くなる問題 (h264_videotoolbox vs libx264)

f:id:ch3cooh393:20220306131429p:plain

iOSアプリにtanersener/ffmpeg-kitを組み込んで、iPhoneで録画した動画をffmpegを使ってh264エンコードするとどの動画にもノイズが入る問題が発生した。macOS上で同じ動画をエンコードした場合にはノイズが乗らない。なぜノイズの有無が発生してしまうのか? この差異を認識するのに随分時間をかけてしまった。

結論としてはffmpegで使われるエンコーダーが異なるのが原因である。iOSアプリ上ではApple社が実装したコーデックの h264_videotoolbox が使われ、macOS上では libx264 が使われる。h264_videotoolboxはハードウェアエンコードをおこなうのでエンコード時間は短くて済むが画質が悪くなってしまう。

h264 といったエイリアスを使うのではなく、厳密な名称である h264_videotoolboxlibx264 を指定すれば、macOS上でも同等の現象を再現できる。

画質が悪いことに気づいた経緯

以下のパラメータを使って動画のエンコードをした。テスト動画を用いて複数のパラメータを使ってエンコードして、サイズと画質のバランスが良いパラメータを選んだ。

ffmpeg -i original.mov -c:v h264 -b:v 1000k output_h264.mp4

しかしiOS上で動画をエンコードすると下図のようにブロックノイズが乗ってしまう。期待する画質が得られないことがわかった。

f:id:ch3cooh393:20220306120811p:plain
左:macOSでエンコードした動画(libx264が使われている)。右:iOSでエンコードした動画(h264_videotoolboxが使われている)。

当初は同じh264エンコーダーを指定しているのに何故画質に違いが出るのかわからなかった。

-c:v h264 はエイリアスであり内部的には異なるコーデックが使われていた

ffmpegに詳しい方にとっては当たり前のことなのかもしれないが、-c:v h264 はエイリアスであり内部的には異なるコーデックが使われていた。これは数日調査してわかった。

homebrew経由でインストールした ffmpeg で利用可能なコーデックを調べると以下の結果が得られる。

$ffmpeg -codecs

 DEV.LS h264                 H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 (encoders: libx264 libx264rgb h264_videotoolbox )

iOSアプリ上で ffmpeg -codecs を実行してみたところ以下の結果が得られた。iOSアプリ上では libx264 を使うことができない*1

 DEV.LS h264                  (encoders: h264_videotoolbox )

つまり -c:v h264 はエイリアスであり、h264を指定するとmacOSでは libx264 のエンコーダーが使われ、iOSでは h264_videotoolbox のエンコーダーが使われることがわかった。

エンコードパラメータとして h264 を使うのではなく、より厳密な h264_videotoolboxlibx264 を指定していればもっと早く画質が悪い原因が特定できていたと思う。

h264_videotoolbox を使って高画質動画にするためにはビットレートを上げる

iOSでは h264_videotoolbox を使わないといけないが、libx264 の感覚でビットレートを指定すると低画質になってしまう。h264_videotoolboxエンコーダーを使って高画質の動画にするにはどうすればよいか? 答えはStack Overflowに書かれていた。

SOのAnswerによると以下の通りである。ここでは libx265 と書かれているがこのまま libx264 と読み変えられる。

多くのハードウェアアクセラレーションエンコーダと同様に、hevc_videotoolbox は libx265 ほど効率的ではありません。そのため、libx265と同等の品質を実現するには、かなり高いビットレートを与えなければならないかもしれません。これは、H.264からHEVC/H.265に再エンコードする目的を失うかもしれません

h264_videotoolbox を使う場合には libx264 より高いビットレートを指定しなければいけないことがわかった。どこまでビットレートを上げれば同等くらいの画質になるのか検証した。ここでの「同等くらいの画質」はさくさん基準によるため、各位には動画の性質によって適切な求めてもらいたい。そして良さげなビットレート設定を教えてもらえると嬉しい。

libx264(1000k) vs h264_videotoolbox(5000k)

h264_videotoolboxでの指定ビットレートを1000kから5000kに変更した。パラメータは下記のように指定している。

ffmpeg -i original.mov -c:v h264_videotoolbox -b:v 5000k output_videotool_5000k.mp4

h264_videotoolboxでもビットレートを -b:v 5000k まであげることで、libx264の動画よりも高画質にすることができた。しかしlibx264の動画サイズは 17.7MB に対して、h264_videotoolboxの動画サイズは 78.9MB とかなり大きくなってしまった。

f:id:ch3cooh393:20220306123934p:plain
左:libx264を使って-b:v 1000k指定した動画。右:h264_videotoolboxを使って-b:v 5000k指定した動画。

libx264(1000k) vs h264_videotoolbox(2000k)

h264_videotoolboxでの指定ビットレートを1000kから2000kに変更した。パラメータは下記のように指定している。

ffmpeg -i original.mov -c:v h264_videotoolbox -b:v 2000k output_videotool_2000k.mp4

h264_videotoolboxの指定ビットレートを -b:v 2000k にあげることで、libx264の動画と同等の画質にすることができた。libx264の動画サイズは 17.7MB に対して、h264_videotoolboxの動画サイズは 32.9MB と2倍程度に抑えることができている。

f:id:ch3cooh393:20220306124406p:plain
左:libx264を使って-b:v 1000k指定した動画。右:h264_videotoolboxを使って-b:v 2000k指定した動画。

結論

以上のことからffmpegで使われるエンコーダーが異なるのが原因であることがわかった。

iOSアプリ上ではApple社が実装したコーデックの h264_videotoolbox が使われ、macOS上では libx264 が使われる。h264_videotoolboxはハードウェアエンコードをおこなうのでエンコード時間は短くて済むが画質が悪くなってしまう。macOSでも-c:v h264_videotoolboxと指定すればハードウェアエンコーダーを使うことができる。

M1 MacBook Proを使っていてもlibx264を使うと等倍でしかエンコードできない。これは10分の動画をエンコードするのに10分かかってしまうことを意味している。対して h264_videotoolbox は6倍でエンコードできる。これは10分の動画を約1.6分でエンコードできることを意味している。

ユーザーを待たせることができるのであれば*2libx264一択で良いだろう。画質面・ファイルサイズ面で優れている。もしユーザーを待たせることができないのであれば h264_videotoolbox を使い、指定ビットレートを上げてファイルサイズを犠牲にして画質を上げる必要がある。

*1:厳密的には異なるが本記事での紹介は省く

*2:そしてライセンス的な問題がなければ

VNRecognizeTextRequestを使って画像からテキストを取得する

iOS 13から画像から顔認識やバーコード、テキスト認識する Vision.framework が実装された。本記事では VNRecognizeTextRequest を使って画像からテキスト(String)を抽出する方法を紹介する。このクラスでは Convert Image to String をおこなう。

よく似たクラス名の VNDetectTextRectanglesRequest はテキスト領域の矩形情報を得るために使用する。これはOCRに掛ける前段階で使うと良さそうだ。VNDetectTextRectanglesRequest ではテキストを抽出できないので注意したい。

VNRecognizeTextRequest がサポートしている言語を調べる

VNRecognizeTextRequest がサポートしている言語を調べる。

let recognizeTextRequest = VNRecognizeTextRequest()
let languages = try! recognizeTextRequest.supportedRecognitionLanguages()
print("\(languages)")

iOS 15.4時点ではサポートされている言語は以下の通りである。日本語には対応していない。

["en-US", "fr-FR", "it-IT", "de-DE", "es-ES", "pt-BR", "zh-Hans", "zh-Hant"]

中国語が含まれているなら日本語にも対応してほしいけれど、単純に言語話者の数が多い順にAppleは対応していっているのかもしれない。2世代先のiOS 17くらいでは対応しているのかもしれない。

2022年現在、画像から日本語のテキストを得たいのであれば、Googleの ML Kit on iOS を併用した方が良いかもしれない。本ブログでも後日紹介したい。

VNRecognizeTextRequestを使って画像からテキストを取得する

表題の通り VNRecognizeTextRequest を使って画像からテキストを取得する。対応言語に日本語が含まれていないため、ここでは英語版の「リングフィット アドベンチャー -Switch」のトレーニング結果画像を利用する。

f:id:ch3cooh393:20220328085253j:plain

// 解析対象であるターゲット画像を取得する
guard let input = UIImage(named: "sample_summary_en")?.cgImage else {
    return
}

// テキスト解析後に実行される (非同期処理/あとで呼ばれる)
let request = VNRecognizeTextRequest { (request, error) in
    if let results = request.results as? [VNRecognizedTextObservation] {
        let strings = results.compactMap { observation in
            observation.topCandidates(1).first?.string
        }
        // 得られたテキストを出力する
        print(strings)
    }
}

// テキスト解析を開始する
let handler = VNImageRequestHandler(cgImage: input, options: [:])
DispatchQueue.global(qos: .userInteractive).async {
    do {
        try handler.perform([request])
    } catch {
        print(error)
    }
}

以下の結果が得られた。

["Today\'s Results", "R Take Screenshot", "Consumed with Fitness", "USER_NAME", "Total Time Exercising", "16 min. 59", "sec.", "Total Calories Burned", "147.03 kcal", "Total Distance Run", "0.94 km", "Next"]

「16 min. 59 sec.」部分が「16 min. 59」と「sec.」に分割されてしまったが、それ以外に問題はなく高精度でテキストを取得できている。

日本語に対応していれば嬉しかったが残念ながらiOS 15現時点では対応していないので、前述の通りGoogleのML Kitを使うのが良いのかもしれない。

Xcode 13.3でアプリバイナリのexportに失敗する問題

昨日、App StoreにてXcode 13.3がリリースされた。早速アップデートしたところ、Bitriseにて特定のアプリで下記のようなエラーが発生した。コンパイル自体は問題なくその後のアーカイブの段階で失敗している。

error: exportArchive: ipatool failed with an exception: #<CmdSpec::NonZeroExitException: $ /Applications/Xcode-13.3.Release.Candidate.app/Contents/Developer/usr/bin/python3 /Applications/Xcode-13.3.Release.Candidate.app/Contents/Developer/usr/bin/bitcode-build-tool -v -t /Applications/Xcode-13.3.Release.Candidate.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin --sdk /Applications/Xcode-13.3.Release.Candidate.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk -o /var/folders/ds/8lz3xw2s09jfp13nlv1tpgjc0000gn/T/ipatool20220315-7746-1lue8qz/thinned-out/arm64/Payload/MY_APPLICATION.app/Frameworks/libswift_Concurrency.dylib --generate-dsym /var/folders/ds/8lz3xw2s09jfp13nlv1tpgjc0000gn/T/ipatool20220315-7746-1lue8qz/thinned-out/arm64/Payload/MY_APPLICATION.app/Frameworks/libswift_Concurrency.dylib.dSYM --strip-swift-symbols /var/folders/ds/8lz3xw2s09jfp13nlv1tpgjc0000gn/T/ipatool20220315-7746-1lue8qz/thinned-in/arm64/Payload/MY_APPLICATION.app/Frameworks/libswift_Concurrency.dylib

ローカルPCで試したところAdHocバイナリのexportでも同じエラーが発生した。

f:id:ch3cooh393:20220316101638p:plain

エラー内容から Swift concurrency に起因する問題だと理解できればよかったのだが、いろんなアプリでBuild Settingsを変更して差異を確認することになった。最終的には現象が異なるが「IllustailがiOS14で起動しない(解決) - CatHand Blog」と同様に Swift concurrency のバックポートにおける問題ではないかと判明した。

結論としては、本件はXcode 13.3における既知の不具合である。

Known Issues

Exporting an app that uses Swift’s concurrency features from an archive with bitcode might fail when the app targets iOS 13.0 – iOS 14.7, watchOS 6.0 – watchOS 7.6, or tvOS 13.0 – 14.7. (89271047)

Workaround: Either uncheck the box Rebuild from bitcode when exporting the app from an archive or disable bitcode (iOS only).

Xcode 13.3 Release Notes

iOS 15.0以下(iOS 13.0〜14.7)が、Deployment target に含まれているアプリ内で Swift concurrency を利用している場合にバイナリのexportに失敗してしまう。

Swift concurrencyを使用しているライブラリをインポートしていても同様なようで、アプリ内DBとしてRealmを使っているがアプリ内コードとしてSwift concurrencyを使っていない ptcgnote でも Enable Bitcode を ON にしたままではAdHocバイナリのexportに失敗してしまった。

解決策としては「Enable bitcodeをNOにする」か「deployment targetをiOS 15以上にあげる」のどちらかになりそうだ。