0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Segment Anything (なんでも画像から切り抜き)をiOSでネイティブに動かすSwiftライブラリ

0
Posted at

SAMKit Demo

以前からMeta の Segment Anything Model (SAM) を iOS 上でオンデバイス動作させる Swift Packageを作りたいなと思っていました。

タップしたオブジェクトを切り抜き
ボックスで囲んだオブジェクトを切り抜き
テキストで指定したオブジェクトを切り抜き
どの方法でも即座にセグメントでき、推論はすべて端末上で完結。

すぐに使えるUIコンポーネントもついている。。。

ということでつくってみました。

GitHub: https://github.com/john-rocky/SamKit

できること

機能 説明
Point & Box タップでポイント、ドラッグでボックスを指定してセグメント
Text Prompt "dog""red cup" とテキスト入力でオブジェクトを検出・セグメント
Subject Lift Apple Photos風にオブジェクトを長押しで持ち上げ、コピー/保存/共有
2つのバックボーン MobileSAM(高速、23MB)と SAM2 Tiny(高精度、76MB)
Drop-in UI SwiftUIビューをそのまま組み込むだけ

アーキテクチャ

SAMKit/
├── SAMKit            # コア推論エンジン(ポイント/ボックス)
├── SAMKitGrounding   # テキスト検出(YOLO-World + CLIP)
└── SAMKitUI          # SwiftUI ビュー(SamView / TextPromptView)

3つの Swift Package プロダクトに分かれています。必要なものだけインポートできます。

セットアップ

1. Swift Package の追加

dependencies: [
    .package(url: "https://github.com/john-rocky/SamKit.git", from: "1.0.0")
]

2. モデルのダウンロード

Releases から .mlpackage ファイルを取得し、Xcodeプロジェクトに追加します。

モデル サイズ 用途
MobileSAM 23 MB ポイント/ボックスセグメント(必須)
SAM2 Tiny 76 MB より高精度なセグメント(任意)
Grounding (YOLO-World + CLIP) 148 MB テキスト検出(任意)

使い方

ポイント/ボックスセグメンテーション

最も基本的な使い方。画像をセットし、ポイントを指定するだけです。

import SAMKit

// セッション作成(モデルはバンドル済みリソースから自動読み込み)
let session = try SamSession(
    model: .bundled(.mobileSam),
    config: .bestAvailable      // Neural Engine > GPU > CPU の優先順位
)

// 画像エンコーディング(1回だけ。以降のpredictではキャッシュを使用)
try session.setImage(cgImage)

// ポイント指定でセグメント
let result = try session.predict(
    points: [SamPoint(x: 100, y: 200, label: .positive)]
)

// 結果
let mask = result.masks.first!
mask.cgImage   // セグメントマスク画像
mask.score     // IoU信頼度スコア
mask.alpha     // アルファチャンネルデータ

ネガティブポイント(除外したい領域)やバウンディングボックスも指定できます:

let result = try session.predict(
    points: [
        SamPoint(x: 100, y: 200, label: .positive),   // 含めたいポイント
        SamPoint(x: 300, y: 400, label: .negative)     // 除外したいポイント
    ],
    box: SamBox(x0: 50, y0: 50, x1: 400, y1: 400)    // バウンディングボックス
)

テキストプロンプトでセグメント

YOLO-World + CLIP によるテキスト検出と SAM を組み合わせます。

import SAMKit
import SAMKitGrounding

let session = try TextSegmentationSession(
    groundingModel: .bundled(),
    samModel: .bundled(.mobileSam)
)

try session.setImage(cgImage)

// テキストで検索してセグメント
let result = try session.segment(query: "dog, cat")

result.detections   // 検出結果(バウンディングボックス + ラベル)
result.masks        // 各検出に対応するセグメントマスク
result.scores       // 信頼度スコア

オブジェクトの切り抜き

セグメント結果から透過PNG画像を生成できます。

// 単一マスクからの切り抜き
let extracted = result.masks[0].extractObject(from: cgImage)
// → 透過背景のCGImage

// 複数マスクの合成切り抜き
let combined = SamMask.extractObject(from: cgImage, masks: result.masks)

SwiftUI ビューの組み込み

UI を自作する必要はありません。SAMKitUI にはすぐ使えるビューが含まれています。

import SAMKitUI

// ポイント/ボックスでインタラクティブにセグメント
SamView(image: uiImage, model: try .bundled(.mobileSam))

// テキスト検索でセグメント
TextPromptView(image: uiImage, session: textSession)

これらのビューには以下が組み込まれています:

  • セグメント後のサブジェクトハイライト(背景暗め + 対象物がフル明度で浮き出る)
  • アニメーション付きグローイングアウトライン
  • 長押しでオブジェクトをリフト → ドラッグ → Copy/Save/Share メニュー

Subject Lift の実装解説

Apple Photos の「被写体を持ち上げる」機能を再現した部分について、技術的な詳細を解説します。

1. マスクの二値化

SAM のマスク出力は sigmoid の連続値なので、表示用にクリーンな二値マスクに変換します。

func binarizeMask(_ maskImage: CGImage) -> CGImage? {
    // CGContextでピクセルデータを取得
    let ctx = CGContext(data: nil, width: width, height: height, ...)
    ctx.draw(maskImage, in: rect)

    let pixels = ctx.data!.bindMemory(to: UInt8.self, capacity: width * height * 4)
    let threshold: UInt8 = 128  // 50% — SAMの標準的な閾値

    for i in 0..<(width * height) {
        let o = i * 4
        if pixels[o + 3] >= threshold {
            // 完全不透明の白に
            pixels[o] = 255; pixels[o+1] = 255; pixels[o+2] = 255; pixels[o+3] = 255
        } else {
            // 完全透明に
            pixels[o] = 0; pixels[o+1] = 0; pixels[o+2] = 0; pixels[o+3] = 0
        }
    }
    return ctx.makeImage()
}

