[iOS] Watson Visual Recognitionを使って顔解析アプリを作ってみた(Swift 4 Codable対応版)

はじめに

以前私は、[Swift] [iOS] Watson Visual Recognitionを使って顔解析アプリを作ってみた、という記事を書きました。

上記の記事ではSwift 3を使用しており、JSONのparseをライブラリ「SwiftyJSON」で行っていました。
今回は、Swift 4にリファクタリングし、新機能Codableプロトコルを使って書き換えました。

記事を全体的に書き直しましたので、以下のような点に興味のある方を対象にした内容になっています。

  • Visual Recognitionサービスの顔解析API「Detect faces」
  • Codableのサンプルコード
  • Swiftによる画像処理

Codable自体の解説についてはクラスメソッドさんのこちら↓の記事が詳しいです。
[Swift 4] SwiftyJSONを使わずにシンプルにJSONをデータ構造化する

<ご注意>
本稿は2018年1月時点の情報に基づいており、現在の情報と異なっている可能性があります。
本稿の内容は執筆者独自の見解であり、所属企業における立場、戦略、意見を代表するものではありません。

動作イメージ

WatsonVR.gif

開発環境

Items Version
Xcode 9.2
Swift 4

顔解析API「Detect faces」について

Visual Recognitionサービス(以下VR)のDetect faces APIは、顔が含まれる画像を解析し、以下のような情報を返してくれます。

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

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

VRを利用する手順

IBM Cloudアカウントが必要です。
無料のライト・アカウントはこちらから登録できます。

アカウント取得後、IBM CloudダッシュボードにてVRのサービスを作成します。
VRにはFreeプランがあり、一日250イベント(画像)までの画像タグ付けおよび顔検出が提供されています。

VRのサービスを作成したら、「サービス資格情報」にてAPIキーを控えておきます。
スクリーンショット 2018-01-03 15.51.30.png

Detect faces APIの仕様

リクエストパラメータ

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

詳細はDetect facesのAPI Referenceを参照。

Detect faces APIのレスポンスサンプル

こちらのオバマさんの写真をDetect faces APIに投げると返ってくるレスポンスのサンプルはこちら。
200px-President_Barack_Obama.jpg

{
"images_processed" : 1,
"images" : [
    {
        "faces" : [
            {
                "identity" : {
                    "name" : "Barack Obama",
                    "type_hierarchy" : "\/people\/politicians\/democrats\/barack obama",
                    "score" : 0.98901300000000003
                },
                "age" : {
                    "min" : 35,
                    "max" : 44,
                    "score" : 0.39339299999999999
                },
                "gender" : {
                    "gender" : "MALE",
                    "score" : 0.99330700000000005
                },
                "face_location" : {
                    "left" : 73,
                    "height" : 78,
                    "top" : 22,
                    "width" : 62
                }
            }
        ]
    }
]
}

ストーリーボード

スクリーンショット 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

コード

Codable

Detect faces APIのレスポンスを扱うCodableはこちら。

FaceData.swift
struct FaceData: Codable {
    struct Faces: Codable {
        struct Face: Codable {
            struct Identity: Codable {
                let name: String?
                let type_hierarchy: String?
                let score: Double?
            }
            struct Age: Codable {
                let min: Int
                let max: Int?
                let score: Double
            }
            struct Gender: Codable {
                let gender: String
                let score: Double
            }
            struct Face_location: Codable {
                let left: Int
                let height: Int
                let top: Int
                let width: Int
            }
            let identity: Identity?
            let age: Age
            let gender: Gender
            let face_location: Face_location
        }
        let faces: [Face]
    }
    let images_processed: Int
    let images: [Faces]
}

JSONオブジェクトを各々structで定義して、Codableプロトコルに準拠していると宣言するだけです。
上の例ではstructを入れ子にして宣言していますが、外に出すこともできます。
ただし、型合わせることと、nilになりえる場合はOptional宣言することに注意しなければなりません。

AppDelegate

解析結果を画面間で受け渡しするために、AppDelegateにクラスと変数を定義しています。
あまり良くない作りかも知れませんが、Proof of Conceptなので…

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

メイン画面の制御と、Web APIのコール、レスポンス処理を実装しています。

