酢ろぐ!

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

NovelAI では同一プロンプト・同一シードでも同一の画像を生成できない

NovelAI では同一パラメータ(プロンプト・シードなど)でも同一の画像を生成できないのではないかと思い検証をおこなった。

結論としては「ピクセルベースで同一画像の生成はできない」であった。

2022年10月17日、NovelAI はユーザー数の増加に処理を捌ききれなくなってしまったので、サイトを数時間停止してアップデートがおこなわれた。これが結果に影響しているかどうかはわからない*1

画像間の比較方法

画像間の比較は、ImageMagick を使って diff 画像を生成して比較する。

対象A 対象B 差分
画像が同一の場合
画像が異なる場合

異なる画像の場合は、異なる箇所が赤色で差分として表示されていることがわかる。

比較結果

2022/10/21に同じプロンプト・同じシードを使って画像を生成した。

ぱっと見ほぼ同一画像のようだが、目視レベルでわかるくらい髪の毛の形状など異なる点が多い。念のため ImageMagick を使って差分も掲載する。

以上のことから、NovelAI では同一プロンプト・同一シードでも類似の画像を生成することは可能だが同一の画像を生成できない。

*1:[緊急メンテナンス終了] 緊急メンテナンス終了のお知らせです。インフラストラクチャの大幅な変更を行ったため、前日からの安定性の問題を解決したはずです。その結果、速度も大幅に向上しました。 https://twitter.com/novelaiofficial/status/1581924337493630976

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側で回避されるのかわからないが、いちアプリ開発者としては現状問題は発生していないので成り行きを見守ることとする。

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

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

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

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

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

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

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

import android.graphics.Color;

public class ColorUtil {

    private static double toSRgb(double c) {
        if (c <= 0.03928) {
            return c / 12.92;
        } else {
            return Math.pow((c + 0.055)/1.055, 2.4);
        }
    }

    private static double relativeLuminance(int color) {
        double red = (double) Color.red(color);
        double green = (double) Color.green(color);
        double blue = (double) Color.blue(color);

        double r = toSRgb(red / 255);
        double g = toSRgb(green / 255);
        double b = toSRgb(blue / 255);

        return 0.2126 * r + 0.7152 * g + 0.0722 * b;
    }

    public static int foregroundTextColor(int color) {
        // 背景色の相対輝度
        final double background = relativeLuminance(color);

        // 黒文字と白文字の判定に使う相対輝度境界値
        final double border = 0.17912878474779;

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

参考記事

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
    }
}

参考記事

flutter で go_router を使って画面遷移する

注意:go_router は現在更新頻度が高くバージョンアップも頻繁におこなわれています。この記事は go_router v3.x時代に書かれたもののため、現行の go_router では動かない可能性があります。ご注意ください。

flutter での画面遷移に躓いたので「flutterでの画面遷移方法がわからない」で色々試した。画面遷移ライブラリはたくさんあるようだが、現状では go_router についての情報が一番多かったので、右に倣えで私も go_router を使うことにした。

go_router のインストール

pubspec.yaml に go_router を追加した。バージョン情報には何を指定すれば良いのかわからないので「go_router | Flutter Package」をコピペした。

dependencies:
  flutter:
    sdk: flutter
  go_router: ^3.1.1

画面を定義する

サンプルアプリでは下図の画面遷移を想定している。設定画面から詳細画面へ遷移する理由については(あまり考えてないが)購入履歴画面から該当の商品ページへ遷移しているイメージである。

サンプルアプリの画面遷移図

Appクラスの定義は以下の通りとした。

class App extends StatelessWidget {
  App({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) => MaterialApp.router(
    routeInformationParser: _router.routeInformationParser,
    routerDelegate: _router.routerDelegate,
    title: 'ptcgscore',
  );

  final GoRouter _router = GoRouter(
    routes: <GoRoute>[
      GoRoute(
          name: "home",
          path: '/',
          builder: (BuildContext context, GoRouterState state) =>
            const HomeScreen(title: 'ホーム')
          ,
          routes: [
            GoRoute(
              name: "settings",
              path: 'settings',
              pageBuilder: (context, state) => const MaterialPage(
                fullscreenDialog: true,
                child: SettingsScreen(),
              ),
            ),
            GoRoute(
                name: "detail",
                path: "detail/:pid",
                builder: (BuildContext context, GoRouterState state) {
                  final String id = state.params['pid']!;
                  return DetailScreen(pid: id);
                }
            )
          ]
      ),
    ],
  );
}

ホーム画面から詳細画面へ遷移する

HomeScreen から DetailScreen へ遷移する時、iOS でいうところの右から左にスライドしてくるプッシュ的なトランジションで移動したい。この場合の説明をする。

ホーム画面のフロートボタンをタップした際に context.go('/detail/1'); を実行して遷移する。

// ... (省略)
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // 詳細画面へ遷移する
          context.go('/detail/1');
        },
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