閾値 0 だとマスクのノイズまで拾ってしまい画像のほとんどが切り抜かれてしまいます。128(50%)が安定です。

2. グローイングアウトラインの生成

マスクの輪郭をCGContextのシャドウ機能で抽出します。ピクセル単位の膨張処理より圧倒的に高速です。

func generateOutline(from maskImage: CGImage) -> CGImage? {
    // Step 1: マスクを白一色のシルエットに変換
    ctx.draw(maskImage, in: rect)
    ctx.setBlendMode(.sourceIn)
    ctx.setFillColor(UIColor.white.cgColor)
    ctx.fill(rect)  // → 白いシルエット

    // Step 2: シャドウ付きで描画し、内部を消す → 輪郭だけ残る
    outCtx.setShadow(offset: .zero, blur: glowRadius, color: UIColor.white.cgColor)
    outCtx.draw(whiteSilhouette, in: rect)   // シャドウ = 輪郭のグロー

    outCtx.setBlendMode(.destinationOut)
    outCtx.draw(whiteSilhouette, in: rect)   // 内部を消去 → 輪郭だけ
}

ポイント:

  • setShadow で白いグローを生成(描画はたった2回)
  • .destinationOut で内部を消去し、外側のグローだけ残す
  • 膨張ループ(O(thickness² × pixels))と比べて大幅に高速

3. シマーアニメーション

TimelineViewAngularGradient で光が輪郭を一周するアニメーションを実現します。

TimelineView(.animation(minimumInterval: 1.0 / 30)) { timeline in
    let phase = timeline.date.timeIntervalSinceReferenceDate
        .truncatingRemainder(dividingBy: 2.5) / 2.5  // 2.5秒で一周

    ZStack {
        // ソフトグロー(シアン色のぼかし)
        outlineImage.colorMultiply(Color(red: 0.5, green: 0.85, blue: 1.0))
            .blur(radius: 5).opacity(0.8)

        // シャープなアウトライン
        outlineImage.colorMultiply(.white)

        // 移動するハイライト
        outlineImage.colorMultiply(.white)
            .mask(
                AngularGradient(
                    colors: [.white, .white.opacity(0.5), .clear, .clear, ...],
                    center: .center,
                    startAngle: .degrees(phase * 360),
                    endAngle: .degrees(phase * 360 + 360)
                )
            )
    }
}

4. 統一ジェスチャーハンドラ

タップ(ポイント追加)、ボックス描画、長押しリフトを 1つの DragGesture(minimumDistance: 0) で全て管理 します。

SwiftUI の onTapGesture + onLongPressGesture の組み合わせは互いにブロックし合うため、全てのタッチを1つのジェスチャーで受け取り、時間と移動量で分類する方式を採用しました。

DragGesture(minimumDistance: 0)
    .onChanged { value in
        // 初回タッチでタイマーをスケジュール
        if gestureStartTime == nil {
            gestureStartTime = Date()
            // 0.3秒後に長押し判定
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                guard gestureStartTime != nil, !isLifted, hasVisibleMasks else { return }
                let moved = hypot(lastTranslation.width, lastTranslation.height)
                guard moved < 15 else { return }  // 動いていたら長押しではない
                handleLiftObject()  // リフト開始!
            }
        }

        if isLifted {
            liftDragOffset = value.translation  // ドラッグ追従
        }
    }
    .onEnded { value in
        if isLifted {
            // 指を離した → メニュー表示
            showLiftMenu = true
        } else if elapsed < 0.3 && moved < 15 {
            // 素早いタッチ → ポイント追加
            addPoint(at: value.startLocation)
        }
    }

判定ロジック:

条件 判定
< 0.3秒、< 15pt移動 タップ → ポイント追加
≥ 0.3秒、< 15pt移動 長押し → リフト開始
≥ 10pt移動(box mode) ドラッグ → ボックス描画
リフト中の移動 リフトドラッグ → オブジェクト移動

5. サブジェクトハイライト

セグメント後に色付きマスクを重ねるのではなく、背景を暗くしてサブジェクトだけ元の明るさで表示 します。

// 背景を暗く
Color.black.opacity(0.25)

// サブジェクトだけ元画像の明るさで表示
Image(uiImage: image)
    .mask(Image(uiImage: UIImage(cgImage: binaryMask)))

これにより長押しリフトへの遷移が自然になります(暗さが 0.25 → 0.4 に深まるだけ)。

パフォーマンス

  • 画像エンコーディング: 1画像につき1回のみ。以降のpredictではキャッシュを再利用
  • 推論: Neural Engine / GPU で高速化(FP16対応)
  • アウトラインの生成: CGContextのシャドウ機能で2回の描画のみ。ピクセルループ不要
  • ネットワーク通信: なし。完全オンデバイス

まとめ

SAMKit を使えば、iOS アプリにセグメンテーション機能を数行で組み込めます。

// これだけでインタラクティブなセグメンテーションUIが完成
SamView(image: uiImage, model: try .bundled(.mobileSam))

Subject Lift のような体験もビルトインで提供されるので、Apple Photos のような UX を自分のアプリにすぐ導入できます。

GitHub: https://github.com/john-rocky/SamKit

フィードバックや Issue は歓迎です!

🐣


最新のAI機能を使ったアプリやサービスを最速で試作したい。
そんなご要望にお応えします。
ご相談はこちらまで。
rockyshikoku@gmail.com

Twitter
Medium
GitHub

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?