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

# 2018-07-09

Watson Visual Recognition APIの仕様が変わったようで、本記事内のサンプルコードが動作しなくなっています。
multipart/form-data で送信する必要があるようです。

# はじめに

以前私は、[[Swift] [iOS] Watson Visual Recognitionを使って顔解析アプリを作ってみた](https://qiita.com/y-some/items/45e2c99b91485638e05b)、という記事を書きました。

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

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

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

Codable自体の解説についてはクラスメソッドさんのこちら↓の記事が詳しいです。
[[Swift 4] SwiftyJSONを使わずにシンプルにJSONをデータ構造化する](https://dev.classmethod.jp/smartphone/json-decoding-without-swiftyjson/)

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

# 動作イメージ

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

# 開発環境

| Items | Version |
|:--    |:--      |
| Xcode | 9.2     |
| Swift | 4       |

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

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

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

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

# VRを利用する手順

IBM Cloudアカウントが必要です。
無料のライト・アカウントは[こちら](https://www.ibm.com/cloud-computing/jp/ja/bluemix/lite-account/)から登録できます。

アカウント取得後、IBM CloudダッシュボードにてVRのサービスを作成します。
VRのサービスを作成したら、「サービス資格情報」にてAPIキーを控えておきます。
<img width="962" alt="スクリーンショット 2018-01-03 15.51.30.png" src="https://qiita-image-store.s3.amazonaws.com/0/122917/21bc3274-d403-93fe-18ff-ad4fc40eb791.png">

# 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)を参照。

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

こちらのオバマさんの写真をDetect faces APIに投げると返ってくるレスポンスのサンプルはこちら。
![200px-President_Barack_Obama.jpg](https://qiita-image-store.s3.amazonaws.com/0/122917/39daa3b9-a43c-6630-e7d1-e09c5e2fc15b.jpeg)

```json
{
"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
                }
            }
        ]
    }
]
}
```

# コード

GitHubにてリポジトリをシェアしていますので、こちらからCloneまたはダウンロードしてください。
https://github.com/y-some/WatsonVR

その際、一つ注意点があります。
前述したAPIキーは、apikey.plistファイルに外出ししています。
apikey.plistはgitignoreしていますので、以下の感じで作成し、プロジェクトに追加してください。
<img width="897" alt="スクリーンショット 2018-04-04 16.28.13.png" src="https://qiita-image-store.s3.amazonaws.com/0/122917/c63fe1a7-733f-efb2-4b7a-3b93668ab231.png">

以下、Watson VR APIとの連携部分のコードを中心に解説を加えます。
UI制御等の部分は長くなるので割愛します。

## Codable

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

```swift:FaceData.swift
/// Watson VR APIのレスポンスをCodableに準拠させた構造体
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 FaceLocation: 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: FaceLocation
        }
        let faces: [Face]
    }
    let images_processed: Int
    let images: [Faces]
}
```

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

## Watson VR API

```swift:ApiService.swift
import UIKit

/// Watson VR APIを扱うクラス
class ApiService {

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

        // API呼び出し準備（※API Keyをapikey.plistに設定しておく必要があります。キー名は"apikey"。）
        var urlComponents = URLComponents(string: "https://gateway-a.watsonplatform.net/visual-recognition/api/v3/detect_faces")!
        guard let APIKey = fetchApiKey() else {
            print("API Key取得エラー")
            fatalError()
        }
        urlComponents.query = "api_key=" + APIKey + "&version=2016-05-20"
        guard let destURL = urlComponents.url else {
            print ("URLエラー： \n" + urlComponents.path)
            fatalError()
        }
        var request = URLRequest(url: destURL)
        request.httpMethod = "POST"
        request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
        // API仕様の画像サイズを超えないようにリサイズしてからAPIコールする
        // WatsonVR detect_faces APIの仕様により画像サイズは最大2MG（2016年12月現在）
        guard let resizedImage = image.fixedOrientation()?.resizeImage(maxSize: 2097152) else {
            print("画像リサイズエラー")
            fatalError()
        }
        request.httpBody = UIImageJPEGRepresentation(resizedImage, 1)

        // WatsonAPIコール
        let task = URLSession.shared.dataTask(with: request) {
            data, response, error in
            
            defer {
                // スコープを抜ける際に全タスクを終了してURLSessionを無効化
                URLSession.shared.finishTasksAndInvalidate()
            }
            
            if error == nil, let data = data {
                do {
                    // APIレスポンス：正常
                    let faceData = try JSONDecoder().decode(FaceData.self, from: data)
                    appDelegate.analyzedFaces = self.interpret(image: resizedImage, parsedData: faceData)
                } catch {
                    // JSONデコードエラー
                    print("JSONデコードエラー：\n" + error.localizedDescription)
                    fatalError()
                }
                // クロージャー実行
                completionHandler()
            } else {
                // APIレスポンスエラー
                print("APIレスポンスエラー：\n" + error.debugDescription)
                fatalError()
            }
            
        }
        task.resume()
        
    }
    
    /// 解析結果のJSONを解釈してAnalyzedFace型の配列で返す
    ///
    /// - parameter image: 元画像
    /// - parameter parsedData: パース後データ
    /// - returns: AnalyzedFace型の配列
    private func interpret(image: UIImage, parsedData: FaceData) -> [AnalyzedFace] {
        let parsedFaces = parsedData.images[0].faces
        // レスポンスのimageFaces要素は配列となっている（複数人が映った画像の解析が可能）。それを抽出しながらAnalyzedFace型の配列に変換する
        return parsedFaces.map {
            let analyzedFace = AnalyzedFace()
            // 性別およびスコア
            if $0.gender.gender == "MALE" {
                analyzedFace.gender = "男性"
            } else {
                analyzedFace.gender = "女性"
            }
            analyzedFace.genderScore = String(floor($0.gender.score * 1000) / 10)
            // 年齢およびスコア
            analyzedFace.ageMin = String($0.age.min)
            if let max = $0.age.max {
                analyzedFace.ageMax = String(max)
            }
            analyzedFace.ageScore = String(floor($0.age.score * 1000) / 10)
            // Identity
            if let identity = $0.identity?.name {
                analyzedFace.identity = identity
            }
            let left = $0.face_location.left
            let top = $0.face_location.top
            let width = $0.face_location.width
            let height = $0.face_location.height
            // 元画像から切り抜いて変数にセット
            analyzedFace.image = cropping(image: image, left: CGFloat(left), top: CGFloat(top), width: CGFloat(width), height: CGFloat(height))
            // 抽出完了
            return analyzedFace
        }
    }
    
    /// 元画像から矩形を切り抜く
    ///
    /// - parameter image: 元画像
    /// - parameter left: x座標
    /// - parameter top: y座標
    /// - parameter width: 幅
    /// - parameter height: 高さ
    /// - returns: UIImage
    private 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)
    }
    
    /// plistからWatson VRのAPIキーを取得する
    ///
    /// - Returns: APIキーの文字列
    private func fetchApiKey() -> String? {
        // API Keyをapikey.plistに設定しておく必要があります。キー名は"apikey"
        guard let keyFilePath = Bundle.main.path(forResource: "apikey", ofType: "plist") else {
            return nil
        }
        guard let keys = NSDictionary(contentsOfFile: keyFilePath) else {
            return nil
        }
        return keys["apikey"] as? String
    }

}
```

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

なお、このアプリはProof of Conceptのため、エラー処理、アンラップ、キャスト等のコードは緩慢です。
その点はご容赦ください。

## UIImage Extension

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

```swift:Extension.swift
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
    }
}
```

# Codableを使って見た感想

Codableを使うと、

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