post Image
[Swift] [iOS] Watson Visual Recognitionを使って顔解析アプリを作ってみた

2018年4月4日追記

新しい記事を書きましたので、以下を参照してください。
[iOS] Watson Visual Recognitionを使って顔解析アプリを作ってみた(Swift 4 Codable対応版)

WatsonVR.gif

はじめに

Watsonの顔認識APIを使って、iOSアプリを作ってみました。
「Visual Recognition」(以下VR)という画像解析機能の「Detect faces」というAPIです。
顔が含まれる画像を解析し、以下のような情報を返してくれます。

  • 検出された顔の座標
  • 性別と確率
  • 年齢と確率
  • 有名人の場合は名前およびタグ情報

このAPIはトレーニング済みの状態で提供されています。
VRには他に、利用者がトレーニング画像を集めてWatsonに学習させるタイプのAPIもあります。

私はこのAPIが「Alchemy Vision」という機能で提供されていた頃に顔解析のiOSアプリを作ってみたのですが、

  • Alchemy VisionがVRに統合された
  • その時はSwift 2だった

ということで、VRとSwift 3で作り直しました。

VRを利用する手順

事前に、IBM BluemixにVRのサービスを作成します。
手順はこちらの記事に詳細に記載されています。
BluemixでWatson API のVisual Recognition を使う by curl

サービス作成後に発行されるAPI KeyをiOSアプリからのリクエストで使用します。(後述)

VRには無料プランがあります。

Detect faces APIの仕様

リクエストパラメータ

POSTメソッドで画像を送ります。
Content-Typeは”application/x-www-form-urlencoded”です。
クエリパラメータに上記「VRを利用する手順」で控えておいたAPI Keyをセットし、Bodyに画像データをエンコードせずセットします。

詳細はDetect facesのAPI Referenceを参照。
サンプルは下記ソースコードを参照。

レスポンスデータのサンプル

JSONで返ってきます。
以下の通り、解析された顔が配列として格納されています。

{
  "images_processed" : 1,
  "images" : [
    {
      "faces" : [
        {
          "age" : {
            "max" : 54,
            "min" : 45,
            "score" : 0.405922
          },
          "face_location" : {
            "top" : 401,
            "height" : 105,
            "left" : 1117,
            "width" : 96
          },
          "gender" : {
            "score" : 0.9933070000000001,
            "gender" : "MALE"
          },
          "identity" : {
            "name" : "Barack Obama",
            "score" : 0.989013,
            "type_hierarchy" : "\/people\/politicians\/democrats\/barack obama"
          }
        },
        {
          "age" : {
            "max" : 64,
            "min" : 55,
            "score" : 0.617719
          },
          "face_location" : {
            "top" : 366,
            "height" : 125,
            "left" : 607,
            "width" : 81
          },
          "gender" : {
            "score" : 0.9933070000000001,
            "gender" : "MALE"
          },
          "identity" : {
            "name" : "Shinzō Abe",
            "score" : 0.731059
          }
        },
        {
          "age" : {
            "min" : 65,
            "score" : 0.416757
          },
          "face_location" : {
            "top" : 285,
            "height" : 107,
            "left" : 1339,
            "width" : 99
          },
          "gender" : {
            "score" : 0.0474259,
            "gender" : "FEMALE"
          }
        },
        {
          "age" : {
            "max" : 44,
            "min" : 35,
            "score" : 0.403753
          },
          "face_location" : {
            "top" : 311,
            "height" : 121,
            "left" : 228,
            "width" : 142
          },
          "gender" : {
            "score" : 0.970688,
            "gender" : "MALE"
          }
        }
      ]
    }
  ]
}

“age”: “max”はセットされていない場合があるようですね。
“identity”はオバマさんと安倍さんだけセットされています。

開発環境

Items Version
Xcode 8.2
Swift 3.0.2

使用ライブラリ

ストーリーボード

