LoginSignup
8

More than 5 years have passed since last update.

【swift3】顔が切れないように自動トリミング【CIDetector】

Last updated at Posted at 2017-06-25

自動トリミングをしたい

動画を生成したり、複数の画像を組み合わせたり、
大量の画像のトリミングを行う時、手動でちまちまはめんどうくさいですね。
自動でいい感じに切り取ってほしいです。

しかし、以下のように、単に固定の座標から切り取ると、顔が切れてしまったり、人物が入らなかったり、残念なトリミングになってしまいます。

また、このような、風景with人物な写真の場合、人物を中心にトリミングは個人的にダメだと思います。
ちゃんと、風景の良さを考慮してほしいものです。

そこで、風景がある場合は、人物が中心にならないように以下のように工夫した結果を書いて行きます。

環境

swift3
playgroundで開発、検証

基本的なアイデア

個人的な経験則から、以下のアイデアをロジックの基礎としました。

  • その写真の中で主張したいものは、写真の比較的中央にある。

風景を主とした写真を撮る時は、人物は写真の比較的端っこの方にいますし、
人物主体の時は写真の比較的中央に人がいます。
なので、基本的にトリミング領域は写真中央にしておき、顔領域がはみ出しているようであれば、それを囲うようにトリミング領域をずらすことにします。
汚い図ですが、おおよそ以下のような感じです。

このアイデアから、以下のようなフローでトリミングを行います。

  1. 写真をリサイズ
  2. デフォルトのトリミング領域を中央に設定
  3. 顔領域を取得、大きさを比較
    1. トリミング領域が顔領域よりも大きかったら
      1. トリミング領域が顔領域を既に含んでいたら、そのまま
      2. 含んでいなければ、トリミング領域が顔領域をギリギリ含むように移動
    2. トリミング領域が顔領域よりも小さかったら
      1. トリミング領域の中央を顔領域の中央と合わせる。
  4. トリミング

顔領域は後述しますが、全ての顔を囲うような領域を指します。

リサイズ

画像のサイズを指定したトリミングサイズに合うようにリサイズします。およそ以下のようなイメージです。

playground
/// クロップ用にリサイズ。sizeが、imageにFillする形になるようにimageをリサイズ。
///
/// - Parameters:
///   - image: 入力画像
///   - size: サイズ
/// - Returns: リサイズ後のUIImage
func resizeContentFill(_ image:UIImage,size:CGSize) -> UIImage {
    let w:CGFloat = size.width
    let h:CGFloat = size.height
    let w_h = w/h // アスペクト比の基準となるもの。大きいほど横に長い。

    // リサイズ処理
    // let origRef    = image.cgImage
    // let origWidth  = Int(origRef!.width)
    // let origHeight = Int(origRef!.height)
    let origWidth = image.size.width
    let origHeight = image.size.height
    let origW_H = origWidth/origHeight
    var resizeWidth:CGFloat = 0, resizeHeight:CGFloat = 0


    if origW_H >= w_h{
        resizeHeight = h
        resizeWidth = origWidth * resizeHeight / origHeight
    }else{
        resizeWidth = w
        resizeHeight = origHeight * resizeWidth / origWidth
    }

    let resizeSize = CGSize.init(width: resizeWidth, height: resizeHeight)

    UIGraphicsBeginImageContext(resizeSize)

    image.draw(in: CGRect.init(x: 0, y: 0, width: CGFloat(resizeWidth), height: CGFloat(resizeHeight)))

    let resizeImage = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsEndImageContext()

    return resizeImage!
}

顔領域を取得

顔の検出

顔が切れないことが大前提であるので、顔検出にCIDetectorを用います。
CIDetectorを用いることで、以下の情報が得られます。

  • 顔の輪郭の矩形(CGRect)
  • 目の位置(CGPoint)
  • 口の位置(CGPoint)
  • 部分チェック
    • 左目があるか、笑っているか、顔が傾いているか、etc

今回は顔の輪郭の矩形(CGRect)のみを用います。
ここで、得られるCGRectですが、以下の通りのように取得されます。
このように、髪の毛やおでこが囲まれていない場合があり、頭より一回り小さいです。

なので、頭全体を囲むように、マージンを設けてあげます。

