以前から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. シマーアニメーション
TimelineView と AngularGradient で光が輪郭を一周するアニメーションを実現します。
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