WWDC Quick Look 💓 By SwiftGGTeam
アプリにVisual Intelligenceを統合するベストプラクティス

アプリにVisual Intelligenceを統合するベストプラクティス

元の動画を見る

ハイライト

AppleはiOS、iPadOS、macOSでVisual Intelligenceの画像検索連携を開放しました。開発者はApp Intentsで業務Entityを定義し、Visionフレームワークで端末上の画像類似度マッチングを行うことで、ユーザーがカメラやスクリーンショット内の内容を選択したときに、アプリの検索結果を直接表示し、1タップで該当画面へ移動できるようにできます。

主要内容

「見るだけ」から「参加できる」へ

以前のVisual Intelligenceはシステム専用の機能でした。ユーザーが植物を撮影したり商品を調べたりしても、結果ページにはシステムが提供する情報だけが表示され、サードパーティAppは参加できませんでした。WWDC26ではこの能力が開放され、あなたのAppもVisual Intelligenceの検索結果ソースの1つになれます。

たとえば、友人からアルバムジャケットのスクリーンショットが送られてきて、そのアルバムを自分のコレクションからすぐ見つけて再生したいとします。以前なら、画像を保存し、Appを開き、手動で検索する必要がありました。今は、ユーザーがスクリーンショット内のジャケットを選択するだけで、Appが一致する結果を直接返せます。タップすればすぐ再生できます。

この流れを成立させるのは3つの要素です。App Entityが返せる内容を定義し、IntentValueQueryが画像を受け取って結果を返し、OpenIntentがユーザーを正しいページへ連れていきます。

クロスプラットフォームで同じコード

(10:08)

Visual Intelligenceは今年、iPadOSとmacOSにも広がりました。よいニュースは、IntentValueQuery、Entity、OpenIntentのコードが3つのプラットフォームで共通だということです。プラットフォームごとに別実装を書く必要はありません。

ただし、利用場面の違いには注意が必要です。iOSでは、ユーザーは主にカメラで物理世界を撮影します。たとえばレコードジャケットやコンサートポスターです。一方macOSやiPadでは、スクリーンショットから画面上のデジタルコンテンツを選択することが多くなります。MacのスクリーンショットはiPhone画像よりはるかに大きなピクセル数を持つ場合があり、元のピクセルをそのまま処理するとメモリを使い切る可能性があります。先に縮小しておくべきです。

複数種類の結果を返す

(11:47)

ユーザーがポスターを選択したとき、アルバムを聴きたい場合もあれば、関連するコンサート情報を知りたい場合もあります。@UnionValueを使うと、1つのQueryから複数のEntity型を返せます。アルバムとコンサートを1つの結果リストに混在させることができ、それぞれの型に対応するOpenIntentによって、タップ後は別々のページへ移動します。

システムの共有ストアを通じたデータの回流

(14:27)

Visual Intelligenceは、「あなたのAppがシステムに結果を提供する」だけの一方向の仕組みではありません。システムが抽出したデータを共有ストアへ書き込み、あなたのAppがそれを読み取ることもできます。

たとえば、ユーザーがVisual Intelligenceでソーシャルメディア上のコンサート情報を認識し、Calendarへ追加したとします。あなたのAppはEventKitでカレンダー変更を監視し、「近日開催のコンサート」リストにその予定を自動表示できます。ContactsやHealthKitでも同じパターンが使えます。

詳細

App Entityを定義する

(03:21)

最初のステップは、App Intentsフレームワークを使って、返すEntityを定義することです。以下は音楽AppにおけるアルバムEntityの完全な定義です。

import AppIntents

struct AlbumEntity: AppEntity {
    var id: String
    @Property var name: String
    @Property var artistName: String
    var coverArtData: Data

    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(
            title: "\(name)",
            subtitle: "\(artistName)",
            image: .init(data: coverArtData)
        )
    }

    static let defaultQuery = AlbumEntityQuery()
    static var typeDisplayRepresentation: TypeDisplayRepresentation { "アルバム" }
}

struct AlbumEntityQuery: EntityQuery {
    @Dependency var catalog: AlbumCatalog

    func entities(for identifiers: [String]) async throws -> [AlbumEntity] {
        catalog.albums(for: identifiers)
    }
}

