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

**2018年4月4日追記**

新しい記事を書きましたので、以下を参照してください。
[[iOS] Watson Visual Recognitionを使って顔解析アプリを作ってみた（Swift 4 Codable対応版）](https://qiita.com/y-some/items/72baf2f6218f2688342e#_reference-6ccb7128cd012722c261)

![WatsonVR.gif](https://qiita-image-store.s3.amazonaws.com/0/122917/de4719f5-916f-dce2-8ab3-3777f04dab16.gif)

# はじめに

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

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

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

私はこのAPIが「Alchemy Vision」という機能で提供されていた頃に[顔解析のiOSアプリを作ってみた](http://qiita.com/y-some/items/8f2cec7a7aac70368202)のですが、

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

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

# VRを利用する手順

事前に、IBM BluemixにVRのサービスを作成します。
手順はこちらの記事に詳細に記載されています。
[BluemixでWatson API のVisual Recognition を使う by curl]((http://qiita.com/MoriKen/items/6403cf5f6775bb8aa4c4))

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

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

# Detect faces APIの仕様

## リクエストパラメータ

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

詳細は[Detect facesのAPI Reference](https://www.ibm.com/watson/developercloud/visual-recognition/api/v3/#detect_faces)を参照。
サンプルは下記ソースコードを参照。

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

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   |

# 使用ライブラリ

- [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON)
- パッケージマネージャとして[Carthage](https://github.com/Carthage/Carthage)を利用


# ストーリーボード

<img width="1342" alt="スクリーンショット 2016-12-20 22.03.11.png" src="https://qiita-image-store.s3.amazonaws.com/0/122917/125e9b06-ac71-00b3-3892-6cae0653724d.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

# コード

```Swift: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> = []

//(以下略)
```

```Swift: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)
    }
    
}
```

```Swift: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
    }
}
```

```Swift: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
    }
    
}
```