スクリーンショット 2016-12-20 22.03.11.png

UINavigationController

初期画面と解析結果画面を行き来するために、UINavigationControllerを組み込みます。

MainViewController: UIViewController

初期画面です。
以下のUIオブジェクトを配置しています。

  • 操作ガイドのUILabel
  • 選択された画像を表示するためのUIImageView
  • カメラを起動するためのUIButton
  • フォトライブラリを起動するためのUIButton
  • 解析(API連携)を開始するためのUIButton
  • 解析待ち用UIActivityIndicatorView
  • Segue: Identifier=”ShowResult”

SubTableViewController: UITableViewController

解析結果を一覧表示するためのUITableViewです。

ResultTableViewCell: UITableViewCell

解析結果用のセルです。
以下のUIオブジェクトを配置しています。

  • 画像を表示するためのUIImageView
  • 性別、性別確信度、年齢(Min-Max)、年齢確信度、名前を表示するためのUILabel

コード

AppDelegate.swift
import UIKit

// 解析結果を格納するクラス
class AnalyzedFace {
    var image: UIImage?
    var gender: String?
    var genderScore: String?
    var ageMin: String?
    var ageMax: String?
    var ageScore: String?
    var identity: String?
}

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    //ViewController間でデータを受け渡しするための変数
    var analyzedFaces: Array<AnalyzedFace> = []

//(以下略)
MainViewController.swift
import UIKit
import SwiftyJSON
import Photos

class MainViewController: UIViewController, UIImagePickerControllerDelegate, UINavigationControllerDelegate {

    // ガイド文言Label
    @IBOutlet weak var guideLabel: UILabel!
    // 選択された画像
    @IBOutlet weak var selectedImageView: UIImageView!
    // 解析中のインジケータ
    @IBOutlet weak var activityIndicator: UIActivityIndicatorView!

    // MARK: UIViewController - Event

    override func viewDidLoad() {
        super.viewDidLoad()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }

    // MARK: IBAction

    /**
     カメラ起動ボタンTap
     */
    @IBAction func launchCameraButtonTapped(_ sender: Any) {
        if UIImagePickerController.isSourceTypeAvailable(.camera) {
            self.launchImagePicker(type: .camera)
        }
    }

    /**
     写真選択ボタンTap
     */
    @IBAction func launchPhotoButtonTapped(_ sender: Any) {
        if UIImagePickerController.isSourceTypeAvailable(.photoLibrary) {
            self.launchImagePicker(type: .photoLibrary)
        }
    }

    /**
     解析開始ボタンTap
     */
    @IBAction func analyzeButtonTapped(_ sender: Any) {
        guard let selectedImage = self.selectedImageView.image else {
            return
        }
        // API仕様の画像サイズを超えないようにリサイズしてからAPIコールする
        guard let resizedImage = self.resizeJpeg(image: selectedImage) else {
            return
        }
        self.callApi(image: resizedImage)
    }

    // MARK: Delegate