重要な点:

  • DisplayRepresentationは結果ページでの表示方法を制御します。システムが提供する領域は、およそ3行のテキストと1枚のサムネイルです。もっとも重要な識別情報をタイトルとサブタイトルに入れます。
  • 複数の結果を返す場合、サムネイルは2列レイアウトに表示されます。元画像URLではなく、200x200前後の小さな画像を提供することが推奨されます。
  • 1件だけ返す場合、画像は結果ページの横幅いっぱいに表示されるため、少し高解像度の画像を使ってもよいでしょう。

IntentValueQueryで画像入力を受け取る

(05:39)

Visual IntelligenceはIntentValueQueryプロトコルを通じて、キャプチャした画像をAppへ渡します。入力型はSemanticContentDescriptorで、その中にpixelBufferが含まれます。

import AppIntents
import VisualIntelligence

struct SearchHandler: IntentValueQuery {
    @Dependency var catalog: AlbumCatalog
    @Dependency var concertFinder: ConcertFinder

    func values(for input: SemanticContentDescriptor) async throws -> [VisualSearchResult] {
        guard let pixelBuffer = input.pixelBuffer else {
            return []
        }

        let albums = try await catalog.search(matching: pixelBuffer)

        return albums.map { VisualSearchResult.album($0) }
    }
}

重要な点:

  • values(for:)SemanticContentDescriptorを受け取り、そこからpixelBufferを取り出します。
  • Queryには厳しい実行時間制限があります。ネットワークリクエストや重い計算は避けます。
  • 一致する結果がない場合は空配列を返せば十分です。空状態の表示はシステムが処理します。

Visionフレームワークでローカル画像マッチングを行う

(06:24)

端末上検索の中核はGenerateImageFeaturePrintRequestです。これは画像をコンパクトな特徴ベクトル、つまりfeature printへ変換し、ベクトル距離を計算して類似度を判定します。

import Vision

@Observable
class AlbumCatalog {
    static let shared = AlbumCatalog()

    struct CatalogEntry: Sendable {
        let album: AlbumEntity
        let featurePrint: FeaturePrintObservation
    }

    private(set) var entries: [CatalogEntry] = []

    private func generateFeaturePrint(for image: CGImage) async throws -> FeaturePrintObservation {
        let request = GenerateImageFeaturePrintRequest()
        let result = try await request.perform(on: image)
        return result
    }
}

検索時には、まず入力画像のfeature printを生成し、事前計算済みのカタログと比較します。

func search(matching pixelBuffer: CVReadOnlyPixelBuffer, limit: Int = 10, maxDistance: Double = 1.0) async throws -> [AlbumEntity] {
    var cgImage: CGImage?
    _ = pixelBuffer.withUnsafeBuffer {
        VTCreateCGImageFromCVPixelBuffer($0, options: nil, imageOut: &cgImage)
    }
    guard let cgImage else { return [] }

    let queryPrint = try await generateFeaturePrint(for: cgImage)

    return try entries.compactMap { entry -> (album: AlbumEntity, distance: Double)? in
        let distance = try queryPrint.distance(to: entry.featurePrint)
        guard distance <= maxDistance else { return nil }
        return (entry.album, distance)
    }
    .sorted { $0.distance < $1.distance }
    .prefix(limit)
    .map { $0.album }
}

重要な点:

  • VTCreateCGImageFromCVPixelBufferCVPixelBufferCGImageへ変換します。
  • FeaturePrintObservation.distance(to:)は2つの特徴ベクトル間の距離を計算します。値が小さいほど類似しています。
  • maxDistanceで無関係な結果を除外し、ノイズを返さないようにします。
  • カタログ内のfeature printは事前計算しておく必要があります。Query時には距離比較だけを行い、応答速度を保ちます。
  • Macのスクリーンショットは大きくなりがちです。CGImageへ変換する前に縮小し、メモリピークを避けます。

OpenIntentで正しいページへ遷移する

(08:27)

ユーザーが検索結果をタップすると、システムは対応するOpenIntentを呼びます。

import AppIntents

struct OpenAlbumIntent: OpenIntent {
    static let title: LocalizedStringResource = "アルバムを開く"

    @Parameter(title: "アルバム")
    var target: AlbumEntity

    @Dependency var appState: AppState