ここで注意するべきところが、座標系です。仕様か何か、検出された領域はCGRectで返却されますが、起点は左下になります。なので、通常のCGRectと同じ左上に揃えてあげる必要があります。
また、今回は一枚だけなので関数の中でCIDetectorを生成していますが、複数枚の処理を行う場合には、外に出してあげるべきでしょう。

playground
/// 与えられた画像の顔の座標、大きさを返す。
///
/// - Parameter image: 画像
/// - Parameter margin: 余白
/// - Returns: 顔の座標、大きさのCGRect配列
func CIDetectOfFace(_ image: UIImage, quality:String = CIDetectorAccuracyHigh, margin_rate:CGFloat = 0.4)->[CGRect]{
    // CIDetector導入
    let ciImage  = CIImage(image:image)
    let ciDetector = CIDetector(ofType:CIDetectorTypeFace
        ,context:CIContext()
        ,options:[
            CIDetectorAccuracy:quality,
            CIDetectorSmile:true
        ]
    )

    // 認識
    let features = ciDetector?.features(in: ciImage!)
    var faceRects:[CGRect] = []

    // 顔情報取得
    for feature in features!{
        //face
        var faceRect = (feature as! CIFaceFeature).bounds

        // 左下起点、Rect左下から、左上起点、Rect左上へ、座標系を合わせる
        faceRect.origin.y = image.size.height - faceRect.origin.y - faceRect.height

        // 余白を持たせる
        let widthMargin = faceRect.width * margin_rate
        faceRect.origin.x -= widthMargin
        faceRect.size.width += widthMargin*2

        let heightMargin = faceRect.height * margin_rate
        faceRect.origin.y -= heightMargin*2
        faceRect.size.height += heightMargin*2

        print(faceRect," in ",image.size)
        image.size
        faceRects.append(faceRect)

    }
    return faceRects
}

複数人への対応

一人の顔を囲むことはできました。しかし、集合写真など、複数人で写真を撮ることもあるでしょう。
複数人にも対応するように、全ての顔を囲うような大矩形を生成します。
これを顔領域とします。
およそ以下のような感じです。

playground
/// 与えられたRect群を全て包含するようなRectを返す。
///
/// - Parameter rects: 包含されるRect群
/// - Returns: Rect群を包含するようなRect
func  inclusionRect(_ rects:[CGRect])->CGRect?{
    if rects.count == 0{return nil}

    let maxX:CGFloat = rects.map({$0.maxX}).max()!
    let minX:CGFloat = rects.map({$0.minX}).min()!
    let maxY:CGFloat = rects.map({$0.maxY}).max()!
    let minY:CGFloat = rects.map({$0.minY}).min()!

    return CGRect(x:minX,y:minY,width:maxX-minX,height:maxY-minY)
}

トリミング

トリミング領域の調整

さて、トリミングする際に外せない領域である顔領域を取得することができました。

デフォルトの切り取り矩形をアイデアに沿って中央にしておきます。

リサイズからやってみましょう。適当なimage:UIImageを用意してください。
ここでは前述の写真を用います。

playground
// トリミングサイズ
contentSize:CGSize = CGSize(width:200,height:480)

// リサイズ
let image:UIImage = resizeContentFill(image,size: contentSize)

// 切り取り位置調整
var cropRect  = CGRect.init(x: (image.size.width - contentSize.width) / 2,
                            y: (image.size.height - contentSize.height) / 2,
                            width: contentSize.width,
                            height: contentSize.height)

// 描画    
image.drawRect(cropRect,color: .yellow)

resizeContentFillは前述したリサイズメソッドですね。
描画のdrawRectはデバッグ用にUIImageを拡張したものです。入力された矩形をUIImageに描画して返す非破壊的なメソッドです。以下のようです。

playground
extension UIImage {
    /// 与えられたCGRect群からUIImageに枠を描画する
    ///
    /// - Parameters:
    ///   - faceRects: CGRect群
    /// - Returns: 描画後のUIImage
    func drawRects(_ rects:[CGRect],color:UIColor = .red)->UIImage{
        UIGraphicsBeginImageContextWithOptions(size, false,  UIScreen.main.scale)
        //context
        let drawCtxt = UIGraphicsGetCurrentContext()

        self.draw(in: CGRect(x:0,y:0,width:self.size.width,height:self.size.height))

        for rect in rects{
            drawCtxt?.setLineWidth(2.0)
            drawCtxt?.setStrokeColor(color.cgColor)
            drawCtxt?.stroke(rect)
        }

        let image = UIGraphicsGetImageFromCurrentImageContext()

        UIGraphicsEndImageContext()

        return image!
    }