    /**
     UIImagePickerControllerDelegate:画像選択時
     */
    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [String : Any]) {
        picker.dismiss(animated: true, completion: nil)
        guard let image = info[UIImagePickerControllerOriginalImage] else {
            return
        }
        // 選択画像をImageViewに表示
        self.selectedImageView.image = image as? UIImage
        // ガイドLabelを非表示に
        self.guideLabel.isHidden = true
    }

    // MARK: Method

    /**
     カメラ/フォトライブラリの起動
     - parameter type: カメラ/フォトライブラリ
     */
    func launchImagePicker(type: UIImagePickerControllerSourceType) {
        let controller = UIImagePickerController()
        controller.delegate = self
        controller.sourceType = type
        self.present(controller, animated: true, completion: nil)
    }

    /**
     画像圧縮処理
     - WatsonVR detect_faces APIの仕様により画像サイズは最大2MG(2016年12月現在)
     - サイズが収まるまで再帰的にサイズを縮小する
     - parameter image: ソース画像
     - returns: UIImage
     */
    func resizeJpeg(image: UIImage) -> UIImage? {
        let maxSize: Int = 1480000  // このぐらいのピクセル数だと2MBを超えないようだ(実験値)
        if Int(image.size.width * image.size.height) <= maxSize {
            return image
        }
        // 圧縮
        let size: CGSize = CGSize(width: (image.size.width * 0.8), height: (image.size.height * 0.8))
        UIGraphicsBeginImageContext(size)
        image.draw(in: CGRect(x: 0, y: 0, width: size.width, height: size.height))
        guard let resizeImage = UIGraphicsGetImageFromCurrentImageContext() else {
            return nil
        }
        UIGraphicsEndImageContext()
        // 再帰処理
        return self.resizeJpeg(image: resizeImage)
    }

    /**
     API連携
     - parameter image: 解析対象画像イメージ
     */
    func callApi(image: UIImage) {
        // 解析結果はAppDelegateの変数を経由してSubViewに渡す
        let appDelegate: AppDelegate = UIApplication.shared.delegate as! AppDelegate

        // API呼び出し準備
        let APIKey = "(API Key)" // APIKeyを取得してここに記述
        let url = "https://gateway-a.watsonplatform.net/visual-recognition/api/v3/detect_faces?api_key=" + APIKey + "&version=2016-05-20"
        guard let destURL = URL(string: url) else {
            print ("url is NG: " + url) // debug
            return
        }
        var request = URLRequest(url: destURL)
        request.httpMethod = "POST"
        request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
        request.httpBody = UIImageJPEGRepresentation(image, 1)

        // activityIndicator始動
        self.activityIndicator.startAnimating()

        // WatsonAPIコール
        let task = URLSession.shared.dataTask(with: request) {
            data, response, error in

            if error == nil {
                // APIレスポンス:正常
                let json = JSON(data: data!)
                print(json) // debug

                appDelegate.analyzedFaces = self.interpretJson(image: image, json: json)

                // リクエストは非同期のため画面遷移をmainQueueで行わないとエラーになる
                OperationQueue.main.addOperation(
                    {
                        // activityIndicator停止
                        self.activityIndicator.stopAnimating()
                        if appDelegate.analyzedFaces.count > 0 {
                            // 顔解析結果あり
                            self.performSegue(withIdentifier: "ShowResult", sender: self)
                        } else {
                            // 顔解析結果なし
                            let actionSheet = UIAlertController(title:"エラー", message: "顔検出されませんでした", preferredStyle: .alert)
                            let actionCancel = UIAlertAction(title: "キャンセル", style: .cancel, handler: {action in
                            })
                            actionSheet.addAction(actionCancel)
                            self.present(actionSheet, animated: true, completion: nil)
                        }
                    }
                )
            } else {
                // APIレスポンス:エラー
                print(error.debugDescription)   // debug
            }
            // activityIndicator停止
            if self.activityIndicator.isAnimating {
                self.activityIndicator.stopAnimating()
            }
        }

        task.resume()
    }

    /**
     解析結果のJSONを解釈してAnalyzedFace型の配列で返す
     - parameter image: 元画像
     - parameter json: JSONデータ
     - returns: AnalyzedFace型の配列
     */
    func interpretJson(image: UIImage, json: JSON) -> Array<AnalyzedFace> {
        var analyzedFaces: Array<AnalyzedFace> = []
        let facesJson = json["images"][0]["faces"].arrayValue
        // レスポンスのimageFaces要素は配列となっている(複数人が映った画像の解析が可能)
        for faceJson in facesJson {
            let face = AnalyzedFace()
            // 性別およびスコア
            guard let gender = faceJson["gender"]["gender"].string else {
                continue
            }
            if gender == "MALE" {
                face.gender = "男性"
            } else {
                face.gender = "女性"
            }
            guard let genderScore = faceJson["gender"]["score"].double else {
                continue
            }
            face.genderScore = String(floor(genderScore * 1000) / 10)
            // 年齢およびスコア
            if let ageMin = faceJson["age"]["min"].int {
                face.ageMin = String(ageMin)
            }
            if let ageMax = faceJson["age"]["max"].int {
                face.ageMax = String(ageMax)
            }
            guard let ageScore = faceJson["age"]["score"].double else {
                continue
            }
            face.ageScore = String(floor(ageScore * 1000) / 10)
            // Identity
            if let identity = faceJson["identity"]["name"].string {
                face.identity = identity
            }
            // 検出された顔の矩形
            guard let left = faceJson["face_location"]["left"].int else {
                continue
            }
            guard let top = faceJson["face_location"]["top"].int else {
                continue
            }
            guard let width = faceJson["face_location"]["width"].int else {
                continue
            }
            guard let height = faceJson["face_location"]["height"].int else {
                continue
            }
            // 元画像から切り抜いて変数にセット
            face.image = self.cropping(image: image, left: CGFloat(left), top: CGFloat(top), width: CGFloat(width), height: CGFloat(height))
            // 抽出完了
            analyzedFaces.append(face)
        }
        return analyzedFaces
    }

    /**
     元画像から矩形を切り抜く
     - parameter image: 元画像
     - parameter left: x座標
     - parameter top: y座標
     - parameter width: 幅
     - parameter height: 高さ
     - returns: UIImage
     */
    func cropping(image: UIImage, left: CGFloat, top: CGFloat, width: CGFloat, height: CGFloat) -> UIImage? {
        let imgRef = image.cgImage?.cropping(to: CGRect(x: left, y: top, width: width, height: height))
        return UIImage(cgImage: imgRef!, scale: image.scale, orientation: image.imageOrientation)
    }

}
SubTableViewController.swift
import UIKit