ホーム画面から設定画面へ遷移する

HomeScreen から SettingsScreen へ遷移する時、iOS でいうところの下から上に画面が生えてくるモーダル的なトランジションで移動したい。この場合の説明をする。

ホーム画面のアクションバー上の設定ボタンをタップした際に context.go('/settings'); を実行して遷移する。

    return Scaffold(
      appBar: AppBar(
          title: Text(widget.title),
          actions: [
            IconButton(
              icon: const Icon(Icons.settings),
              tooltip: '設定',
              onPressed: () {
                // 設定画面へ遷移する
                context.go('/settings');
              },
            )
          ],
      ),
// ... (省略)

余談だがネイティブのiOSアプリと比べると下から上に画面が生えてくるトランジションに少し違和感があるように思える。pageSheet的な半モーダル表示にはできなさそうである (要調査)。

設定画面から詳細画面へ遷移する

SettingsScreen から DetailScreen へ遷移する時、iOS でいうところのプッシュ的なトランジションで移動したい。ただし、設定画面と詳細画面はパスが /settings/detail/:pid といったように同列に位置している。前述した context.go() を使う方法では画面遷移が壊れてしまう。この場合の説明をする。

設定画面と詳細画面は同列なので context.go() ではなく context.push()を使うことで、App#_router で定義した画面の親子関係を無視して画面遷移できる。

// ... (省略)
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // 設定画面と詳細画面は同列なので go ではなく pushを使う。
          context.push('/detail/2');
        },
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

遷移スタックがきちんと管理されているので、ブラウザで実行した際に戻るボタンを押しても適切にホーム画面に戻ることができる。

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:そしてライセンス的な問題がなければ

macOSで KMMのスケルトンプロジェクトの iosApp のビルドが通らない

macOSで Kotlin Multiplatform Mobile(KMM) のスケルトンプロジェクトの iosApp のビルドが通らない問題が発生した。

JVMのバージョンを11にする必要があったが、Android Studio内の設定で「Gradle JDK」を変更しても、システムのJDK(macOS組み込みのJDK)が使われてしまう問題であった。この現象が発生してしまうのが M1 Mac だからなのか macOS だからなのかについては検証できていない。

実行環境は以下の通り。

  • Android Studio Bumblebee | 2021.1.1 Patch 3
  • macOS 12.3.1
  • MacBook Pro(16インチ、2021)

KMM の iosApp のビルドができない

KMMのプラグインをインストールして、スケルトンプロジェクトを作成した。androidApp と iosApp が生成された。

androidApp の方は問題なくビルドでき Android エミュレータで実行できることを確認したが、iosApp の方はビルド時にエラーが発生してしまうことがわかった。

* What went wrong:
An exception occurred applying plugin request [id: 'com.android.application']
> Failed to apply plugin 'com.android.internal.application'.
   > Android Gradle plugin requires Java 11 to run. You are currently using Java 1.8.
     You can try some of the following options:
       - changing the IDE settings.
       - changing the JAVA_HOME environment variable.
       - changing `org.gradle.java.home` in `gradle.properties`.

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.

* Get more help at https://help.gradle.org

BUILD FAILED in 1s
** BUILD FAILED **

要はJava 1.8 ではなくて Java 11 を使えとのこと。Gradle JDK の設定値を確認してもAndroid Studioに同梱されている Java 11 を使っていることになっている。

Android Studio の Terminal で ./gradlew --version をした結果が以下の通り。

------------------------------------------------------------
Gradle 7.2
------------------------------------------------------------

Build time:   2021-08-17 09:59:03 UTC
Revision:     a773786b58bb28710e3dc96c4d1a7063628952ad

Kotlin:       1.5.21
Groovy:       3.0.8
Ant:          Apache Ant(TM) version 1.10.9 compiled on September 27 2020
JVM:          1.8.0_312 (Temurin 25.312-b07)
OS:           Mac OS X 10.16 x86_64

JVMが 1.8.0 になっていた。ちなみに Android Studio の設定で Gradle JDK を変更しても、システムの 1.8.0 を使っているようだった。

解決

macOSの ターミナルで vim ~/.zshenv して JAVA_HOME を指定した。

export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jre/Contents/Home

.zshenvを保存して、Android Studio を再起動した。

Android Studio の Terminal で ./gradlew --version をした結果が以下の通り。

------------------------------------------------------------
Gradle 7.2
------------------------------------------------------------

Build time:   2021-08-17 09:59:03 UTC
Revision:     a773786b58bb28710e3dc96c4d1a7063628952ad

Kotlin:       1.5.21
Groovy:       3.0.8
Ant:          Apache Ant(TM) version 1.10.9 compiled on September 27 2020
JVM:          11.0.11 (JetBrains s.r.o. 11.0.11+0-b60-7772763)
OS:           Mac OS X 12.3.1 aarch64

無事に KMM の iosApp をiOSシミュレータで起動するようになった。