    // CGRect1個だけパターン
    func drawRect(_ rect:CGRect,color:UIColor = .red)->UIImage{
        return self.drawRects([rect],color: color)
    }
}

実行結果のimageは以下の通りです。

次に、顔領域と比較して、トリミング領域を調整します。

playground

 // 画像内の顔の大きさと座標を取得
    let faceRects:[CGRect] = CIDetectOfFace(image)
    if faceRects.count == 0{return cropRect}

    // 全てを包含するRectを生成。
    let pRect:CGRect = inclusionRect(faceRects)!

    let test = image.drawRects(faceRects).drawRect(pRect,color: .blue)

    cropRect.contains(pRect)

    if !cropRect.contains(pRect){//調整の必要
        // 調整できるか否か。
        if pRect.width < cropRect.width && pRect.height < cropRect.height{
            // 調整できるので調整。

            // minXでの調整
            cropRect.origin.x = pRect.minX < cropRect.minX ? pRect.minX : cropRect.minX
            // minYでの調整
            cropRect.origin.y = pRect.minY < cropRect.minY ? pRect.minY : cropRect.minY
            // maxXでの調整
            cropRect.origin.x = pRect.maxX > cropRect.maxX ? cropRect.origin.x + pRect.maxX - cropRect.maxX : cropRect.origin.x
            // maxYでの調整
            cropRect.origin.y = pRect.maxY > cropRect.maxY ? cropRect.origin.y + pRect.maxY - cropRect.maxY : cropRect.origin.y
        }else{
            //調整できない場合、中心を合わせる。
            if pRect.width > cropRect.width{
                cropRect.origin.x += (pRect.midX - cropRect.midX)
            }
            if pRect.height > cropRect.height{
                cropRect.origin.y += (pRect.midY - cropRect.midY)
            }
        }
    }

    test.drawRect(cropRect,color: .yellow)

ここで、test:UIImageは以下のようになります。

うまく行きそうですね。

トリミング

切り取りメソッドは、以下のリンクから丸パクリでUIImage組み込みました。

Swift3.0で画像の切り抜き

playground
extension UIImage {
    func cropping(to: CGRect) -> UIImage? {
        var opaque = false
        if let cgImage = cgImage {
            switch cgImage.alphaInfo {
            case .noneSkipLast, .noneSkipFirst:
                opaque = true
            default:
                break
            }
        }

        UIGraphicsBeginImageContextWithOptions(to.size, opaque, scale)
        draw(at: CGPoint(x: -to.origin.x, y: -to.origin.y))
        let result = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return result
    }
}

実行結果

比較してみましょう
中央固定で切り抜いた時
スクリーンショット 2017-06-23 2.42.01.png

ロジック適用後
スクリーンショット 2017-06-23 2.40.21.png

はるかに良くなりました。風景もちゃんと捉えた上でトリミングができました。
もうちょい右の方が。。などご意見あるかもしれませんが、その際はCIDetectOfFaceメソッドでのmargin_rateをいじって調整してください。

実行例

色々な写真について、切り抜いて行きます。

実行例1

フリー素材感丸出しな家族の写真。
元画像 w:861, h:1024
※ 2018/09/28追記:ごめんなさい、、リンク切れしちゃってます、、想像で補完してください

トリミング -> w:480, height:200

実行例2

かわいい。
元画像 w:4,288 h:2,848

トリミング -> w:480, height:200

トリミング -> w:200, height:480

トリミング -> w:480, height:480

いい感じですね。
特に最後のスクエアトリミングの構図感、最高じゃないでしょうか。

実装コード

playgroundでべたっと貼り付けていただければ動作するかと思います。

playground
// DIDetectorの実験
import UIKit