class SubTableViewController: UITableViewController {

    // 解析結果はAppDelegateの変数に入っている
    private let appDelegate: AppDelegate = UIApplication.shared.delegate as! AppDelegate

    override func viewDidLoad() {
        super.viewDidLoad()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }

    // MARK: - Table view data source

    /**
     セルの数を返す
     */
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.appDelegate.analyzedFaces.count
    }

    /**
     セルの項目をセット
     */
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let result = self.appDelegate.analyzedFaces[indexPath.row]
        let cell = tableView.dequeueReusableCell(withIdentifier: "ResultCell", for: indexPath) as! ResultTableViewCell
        cell.setData(data: result)
        return cell
    }
}
ResultTableViewCell
import UIKit

class ResultTableViewCell: UITableViewCell {

    @IBOutlet weak var faceImageView: UIImageView!
    @IBOutlet weak var genderNameLabel: UILabel!
    @IBOutlet weak var genderScoreLabel: UILabel!
    @IBOutlet weak var ageRangeLabel: UILabel!
    @IBOutlet weak var ageScoreLabel: UILabel!
    @IBOutlet weak var identityLabel: UILabel!

    /**
     セルの内容をセット
     - parameter data: 解析結果
     */
    func setData(data: AnalyzedFace) {
        self.faceImageView.image = data.image
        if let gender = data.gender {
            self.genderNameLabel.text = "性別:" + gender
        }
        if let genderScore = data.genderScore {
            self.genderScoreLabel.text = "   確信度:" + genderScore + "%"
        }
        var ageRangeText = "年齢:"
        if let ageMin = data.ageMin {
            ageRangeText += ageMin + "才以上 "
        }
        if let ageMax = data.ageMax {
            ageRangeText += ageMax + "才以下"
        }
        self.ageRangeLabel.text = ageRangeText
        if let ageScore = data.ageScore {
            self.ageScoreLabel.text = "   確信度:" + ageScore + "%"
        }
        self.identityLabel.text = data.identity
    }

}

『 Swift 』Article List