写真を1枚選ぶと、それが夜景なのか、空なのか、抽象的なパターンなのかを、端末の中だけで判定したい。壁紙アプリに小さな分類機能を足そうとしたとき、手が止まったのはモデル選びではありませんでした。
React Native(Expo)側に TensorFlow Lite を積むのか。Rork Max が吐くネイティブ Swift で Core ML に寄せるのか。詰まったのはそこです。
料金にも差があります。通常の Rork は無料から始められて有料が月25ドル、Rork Max は月200ドル。個人開発の財布には、決して軽い差ではありません。
結局、両方を同じ題材で組んでみました。そこで分かったのは、私が比べるべきだったのは推論エンジンの速さではなかった、ということです。
比べる前に置いた3つの問い
手を動かす前に、判断を分ける問いを3つだけ決めました。これを決めずに実装に入ると、「動いた方」を選んでしまいます。
その機能は、推論結果が出るまでユーザーを待たせてよいか — 撮影して1枚判定するのか、カメラのプレビューに追従するのか
モデルは自分で差し替え続けるか — 出来合いの分類器を1つ積むだけなのか、自前データで再学習して更新し続けるのか
画像以外に、その端末の何を触りたくなるか — 半年後にウィジェットや写真ライブラリの深い部分へ手が伸びないか
この3問めが、後から効いてきました。
React Native 側 — TFLite を積むルートは1本ではありません
まず通常の Rork が吐く React Native(Expo)の土台に TFLite を積みます。ここで最初の分かれ道があります。
TensorFlow.js の React Native バインディングを経由するルートと、ネイティブの TFLite を JSI で直接叩くルート。同じ「TFLite を使う」でも、実装の手触りがまったく別物です。
私は最初、手軽さに引かれて tfjs 経由で組みました。動きます。動きますが、シャッターを押してから結果が出るまで、一拍置かれる感触が残りました。
手軽な方のコード、そして残った引っかかり
// utils/classifyViaTfjs.ts
import * as tf from '@tensorflow/tfjs' ;
import { decodeJpeg } from '@tensorflow/tfjs-react-native' ;
import * as FileSystem from 'expo-file-system' ;
// 画像を「文字列」に変換してから読み戻す — ここが後で効いてきます
export async function classifyViaTfjs ( model : tf . GraphModel , uri : string ) {
const b64 = await FileSystem. readAsStringAsync (uri, {
encoding: FileSystem.EncodingType.Base64,
});
const bytes = tf.util. encodeString (b64, 'base64' );
const image = decodeJpeg ( new Uint8Array (bytes.buffer));
// 224x224 に落として -1〜1 に正規化し、バッチ次元を足す
const input = tf. tidy (() =>
tf.image. resizeBilinear (image, [ 224 , 224 ]). div ( 127.5 ). sub ( 1 ). expandDims ( 0 )
);
const logits = model. predict (input) as tf . Tensor ;
const probs = await logits. data ();
// tidy の外で作ったテンソルは自分で捨てます。ここを忘れると数十枚で膨らみます
tf. dispose ([image, input, logits]);
return probs;
}
tf.tidy を挟んでいるのは、中間テンソルをまとめて回収するためです。ただし tidy は非同期処理を跨げないので、decodeJpeg の結果や predict の出力は自分で dispose する必要があります。ここを落とすと、数十枚流したあたりからメモリが目に見えて膨らみます。
なぜ気をつけるかというと、この手のリークは「開発中の10枚」では絶対に見えないからです。見えるのは、ユーザーがアルバムを延々スクロールした後です。
そして肝心の引っかかりは、リークではなく画像の渡し方の方にありました。ファイルを Base64 文字列にして、JS のブリッジを通して、また Uint8Array に戻す。2MB の写真なら、Base64 化した時点で約2.7MB の文字列がメモリに乗ります。
推論は速いのに、待たされる。この時点で、私が測るべき対象がはっきりしました。
Rork Max 側 — Core ML が短いのは推論ではなく助走です
Rork Max は React Native ではなくネイティブ Swift を生成します。同じ分類を Vision と Core ML で書くと、こうなります。
// ImageClassifier.swift
import CoreML
import Vision
final class ImageClassifier {
private let request: VNCoreMLRequest
init ( model : MLModel) throws {
let vnModel = try VNCoreMLModel ( for : model)
request = VNCoreMLRequest ( model : vnModel)
// リサイズと正規化を Vision に任せます。自前で書かない、が要点です
request.imageCropAndScaleOption = .centerCrop
}
/// CGImage をそのまま渡します。Base64 も中間ファイルも経由しません
func classify ( _ cgImage: CGImage) throws -> [( String , Float )] {
let handler = VNImageRequestHandler ( cgImage : cgImage, options : [ : ])
try handler. perform ([request])
guard let results = request.results as? [VNClassificationObservation] else {
return []
}
return results. prefix ( 5 ). map { ( $0 .identifier, $0 .confidence) }
}
}
行数が減ったこと自体は、大した話ではありません。効いているのは VNImageRequestHandler に CGImage を直接渡せる点です。
React Native 側で私が苦労していたリサイズ・正規化・バッチ次元の追加は、Vision が引き受けます。前処理のコードが消えたのではなく、前処理がプロセス境界を跨がなくなった ということです。
もう一つ、初回だけは別物である点は Core ML でも変わりません。モデルのコンパイルと計算グラフの準備が初回推論に乗るので、起動直後の1枚だけが目に見えて遅くなります。この助走をどこで踏むかは設計の問題で、オンデバイス Core ML を、起動の重さとサーマルで破綻させない設計 で扱っている論点と地続きです。
同じ題材で測る — 内訳を取らないと比較になりません
「どちらが速いか」を推論時間だけで比べると、判断を間違えます。ユーザーが体感するのは、指がシャッターに触れてから画面が変わるまでの全部だからです。
そこで両方に同じ計測点を置きました。まず React Native 側。
// utils/latencyProbe.ts
type Stage = 'capture' | 'read' | 'decode' | 'preprocess' | 'infer' | 'postprocess' ;
export class LatencyProbe {
private marks : Array <[ Stage , number ]> = [];
private t0 = 0 ;
start () {
this .marks = [];
this .t0 = performance. now ();
}
/// 各段の「終わった時刻」を積むだけ。差分は後で取ります
mark ( stage : Stage ) {
this .marks. push ([stage, performance. now ()]);
}
/// 段ごとの所要 ms と合計を返します
report () : { breakdown : Record < string , number >; totalMs : number } {
const breakdown : Record < string , number > = {};
let prev = this .t0;
for ( const [ stage , at ] of this .marks) {
breakdown[stage] = Math. round ((at - prev) * 10 ) / 10 ;
prev = at;
}
return { breakdown, totalMs: Math. round ((prev - this .t0) * 10 ) / 10 };
}
}
使う側は、段の切れ目で mark を呼ぶだけです。
const probe = new LatencyProbe ();
probe. start ();
const photo = await cameraRef.current. takePictureAsync ({ quality: 0.5 , skipProcessing: true });
probe. mark ( 'capture' );
const b64 = await FileSystem. readAsStringAsync (photo.uri, { encoding: FileSystem.EncodingType.Base64 });
probe. mark ( 'read' );
const image = decodeJpeg ( new Uint8Array (tf.util. encodeString (b64, 'base64' ).buffer));
probe. mark ( 'decode' );
const input = tf. tidy (() => tf.image. resizeBilinear (image, [ 224 , 224 ]). div ( 127.5 ). sub ( 1 ). expandDims ( 0 ));
probe. mark ( 'preprocess' );
const logits = model. predict (input) as tf . Tensor ;
const probs = await logits. data ();
probe. mark ( 'infer' );
const top5 = toTop5 (probs);
probe. mark ( 'postprocess' );
console. log (probe. report ());
// 出力例: { breakdown: { capture: 118.4, read: 96.2, decode: 141.7, preprocess: 28.9, infer: 19.6, postprocess: 3.1 }, totalMs: 407.9 }
Swift 側は os_signpost で同じ区間に名前を付ければ、Instruments で同じ粒度の内訳が取れます。
import os
let log = OSLog ( subsystem : "net.rorklab.classifier" , category : .pointsOfInterest)
os_signpost (.begin, log : log, name : "decode" )
let cgImage = try decodeImage ( from : data)
os_signpost (. end , log : log, name : "decode" )
os_signpost (.begin, log : log, name : "infer" )
let results = try classifier. classify (cgImage)
os_signpost (. end , log : log, name : "infer" )
内訳の読み方
手元の計測例を1つ置きます。数値は端末・モデル・画像サイズで大きく動くので、必ずご自身の実機で取り直してください。 ここで見ていただきたいのは絶対値ではなく、どの段が支配的かという形の方です。
推論は全体の5%ほどでした。残りは、画像をどう運ぶかに費やされていたということです。
つまり量子化でモデルを14MBから3.5MBに縮めても、20msが13msになるだけ。体感は変わりません。効くのは read と decode を消すことです。
この気づきの後で見ると、量子化の位置づけも変わります。私にとって量子化はもう速度対策ではなく、アプリサイズ対策の手段です。この整理は Rork Max の Machine Learning 最適化 — Core ML 統合・端末別パフォーマンス調整・量子化からサイズ削減まで の観点とも噛み合います。
効いた順に直す
内訳が取れると、直す順番が決まります。React Native 側に留まる前提でも、ここまでは詰められました。
Base64 の往復をやめる — JSI 直結のライブラリに寄せ、ArrayBuffer を直接渡します。read が段ごと消えます
撮影解像度を落とす — 224×224 に縮めるなら、12MP で撮る必要はありません。takePictureAsync の quality だけでなく、可能なら小さいプリセットで撮ります
デコードをネイティブへ寄せる — フレームプロセッサを使えば、そもそもファイルに書き出さずに済みます
助走を隠す — 起動直後にダミー画像で1回推論を回し、初回の重さをユーザーの操作の外に追い出します
4番めは地味ですが、体感への効き目が最も大きい部類でした。初回だけ数百ms余計にかかるのは避けられません。避けられないなら、見えない場所で払っておく。
ここまでやれば、React Native 側でも「一拍待たされる」感触はかなり消えます。逆に言えば、ここまでやっても届かない要件だけが、Rork Max を検討する理由になります。
月200ドルが正当化されるのは、画像認識単体ではありません
費用の話に戻ります。通常の Rork は無料開始・有料が月25ドル、Rork Max は月200ドル。差は月175ドル、年で2,100ドルです。
正直なところ、画像分類を1つ載せたいだけなら、この差は正当化しにくいと感じました。上に書いた4つの手当てで、React Native 側でも実用の範囲に入るからです。
私の場合、この種のアプリは AdMob の広告収益で回しています。月175ドルの固定費は、その収益から先に引かれる数字です。だから問いは「速くなるか」ではなく「その固定費を毎月払い続ける理由があるか」に変わります。分類が0.3秒速くなることに、年2,100ドルの値札は付きませんでした。
判断が変わるのは、3つめの問い — その端末の他に何を触りたくなるか — に「ある」と答えたときでした。
私の壁紙アプリの場合、App Store と Google Play の両方へ同じコードで出す前提があったので、React Native 側に留まりました。もしこれが Apple 専用で、しかもウィジェットに分類結果を出したい要件だったなら、迷わず Rork Max を選んだと思います。
上位版と下位版だと捉えると、判断を誤ります。生成される土台そのものが Swift と React Native で別物である以上、選ぶ基準は価格ではなく、作りたいものがどこまで Apple の機能に踏み込むかです。
なお Core ML 側でモデルを自前で持つ場合の変換や運用は、Rork × Core ML カスタムモデル開発 × オンデバイスAI に整理があります。
決め方を1行にすると
半年後に触りたくなる端末機能を数えて、それがゼロなら React Native、1つでもあるなら Rork Max。
画像認識そのものの速さは、この判断にほとんど寄与しませんでした。推論は全体の5%だったからです。速さで選ぼうとして手が止まっていた時間が、いま思えば一番の無駄でした。
まず内訳を測ってください。自分の端末で、自分の画像サイズで。支配的な段が見えた瞬間に、選ぶべき道は自然と一本に絞られます。私の場合はそれが read と decode で、答えは「エンジンを乗り換える」ではなく「画像を文字列にするのをやめる」でした。
計測の道具は上に置いた通りです。お読みいただきありがとうございました。