extension UIImage {
    /// 与えられたCGRect群からUIImageに枠を描画する
    ///
    /// - Parameters:
    ///   - faceRects: CGRect群
    /// - Returns: 描画後のUIImage
    func drawRects(_ rects:[CGRect],color:UIColor = .red)->UIImage{
        UIGraphicsBeginImageContextWithOptions(size, false,  UIScreen.main.scale)
        //context
        let drawCtxt = UIGraphicsGetCurrentContext()

        self.draw(in: CGRect(x:0,y:0,width:self.size.width,height:self.size.height))

        for rect in rects{
            let rect = rect
            //rect.origin.y = self.size.height - rect.origin.y - rect.size.height
            drawCtxt?.setLineWidth(2.0)
            drawCtxt?.setStrokeColor(color.cgColor)
            drawCtxt?.stroke(rect)
        }

        let image = UIGraphicsGetImageFromCurrentImageContext()

        UIGraphicsEndImageContext()

        return image!
    }

    func drawRect(_ rect:CGRect,color:UIColor = .red)->UIImage{
        return self.drawRects([rect],color: color)
    }
}

extension UIImage {
    // クロップ用
    func cropping(to: CGRect) -> UIImage? {
        var opaque = false
        if let cgImage = cgImage {
            switch cgImage.alphaInfo {
            case .noneSkipLast, .noneSkipFirst:
                opaque = true
            default:
                break
            }
        }
        UIGraphicsBeginImageContextWithOptions(to.size, opaque, scale)
        //draw(at: CGPoint(x: -to.origin.x, y: -(self.size.height - to.origin.y - to.size.height)))
        draw(at: CGPoint(x: -to.origin.x, y: -to.origin.y))
        let result = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return result
    }
}


/// 与えられた画像の顔の座標、大きさを返す。
///
/// - Parameter image: 画像
/// - Parameter margin: 余白
/// - Returns: 顔の座標、大きさのCGRect配列
func CIDetectOfFace(_ image: UIImage, quality:String = CIDetectorAccuracyHigh, margin_rate:CGFloat = 0.4)->[CGRect]{
    // CIDetector導入
    let ciImage  = CIImage(image:image)
    let ciDetector = CIDetector(ofType:CIDetectorTypeFace
        ,context:CIContext()
        ,options:[
            CIDetectorAccuracy:quality,
            CIDetectorSmile:true
        ]
    )

    // 認識
    let features = ciDetector?.features(in: ciImage!)
    var faceRects:[CGRect] = []

    // 顔情報取得
    for feature in features!{
        //face
        var faceRect = (feature as! CIFaceFeature).bounds

        // 左下起点、Rect左下から、左上起点、Rect左上へ、座標系を合わせる
        faceRect.origin.y = image.size.height - faceRect.origin.y - faceRect.height

        // 余白を持たせる
        let widthMargin = faceRect.width * margin_rate
        faceRect.origin.x -= widthMargin
        faceRect.size.width += widthMargin*2

        let heightMargin = faceRect.height * margin_rate
        faceRect.origin.y -= heightMargin*2
        faceRect.size.height += heightMargin*2

        print(faceRect," in ",image.size)
        image.size
        faceRects.append(faceRect)

    }
    return faceRects
}


/// 与えられたRect群を全て包含するようなRectを返す。
///
/// - Parameter rects: 包含されるRect群
/// - Returns: Rect群を包含するようなRect
func  inclusionRect(_ rects:[CGRect])->CGRect?{
    if rects.count == 0{return nil}

    let maxX:CGFloat = rects.map({$0.maxX}).max()!
    let minX:CGFloat = rects.map({$0.minX}).min()!
    let maxY:CGFloat = rects.map({$0.maxY}).max()!
    let minY:CGFloat = rects.map({$0.minY}).min()!

    return CGRect(x:minX,y:minY,width:maxX-minX,height:maxY-minY)
}