    func perform() async throws -> some IntentResult {
        await appState.openAlbum(id: target.id)
        return .result()
    }
}

重要な点:

  • perform()はAppが前面に出る過程で実行されるため、軽量なナビゲーション処理だけにします。
  • 重いデータ読み込みはビュー表示後に回し、起動フローをブロックしないようにします。
  • Siri連携のためにすでにOpenIntentを書いているなら、そのまま再利用できます。

複数の結果型を返す

(12:05)

@UnionValueを使うと、1つのQueryから複数種類のEntityを返せます。

@UnionValue
enum VisualSearchResult {
    case album(AlbumEntity)
    case concert(ConcertEntity)
}

struct OpenConcertIntent: OpenIntent {
    static let title: LocalizedStringResource = "コンサートを開く"

    @Parameter(title: "コンサート")
    var target: ConcertEntity

    @Dependency var appState: AppState

    func perform() async throws -> some IntentResult {
        await appState.openConcert(id: target.id)
        return .result()
    }
}

次に、2種類の結果を混在させるようQueryロジックを更新します。

struct SearchHandler: IntentValueQuery {
    @Dependency var catalog: AlbumCatalog
    @Dependency var concertFinder: ConcertFinder

    func values(for input: SemanticContentDescriptor) async throws -> [VisualSearchResult] {
        guard let pixelBuffer = input.pixelBuffer else {
            return []
        }

        let albums = try await catalog.search(matching: pixelBuffer)
        let artists = albums.map { $0.artistName }
        let concerts = await concertFinder.findNearby(byArtists: artists)

        return albums.map { VisualSearchResult.album($0) }
            + concerts.map { VisualSearchResult.concert($0) }
    }
}

重要な点:

  • @UnionValue enumの各caseはAppEntityを包みます。
  • Entity型ごとに独立したOpenIntentが必要です。
  • 結果の混在順は自分で制御できます。もっとも関連性の高い結果を先に置くのがよいでしょう。

App内検索へのフォールバック入口を提供する

(13:13)

システムの結果ページにユーザーが求める内容がない場合、「App内で検索を続ける」入口を提供できます。

@AppIntent(schema: .visualIntelligence.semanticContentSearch)
struct SemanticContentSearchIntent: AppIntent {
    static let title: LocalizedStringResource = "アプリ内で検索"
    static let openAppWhenRun: Bool = true

    var semanticContent: SemanticContentDescriptor
    @Dependency var catalog: AlbumCatalog
    @Dependency var concertFinder: ConcertFinder
    @Dependency var appState: AppState

    func perform() async throws -> some IntentResult {
        guard let pixelBuffer = semanticContent.pixelBuffer else { return .result() }
        let albums = try await catalog.search(matching: pixelBuffer)
        let artists = albums.map { $0.artistName }
        let concerts = await concertFinder.findNearby(byArtists: artists)
        await appState.openSearch(albums: albums, concerts: concerts)
        return .result()
    }
}

重要な点:

  • .visualIntelligence.semanticContentSearch schemaを使うと、システムがsemanticContentを自動で渡します。
  • openAppWhenRun: trueによりAppが起動されます。
  • App内で検索結果をあらかじめ埋めておけば、ユーザーは最初からやり直す必要がありません。

EventKitを通じてシステムのカレンダーデータを受け取る

(15:24)

ユーザーがVisual Intelligence経由でCalendarに追加したイベントは、AppがEventKitで読み取れます。

import EventKit

@Observable
class UpcomingConcertManager {
    private let eventStore = EKEventStore()
    var upcomingConcerts: [EKEvent] = []
    var authorizationStatus: EKAuthorizationStatus = .notDetermined

    func requestAccessAndFetch() async throws {
        let granted = try await eventStore.requestFullAccessToEvents()
        guard granted else {
            authorizationStatus = .denied
            return
        }
        authorizationStatus = .fullAccess
        await fetchUpcomingConcerts()

        for await _ in NotificationCenter.default.notifications(named: .EKEventStoreChanged) {
            await fetchUpcomingConcerts()
        }
    }

    func fetchUpcomingConcerts() async {
        let predicate = eventStore.predicateForEvents(
            withStart: .now,
            end: .now.addingTimeInterval(90 * 24 * 60 * 60),
            calendars: nil
        )

        let events = eventStore.events(matching: predicate)

        upcomingConcerts = events.filter { event in
            AlbumCatalog.shared.entries.contains { entry in
                event.title?.localizedCaseInsensitiveContains(entry.album.artistName) == true
            }
        }
    }
}

