はじめに
mapengu は「日本でペンギンに会える場所」を地図で探せるiOSアプリです。
v1.6 で追加したペンギン判定カメラは、スマホをペンギンに向けるだけで種類をリアルタイム判定し、そのまま飼育施設を地図で探せる機能です。
この記事では、モデルの学習から iOS への組み込み・UI 設計まで、実装のすべてを解説します。
全体の構成
[Create ML でモデル学習]
↓ .mlpackage
[Xcode プロジェクトに組み込み]
↓
[AVCaptureSession] → カメラ映像取得
↓ 30フレームに1回
[VNCoreMLRequest] → ペンギン種別を推論
↓ ラベル + 信頼度
[SwiftUI] → 結果カード表示 + 地図検索へ遷移
Step 1 — Create ML でモデルを学習する
タスク種別
Xcode 付属の Create ML(バージョン 6.2)を使います。
タスク種別は Image Classifier(画像分類)を選択します。
対象クラスの選定
動物園・水族館でよく見られる 11 種を選びました。
| 英語名 | 日本語名 |
|---|---|
| Emperor Penguin | コウテイペンギン |
| King Penguin | キングペンギン |
| Gentoo Penguin | ジェンツーペンギン |
| Adelie Penguin | アデリーペンギン |
| Chinstrap Penguin | ヒゲペンギン |
| Rockhopper Penguin | イワトビペンギン |
| Macaroni Penguin | マカロニペンギン |
| African Penguin | ケープペンギン |
| Humboldt Penguin | フンボルトペンギン |
| Magellanic Penguin | マゼランペンギン |
| Little Penguin | コガタペンギン |
データセットの構成
ディレクトリ構造はシンプルです。クラス名をそのままフォルダ名にします。
penguin_dataset/
├── Emperor Penguin/
│ ├── 0001.jpg
│ ├── 0002.jpg
│ └── ...
├── King Penguin/
│ └── ...
└── ...(11クラス)
学習は 2 回試みました。
| バージョン | 枚数/クラス | 合計 | 収束 |
|---|---|---|---|
| v1 | 100枚 | 1,100枚 | 19イテレーション |
| v2 | 500枚 | 5,500枚 | 26イテレーション |
v1 は枚数が少なく特定クラスの精度が不安定だったため、v2 でデータを 5 倍に増やして再学習しました。
Create ML の転移学習ベースのため、Mac 単体で数十分以内に完了します。
モデルの出力
学習後、Export Model から .mlpackage 形式で書き出し、Xcode プロジェクトにドラッグ&ドロップするだけで組み込めます。コンパイルは Xcode ビルド時に自動で行われます。
Step 2 — iOS への組み込み(PenguinIdentifierViewModel)
カメラのセットアップ
private func configureCaptureSession() {
captureSession.beginConfiguration()
captureSession.sessionPreset = .high // 高解像度で取得
guard let device = AVCaptureDevice.default(
.builtInWideAngleCamera, for: .video, position: .back),
let input = try? AVCaptureDeviceInput(device: device)
else { return }
captureSession.addInput(input)
videoOutput.setSampleBufferDelegate(self, queue: visionQueue)
videoOutput.alwaysDiscardsLateVideoFrames = true
captureSession.addOutput(videoOutput)
captureSession.commitConfiguration()
captureSession.startRunning()
}
推論はすべて専用の visionQueue(QoS: .userInitiated)で行い、メインスレッドをブロックしません。
推論頻度の制御
func captureOutput(_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection) {
frameCount += 1
guard frameCount % 30 == 0 else { return } // 30フレームに1回だけ推論
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer),
let request = visionRequest else { return }
let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: .right)
try? handler.perform([request])
}
60fps のカメラなら約 2 回/秒の推論。体感的に十分なレスポンスを保ちながら、CPU・バッテリー消費を大幅に抑えられます。
カスタムモデル優先 + フォールバック設計
カスタムモデルが Bundle に存在すれば優先し、なければ Apple 標準モデルで代替します。
private func setupVisionRequest() {
if let coreMLRequest = loadCoreMLRequest() {
visionRequest = coreMLRequest
usingCustomModel = true
} else {
visionRequest = makeBuiltinRequest() // VNClassifyImageRequest にフォールバック
}
}
private func loadCoreMLRequest() -> VNCoreMLRequest? {
// .mlmodelc(コンパイル済み)と .mlpackage の両方を探す
guard let modelURL = Bundle.main.url(forResource: "PenguinClassifier", withExtension: "mlmodelc")
?? Bundle.main.url(forResource: "PenguinClassifier", withExtension: "mlpackage"),
let mlModel = try? MLModel(contentsOf: modelURL),
let vnModel = try? VNCoreMLModel(for: mlModel)
else { return nil }
let request = VNCoreMLRequest(model: vnModel) { [weak self] req, _ in
guard let results = req.results as? [VNClassificationObservation] else { return }
let top = results.first { $0.confidence >= 0.50 } // 信頼度 50% 以上
DispatchQueue.main.async { self?.applyResult(label: top?.identifier, confidence: top?.confidence) }
}
request.imageCropAndScaleOption = .centerCrop // ガイド枠に合わせたクロップ
return request
}
信頼度の閾値設計
| モデル | 閾値 | 設計の理由 |
|---|---|---|
| カスタムモデル(PenguinClassifier) | 50% | 11クラス専用なので誤検知を抑えるために高めに設定 |
| Apple 標準(VNClassifyImageRequest) | 5% | 数千クラスの中から “penguin” を含むラベルを拾うため低く設定 |
標準モデルは閾値を低くしてでも「ペンギンっぽいもの」を拾いにいきます。カスタムモデルの有無でフォールバック挙動が自然に切り替わります。
判定ラベルをアプリデータと照合する
モデルが返すラベル(例: "Emperor Penguin")をアプリ内の Penguin マスターデータと紐づけます。
private func matchPenguin(label: String) -> Penguin? {
let lower = label.lowercased()
return penguins.first { penguin in
let words = penguin.name.lowercased().split(separator: " ")
// 全単語一致を優先、部分一致もカバー
return words.allSatisfy { lower.contains($0) }
|| (words.first.map { lower.contains($0) } ?? false)
}
}
モデルのラベルとマスターデータの表記ゆれ(例: "African Penguin" vs "Cape Penguin")に対応するため、全単語一致 → 先頭単語一致の順で照合しています。
Step 3 — UI(PenguinCameraView)
レイアウト構成
ZStack
├── Color.black(背景)
├── CameraPreviewView(フルスクリーン)
└── VStack
├── Spacer
├── RoundedRectangle 260×260(判定枠ガイド)
├── Spacer
└── classificationResultCard(判定結果カード)
260×260 の白枠はユーザーにカメラの向け方を示すガイドです。centerCrop と合わせて「枠の中に入れれば判定される」という直感的な UX を実現しています。
判定結果カード
VStack(spacing: 12) {
if let result = viewModel.penguinResult {
HStack {
VStack(alignment: .leading) {
Text(result.penguin?.name_jp ?? "ペンギン") // 日本語名
.font(.title3.bold())
Text(result.penguin?.name ?? result.label) // 英語名
.font(.subheadline)
}
Spacer()
Text("\(Int(result.confidence * 100))%") // 信頼度
.font(.title2.bold())
.foregroundColor(.blue)
}
if let penguin = result.penguin {
NavigationLink {
PenguinSearch(penguinCode: penguin.penguin_code, ...)
} label: {
Label("このペンギンがいる場所を探す", systemImage: "map.fill")
// → 地図検索画面へ遷移
}
}
} else {
Text("カメラをペンギンに向けてください")
}
}
.background(.ultraThinMaterial)
.cornerRadius(16)
判定結果が出た瞬間にボタンが現れて地図検索へ遷移できます。「見たペンギンを、今すぐ他の場所でも探す」という動線を最短にしました。
ツールバーのモデルバッジ
Label(
viewModel.usingCustomModel ? "カスタムモデル" : "標準モデル",
systemImage: viewModel.usingCustomModel ? "brain.filled.head.profile" : "cpu"
)
.foregroundColor(viewModel.usingCustomModel ? .green : .yellow)
開発中のデバッグにも役立ちつつ、ユーザーへの透明性も確保しています。
まとめ
| 項目 | 採用技術・数値 |
|---|---|
| モデル学習ツール | Create ML 6.2 |
| タスク | Image Classifier(転移学習) |
| クラス数 | 11種 |
| データ枚数 | 5,500枚(500枚/クラス) |
| 収束イテレーション | 26 |
| 推論エンジン | Core ML + Vision(VNCoreMLRequest) |
| フォールバック | VNClassifyImageRequest(Apple 標準) |
| 推論頻度 | 30フレームに1回(約2回/秒) |
| 信頼度閾値 | カスタム 50% / 標準 5% |
Create ML × Core ML × Vision のスタックはサーバー不要・オフライン動作・Neural Engine フル活用という三拍子が揃っています。ペンギン以外の動物図鑑・博物館ガイド・野鳥識別など、似た構造のアプリにそのまま応用できるはずです。

No responses yet