MainViewController.swift
import UIKit
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コールする
        // WatsonVR detect_faces APIの仕様により画像サイズは最大2MG(2016年12月現在)
        guard let resizedImage = selectedImage.fixedOrientation()?.resizeImage(maxSize: 2097152) 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)
    }

    /**
     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, let data = data {
                // APIレスポンス:正常
                let faceData = try! JSONDecoder().decode(FaceData.self, from: data)

                appDelegate.analyzedFaces = self.interpret(image: image, parsedData: faceData)

                // リクエストは非同期のため画面遷移を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停止
            OperationQueue.main.addOperation(
                {
                    if self.activityIndicator.isAnimating {
                        self.activityIndicator.stopAnimating()
                    }
                }
            )
        }

        task.resume()
    }

    /**
     解析結果のJSONを解釈してAnalyzedFace型の配列で返す
     - parameter image: 元画像
     - parameter parsedData: パース後データ
     - returns: AnalyzedFace型の配列
     */
    func interpret(image: UIImage, parsedData: FaceData) -> Array<AnalyzedFace> {
        var analyzedFaces: Array<AnalyzedFace> = []
        let parsedFaces = parsedData.images[0].faces
        // レスポンスのimageFaces要素は配列となっている(複数人が映った画像の解析が可能)
        for parsedFace in parsedFaces {
            let analyzedFace = AnalyzedFace()
            // 性別およびスコア
            if parsedFace.gender.gender == "MALE" {
                analyzedFace.gender = "男性"
            } else {
                analyzedFace.gender = "女性"
            }
            analyzedFace.genderScore = String(floor(parsedFace.gender.score * 1000) / 10)
            // 年齢およびスコア
            analyzedFace.ageMin = String(parsedFace.age.min)
            if let max = parsedFace.age.max {
                analyzedFace.ageMax = String(max)
            }
            analyzedFace.ageScore = String(floor(parsedFace.age.score * 1000) / 10)
            // Identity
            if let identity = parsedFace.identity?.name {
                analyzedFace.identity = identity
            }
            let left = parsedFace.face_location.left
            let top = parsedFace.face_location.top
            let width = parsedFace.face_location.width
            let height = parsedFace.face_location.height
            // 元画像から切り抜いて変数にセット
            analyzedFace.image = self.cropping(image: image, left: CGFloat(left), top: CGFloat(top), width: CGFloat(width), height: CGFloat(height))
            // 抽出完了
            analyzedFaces.append(analyzedFace)
        }
        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)
    }

}

let faceData = try! JSONDecoder().decode(FaceData.self, from: data)
これだけでFaceData型にJSONがparseされます。
とてもSwiftyですね!

ちなみに上の実装では、try!でコンパイラを黙らせてしまっているので、Codableの型が合わないとクラッシュします。
本来は適切な例外処理が必要になります。

UIImage Extension

APIのファイルサイズ制限(2MB)や、iPhoneで撮影した写真は他の環境だと上下反転している問題を解消するため、UIImageのExtensionを作りました。

Extension.swift
import Foundation
import UIKit

extension UIImage {

    /// 上下逆になった画像を反転する
    func fixedOrientation() -> UIImage? {
        if self.imageOrientation == UIImageOrientation.up {
            return self
        }
        UIGraphicsBeginImageContextWithOptions(self.size, false, scale)
        self.draw(in: CGRect(x: 0, y: 0, width: self.size.width, height: self.size.height))
        guard let newImage = UIGraphicsGetImageFromCurrentImageContext() else {
            return nil
        }
        UIGraphicsEndImageContext()
        return newImage
    }

    /// イメージ縮小
    func resizeImage(maxSize: Int) -> UIImage? {

        guard let jpg = UIImageJPEGRepresentation(self, 1) as NSData? else {
            return nil
        }
        if isLessThanMaxByte(data: jpg, maxDataByte: maxSize) {
            return self
        }
        // 80%に圧縮
        let _size: CGSize = CGSize(width: (self.size.width * 0.8), height: (self.size.height * 0.8))
        UIGraphicsBeginImageContext(_size)
        self.draw(in: CGRect(x: 0, y: 0, width: _size.width, height: _size.height))
        guard let newImage = UIGraphicsGetImageFromCurrentImageContext() else {
            return nil
        }
        UIGraphicsEndImageContext()
        // 再帰処理
        return newImage.resizeImage(maxSize: maxSize)
    }

    /// 最大容量チェック
    func isLessThanMaxByte(data: NSData?, maxDataByte: Int) -> Bool {

        if maxDataByte <= 0 {
            // 最大容量の指定が無い場合はOK扱い
            return true
        }
        guard let _data = data else {
            fatalError("Data unwrap error")
        }
        if _data.length < maxDataByte {
            // 最大容量未満:OK ※以下でも良いがバッファを取ることにした
            return true
        }
        // 最大容量以上:NG
        return false
    }
}

SubTableViewController

解析結果を一覧表示するためのUITableViewの制御を実装しています。

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

受け取った解析結果をTableViewCellに表示する処理を実装しています。

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
        self.genderNameLabel.text = "性別:" + data.gender
        self.genderScoreLabel.text = "   確信度:" + data.genderScore + "%"
        var ageRangeText = "年齢:"
        ageRangeText += data.ageMin + "才以上 "
        if let ageMax = data.ageMax {
            ageRangeText += ageMax + "才以下"
        }
        self.ageRangeLabel.text = ageRangeText
        self.ageScoreLabel.text = "   確信度:" + data.ageScore + "%"
        self.identityLabel.text = data.identity
    }

}

Codableを使って見た感想

Codableを使うと、

  • JSONをゴニョゴニョとparseするコードを書かなくて済むことが素晴らしい!
  • Codableの宣言を見れば、レスポンスの仕様をイメージできるのが素晴らしい!
    • しかもnilになるか否かも定義されるので素晴らしい!