重要な点:

  • requestFullAccessToEvents()でカレンダーへの完全アクセス権をリクエストします。
  • .EKEventStoreChanged通知を監視します。Visual Intelligenceが追加した新しいイベントは自動で更新を発火します。
  • predicateForEventsで今後90日間のイベントを取得し、そこから業務ロジックに応じて絞り込みます。

重要ポイント

1. EC向けに「撮影して商品を探す」入口を作る

  • 何をするか: ユーザーが雑誌に載った商品を撮影すると、Appが一致する商品カードを直接返します。
  • なぜ価値があるか: Visual Intelligenceはカメラを、Appを明示的に起動しなくても使える検索入口に変えます。ユーザーは文字入力もApp切り替えも不要です。
  • どう始めるか: GenerateImageFeaturePrintRequestで商品画像のfeature printを事前計算し、ローカルデータベースに保存します。ProductEntityを定義し、IntentValueQueryで類似度マッチングを実装します。

2. 「チケット情報をスクリーンショットから認識する」イベントアシスタントを作る

  • 何をするか: ユーザーがソーシャルメディア上のコンサートポスターをスクリーンショットし、Visual Intelligenceが認識してCalendarへ追加すると、Appが自動で「近日鑑賞」リストに表示し、予習用プレイリストを推薦します。
  • なぜ価値があるか: システムストア統合によりデータが自動で回流するため、App側でOCRを使ってスクリーンショットを解析する必要がありません。
  • どう始めるか: EventKitの.EKEventStoreChangedを監視し、Appの業務に関連するイベントを絞り込みます。

3. 「画面上の文字を選択する」クロスアプリのノートツールを作る

  • 何をするか: ユーザーが任意のAppでテキストや画像を選択すると、ノートAppがSemanticContentDescriptorを受け取り、内容を抽出してノート項目を作成します。
  • なぜ価値があるか: macOSではスクリーンショットが主要な入力源であり、デスクトップ作業におけるアプリ横断の情報収集は現実的な痛点です。
  • どう始めるか: Visionのテキスト認識、RecognizeTextRequestpixelBufferと組み合わせて文字内容を抽出します。

4. 「撮影して医療機器を読み取る」健康記録ツールを作る

  • 何をするか: ユーザーが血圧計や血糖値計の画面を撮影すると、Visual Intelligenceが読み取り値をHealthKitへ書き込み、Appが自動で健康記録へ同期します。
  • なぜ価値があるか: WWDC26では、医療機器ログがVisual Intelligenceの新機能として言及されました。Appは自分で数字を解析せず、HealthKitから読み取れます。
  • どう始めるか: HKHealthStoreで関連する健康データ型をQueryし、データ変化を監視します。

5. 「ポスターを選択する」マルチメディア発見エンジンを作る

  • 何をするか: ユーザーが映画ポスターを選択すると、Appは映画情報、サウンドトラック、近くの映画館の上映時間を1つの混在リストで返します。
  • なぜ価値があるか: @UnionValueにより、1つのQueryから複数種類のEntityを返せます。1つの操作で複数の事業導線を起動できます。
  • どう始めるか: 映画、音楽、映画館の3つのcaseを持つ@UnionValue enumを定義し、それぞれに独立したOpenIntentを用意します。

関連 Session

  • アプリをSiriへ対応させる - App Intentsの基礎入門です。App EntityやIntentにまだ触れたことがない場合は、まずこのSessionから見るとよいでしょう。
  • App Intentsの新機能 - UnionValueのさらなる使い方を含む、App Intentsの新機能を学びます。
  • Visionの新機能 - feature print、テキスト認識、バーコードスキャンなど、Visionフレームワークの画像処理機能を深く理解します。
  • App Intentsでagentic appを構築する - インテリジェントなアプリにおけるApp Intentsの高度な使い方を探ります。
  • SwiftUIの新機能 - Visual Intelligenceの結果ページを正しい画面へ着地させるには、SwiftUIのナビゲーションと状態管理が必要です。

コメント

GitHub Issues · utterances