Vision 入門 — アプリのプロフィール画像アップロード時に自動で顔を認識してトリミング (Swift)
Vision 実践応用
[2024/08/13 更新]
- 新しい記事、新しいAPIをご参照ください:「 iOS Vision framework x WWDC 24 Discover Swift enhancements in the Vision framework Session 」
同じく説明は省略して、まずは完成画像を一枚:

最適化前 V.S 最適化後 — 結婚吧APP
先日、iOS 12がリリースされ、新たに公開されたCoreML機械学習フレームワークに注目しました。とても面白いと感じ、現在の製品でどこに活用できるかを考え始めました。
CoreMLの最新記事が公開されました: 機械学習を使って記事のカテゴリを自動予測、モデルも自分で訓練
CoreMLはテキストや画像の機械学習モデルの訓練およびAPPへの組み込みインターフェースを提供します。私の当初の考えは、CoreMLを使って顔認識を行い、APP内でトリミングされた画像の頭や顔が切れてしまう問題を解決することでした。上図左のように、顔が端にある場合、拡大+トリミングによって顔が不完全になることがよくあります。
ネットで調べた結果、自分の知識不足を痛感しましたが、この機能はiOS 11で既にリリースされている「Vision」フレームワークで対応可能です。文字認識、人顔検出、画像比較、QRコード検出、物体追跡などの機能をサポートしています。
ここで使用しているのは顔検出の項目で、最適化後は右の画像のようになります。顔を検出し、それを中心にしてトリミングします。
実践開始:
まずは顔の位置を検出する機能を作り、Visionの使い方を簡単に理解しましょう

Demoアプリ
完成図は上記の通りで、写真の中の顔の位置をマークできます。
p.s 顔だけを検出できます。髪全体を含む頭全体は検出できません😅
このコードは主に二つの部分に分かれています。第一の部分は、画像の元のサイズを縮小してImageViewに入れたときに余白ができる問題を解決することです。簡単に言うと、画像のサイズがどれだけであっても、ImageViewのサイズもそれに合わせる必要があります。もしそのまま画像を入れると、以下のように位置がずれてしまいます。

ContentModeを直接fill、fit、redrawに変更すると、画像が歪んだり切れてしまうことがあります。
let ratio = UIScreen.main.bounds.size.width
// ここではUIImageViewの左右を0に揃え、アスペクト比を1:1に設定しているため
let sourceImage = UIImage(named: "Demo2")?.kf.resize(to: CGSize(width: ratio, height: CGFloat.leastNonzeroMagnitude), for: .aspectFill)
// KingFisherの画像リサイズ機能を使い、幅を基準に高さは自由に設定
imageView.contentMode = .redraw
// contentModeをredrawにして画像を塗りつぶす
imageView.image = sourceImage
// 画像をセット
imageViewConstraints.constant = (ratio - (sourceImage?.size.height ?? 0))
imageView.layoutIfNeeded()
imageView.sizeToFit()
// ここでimageViewのConstraintsを変更している。詳細は記事末の完全サンプルを参照
以上は画像処理に関する説明です。
トリミング部分はKingfisherを使ってサポートしていますが、他のライブラリや自作の方法に置き換えることも可能です
第二部分、核心に入り、直接コードを見てみましょう。
if #available(iOS 11.0, *) {
// iOS 11以降でサポート
let completionHandle: VNRequestCompletionHandler = { request, error in
if let faceObservations = request.results as? [VNFaceObservation] {
// 検出された顔
DispatchQueue.main.async {
// UIViewを操作するためメインスレッドに戻す
let size = self.imageView.frame.size
faceObservations.forEach({ (faceObservation) in
// 座標系の変換
let translate = CGAffineTransform.identity.scaledBy(x: size.width, y: size.height)
let transform = CGAffineTransform(scaleX: 1, y: -1).translatedBy(x: 0, y: -size.height)
let transRect = faceObservation.boundingBox.applying(translate).applying(transform)
let markerView = UIView(frame: transRect)
markerView.backgroundColor = UIColor.init(red: 0/255, green: 255/255, blue: 0/255, alpha: 0.3)
self.imageView.addSubview(markerView)
})
}
} else {
print("顔が検出されませんでした")
}
}
// 検出リクエスト
let baseRequest = VNDetectFaceRectanglesRequest(completionHandler: completionHandle)
let faceHandle = VNImageRequestHandler(ciImage: ciImage, options: [:])
DispatchQueue.global().async {
// 検出には時間がかかるため、背景スレッドで実行し画面のフリーズを防ぐ
do{
try faceHandle.perform([baseRequest])
}catch{
print("エラー:\(error)")
}
}
} else {
//
print("サポートされていません")
}
主に注意すべきは、座標系の変換部分です。認識された結果は画像の元の座標であるため、それを外側のImageViewの実際の座標に変換して正しく使用する必要があります。
次に、本日のメインイベントです — 顔の位置に合わせてプロフィール画像の正しい位置をトリミングする方法
let ratio = UIScreen.main.bounds.size.width
// ここはUIImageViewの左右を0に揃え、アスペクト比を1:1に設定しているためです。詳細は記事末の完全なサンプルを参照してください。
let sourceImage = UIImage(named: "Demo")
imageView.contentMode = .scaleAspectFill
// scaleAspectFillモードで画像を埋める
imageView.image = sourceImage
// 元の画像を直接代入し、後で操作します
if let image = sourceImage,#available(iOS 11.0, *),let ciImage = CIImage(image: image) {
let completionHandle: VNRequestCompletionHandler = { request, error in
if request.results?.count == 1,let faceObservation = request.results?.first as? VNFaceObservation {
// 顔が1つだけ検出された場合
let size = CGSize(width: ratio, height: ratio)
let translate = CGAffineTransform.identity.scaledBy(x: size.width, y: size.height)
let transform = CGAffineTransform(scaleX: 1, y: -1).translatedBy(x: 0, y: -size.height)
let finalRect = faceObservation.boundingBox.applying(translate).applying(transform)
let center = CGPoint(x: (finalRect.origin.x + finalRect.width/2 - size.width/2), y: (finalRect.origin.y + finalRect.height/2 - size.height/2))
// 顔の範囲の中心点を計算
let newImage = image.kf.resize(to: size, for: .aspectFill).kf.crop(to: size, anchorOn: center)
// 中心点を基準に画像をクロップ
DispatchQueue.main.async {
// UIViewの操作はメインスレッドで行う
self.imageView.image = newImage
}
} else {
print("複数の顔が検出されたか、顔が検出されませんでした")
}
}
let baseRequest = VNDetectFaceRectanglesRequest(completionHandler: completionHandle)
let faceHandle = VNImageRequestHandler(ciImage: ciImage, options: [:])
DispatchQueue.global().async {
do{
try faceHandle.perform([baseRequest])
}catch{
print("エラー:\(error)")
}
}
} else {
print("対応していません")
}
原理は顔の位置をマークする方法とほぼ同じですが、プロフィール画像の部分は固定サイズ(例:300x300)なので、最初のImageをImageViewに合わせる部分は省略します。
もう一つの違いは、顔の範囲の中心点を計算し、その中心点を基準に画像をトリミングすることです。

赤い点は顔の範囲の中心点です。
完成イメージ:

頓丹前のその一秒は元の画像の位置です
完全なAPPサンプル:

コードはGithubにアップロードされています:こちらをクリック
Post は Medium から ZMediumToMarkdown によって変換されました。



コメント