func getCropRect(image :UIImage, contentSize:CGSize)->CGRect{

    let image:UIImage = resizeContentFill(image,size: contentSize)

    // 切り取り位置調整
    var cropRect  = CGRect.init(x: (image.size.width - contentSize.width) / 2,
                                y: (image.size.height - contentSize.height) / 2,
                                width: contentSize.width,
                                height: contentSize.height)

    // 画像内の顔の大きさと座標を取得
    let faceRects:[CGRect] = CIDetectOfFace(image)
    if faceRects.count == 0{return cropRect}

    // 全てを包含するRectを生成。
    let pRect:CGRect = inclusionRect(faceRects)!

    let test = image.drawRects(faceRects).drawRect(pRect,color: .blue)

    cropRect.contains(pRect)

    if !cropRect.contains(pRect){//調整の必要
        // 調整できるか否か。
        if pRect.width < cropRect.width && pRect.height < cropRect.height{
            // 調整できるので調整。

            // minXでの調整
            cropRect.origin.x = pRect.minX < cropRect.minX ? pRect.minX : cropRect.minX
            // minYでの調整
            cropRect.origin.y = pRect.minY < cropRect.minY ? pRect.minY : cropRect.minY
            // maxXでの調整
            cropRect.origin.x = pRect.maxX > cropRect.maxX ? cropRect.origin.x + pRect.maxX - cropRect.maxX : cropRect.origin.x
            // maxYでの調整
            cropRect.origin.y = pRect.maxY > cropRect.maxY ? cropRect.origin.y + pRect.maxY - cropRect.maxY : cropRect.origin.y
        }else{
            //調整できない場合、中心を合わせる。
            if pRect.width > cropRect.width{
                cropRect.origin.x += (pRect.midX - cropRect.midX)
            }
            if pRect.height > cropRect.height{
                cropRect.origin.y += (pRect.midY - cropRect.midY)
            }
        }
    }

    test.drawRect(cropRect,color: .yellow)

    return cropRect
}


/// クロップ用にリサイズ。sizeが、imageにFillする形になるようにimageをリサイズ。
///
/// - Parameters:
///   - image: 入力画像
///   - size: サイズ
/// - Returns: リサイズ後のUIImage
func resizeContentFill(_ image:UIImage,size:CGSize) -> UIImage {
    let w:CGFloat = size.width
    let h:CGFloat = size.height
    let w_h = w/h // アスペクト比の基準となるもの。大きいほど横に長い。

    // リサイズ処理
    // let origRef    = image.cgImage
    // let origWidth  = Int(origRef!.width)
    // let origHeight = Int(origRef!.height)
    let origWidth = image.size.width
    let origHeight = image.size.height
    let origW_H = origWidth/origHeight
    var resizeWidth:CGFloat = 0, resizeHeight:CGFloat = 0


    if origW_H >= w_h{
        resizeHeight = h
        resizeWidth = origWidth * resizeHeight / origHeight
    }else{
        resizeWidth = w
        resizeHeight = origHeight * resizeWidth / origWidth
    }

    let resizeSize = CGSize.init(width: resizeWidth, height: resizeHeight)

    UIGraphicsBeginImageContext(resizeSize)

    image.draw(in: CGRect.init(x: 0, y: 0, width: CGFloat(resizeWidth), height: CGFloat(resizeHeight)))

    let resizeImage = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsEndImageContext()

    return resizeImage!
}



//// 以下実行

let cropSize:CGSize = CGSize(width:480,height:480)

// 画像をURLから取得。
let url = URL(string: "http://farm4.static.flickr.com/3442/4001633830_71546c8f53_o.jpg")
var imageData = try? Data(contentsOf: url!)
var image:UIImage = UIImage(data:imageData!)!

image = resizeContentFill(image, size: cropSize)

// 画像内の顔の大きさと座標を取得
let faceRects:[CGRect] = CIDetectOfFace(image)

// 顔を囲う
let faceRectedImage:UIImage = image.drawRects(faceRects)

// 全てを包含するRectを生成。
var faceRectedAllImage:UIImage = faceRectedImage
if let pRect:CGRect = inclusionRect(faceRects){
    pRect
    faceRectedAllImage = faceRectedImage.drawRect(pRect,color: .blue)
}

// 切り取り範囲を算出
let cropRect:CGRect = getCropRect(image: image, contentSize: cropSize)
let cropRangeImage:UIImage = faceRectedAllImage.drawRect(cropRect,color: .yellow)

image.cropping(to: cropRect)

おわり

顔のマージン設定のロジックがまだまだ甘いです。
なんかいい案あればご教授いただけると幸いです。

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
8