【Swift】WatsonのAlchemyVision(画像解析)を使ってiOSアプリを作ってみた

# 追記： Dec-11. 2016

Alchemy Vision は、[Visual Recognition](http://www.ibm.com/watson/developercloud/doc/visual-recognition/) に統合されました。

# 追記： Dec-26. 2016

本稿のアプリを、Visual RecognitionおよびSwift 3.0を使用して、作り直してみました。
http://qiita.com/y-some/items/45e2c99b91485638e05b

# はじめに

<img src="https://qiita-image-store.s3.amazonaws.com/0/122917/a5787e45-0c70-ae55-c395-bd574dd0c14f.gif" width="400" alt="AlchemyTest_6s.gif">

[動画はこちら](https://youtu.be/j_CD3K_Fuik)

AlchemyVisionAPIは、AlchemyAPIの中の画像認識関連のAPIです。
AlchemyAPIには、その他にテキスト解読などの機能もあります。

元々はAlchemyAPI社が開発した技術で、2015年3月にIBMが買収しました。
現在はWatsonの機能の一つとしてBluemix上で提供されています。

AlchemyAPIの詳細とFree API key（無料トライアル）の取得方法は[こちらの記事](http://qiita.com/y_some/items/e637d30a835655894a59)をご参照ください。

今回は、顔認識のAPI「ImageGetRankedImageFaceTags」を使って、iOSアプリを作ってみました。
顔が含まれる画像を解析し、以下のような情報を返してくれるAPIです。

- 検出された顔の座標
- 性別と確率
- 年齢と確率
- 名前、職業などのタグ
- 複数人の場合は配列となっている

有名人ではない場合、名前とタグを除く項目が返ってきます。

性別・年齢の解析結果は結構正確で、自分、両親、妻、子供などの写真で試したところ、ほとんど正解でした。
ただし、自分の顔を送ったら"Naoto Kan"（菅直人）と名前が返ってきました。
今まで似てると言われたことないです（笑）

顔解析以外にもいろいろな画像解析APIがあります。
飲食店の口コミサービスで、写真から料理名を解析してタグ付けしたりとか、そんな使い道があるようです。
[AlchemyVisionAPIのリファレンス](https://watson-api-explorer.mybluemix.net/apis/alchemy-vision-v1)

Free API keyを使用する場合、1日あたり1000トランザクションという制限があります。
「リクエスト1000回」ではなくAPI内部のトランザクションです。
また商用利用のライセンスの扱いについては未調査です。

# APIのリクエスト・レスポンス

リクエストパラメータはシンプルです。→[AlchemyVisionAPIのリファレンス](https://watson-api-explorer.mybluemix.net/apis/alchemy-vision-v1)を参照
ただし、ボディに画像データをバイナリのままエンコードなしでセットする必要があるようですので注意が必要。
これは、curlコマンドの"--data-binary"オプションに相当します。

レスポンス（JSON）のサンプルは以下の通りです。

```
{
  "usage" : "By accessing AlchemyAPI or using information generated by AlchemyAPI, you are agreeing to be bound by the AlchemyAPI Terms of Use: http:\/\/www.alchemyapi.com\/company\/terms.html",
  "NOTICE" : "THIS API FUNCTIONALITY IS DEPRECATED AND HAS BEEN MIGRATED TO WATSON VISUAL RECOGNITION. THIS API WILL BE DISABLED ON MAY 19, 2017.",
  "status" : "OK",
  "totalTransactions" : "4",
  "imageFaces" : [
    {
      "gender" : {
        "score" : "0.99593",
        "gender" : "MALE"
      },
      "height" : "175",
      "positionX" : "200",
      "age" : {
        "score" : "0.43953",
        "ageRange" : "55-64"
      },
      "width" : "175",
      "positionY" : "65",
      "identity" : {
        "name" : "Barack Obama",
        "score" : "0.970688",
        "disambiguated" : {
          "website" : "http:\/\/www.whitehouse.gov\/",
          "yago" : "http:\/\/yago-knowledge.org\/resource\/Barack_Obama",
          "subType" : [
            "Person",
            "Politician",
            "President",
            "Appointer",
            "AwardWinner",
            "Celebrity",
            "PoliticalAppointer",
            "U.S.Congressperson",
            "USPresident",
            "TVActor"
          ],
          "dbpedia" : "http:\/\/dbpedia.org\/resource\/Barack_Obama",
          "name" : "Barack Obama",
          "freebase" : "http:\/\/rdf.freebase.com\/ns\/m.02mjmr"
        }
      }
    }
  ]
}
```

# 開発環境

Xcode7.3

# 使用ライブラリ

JSONを扱い易くしてくれるライブラリ「SwiftyJson」を使いました。
ライブラリ管理ツールとして「Carthage」を使いました。

APIのリクエストについて「Alamofire」を使ってみたのですが、上手くいかず諦めました。
POSTは正常にできているようですが、あれこれ試しても解析結果がnullとなってしまいました。
＃Alamofireはcurlコマンドの"--data-binary"に相当するオプションを持っていない？

最終的には[こちらの記事](http://qiita.com/Godai_Aoki/items/475c7eb344571cf25882)を参考にさせていただきました。

# ストーリーボード

<img width="1440" alt="スクリーンショット 2016-05-13 6.15.52.png" src="https://qiita-image-store.s3.amazonaws.com/0/122917/3fef60e9-c4bd-583c-749c-22a8186b040e.png">

### MainViewController

最初の画面です。

- フォトライブラリで選択された画像を表示するためのUIImageView
- フォトライブラリを起動するためのUIButton
- 解析（API連携）を開始するためのUIButton
- 解析待ち用UIActivityIndicatorView
- Segue: Identifier="next"

### SubViewController

解析結果画像を表示する画面です。

- 解析結果画像を表示するためのUIImageView
- 画像をズームするためのUIScrollView

# コード

エラーハンドリングは甘いと思います。
アーキテクチャーデザイン（MV?）も、大した規模ではないのであえて無視しています。

```lang:AppDelegate.swift
import UIKit

// 解析結果を格納するクラス
class AnalyzedFace {
    var height: String?
    var width: String?
    var positionX: String?
    var positionY: String?
    var gender: String?
    var genderScore: String?
    var ageRange: String?
    var ageScore: String?
    var name: String?
}

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

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

(以下略)
```

```lang:MainViewController.swift
import UIKit
import SwiftyJSON
import Photos

class MainViewController: UIViewController, UIImagePickerControllerDelegate, UINavigationControllerDelegate {

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

    // MARK: Event

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

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

    // MARK: Action
    
    /// 画像選択ボタンTap
    @IBAction func SelectPicButtonTapped(sender: AnyObject) {
        if UIImagePickerController.isSourceTypeAvailable(UIImagePickerControllerSourceType.PhotoLibrary) {
            let controller = UIImagePickerController()
            controller.delegate = self
            controller.sourceType = UIImagePickerControllerSourceType.PhotoLibrary
            self.presentViewController(controller, animated: true, completion: nil)
        }
    }
    
    /// 解析開始ボタンTap
    @IBAction func goButtonTaped(sender: UIButton) {
        if self.selectedImageView.image == nil {
            return
        }
        callAlchemyAPI(self.selectedImageView.image!)
    }
    
    // MARK: Delegate
    
    /// UIImagePickerControllerDelegate：画像選択時
    func imagePickerController(picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [String : AnyObject]) {
        picker.dismissViewControllerAnimated(true, completion: nil)
        guard let image = info[UIImagePickerControllerOriginalImage] else {
            return
        }
        self.selectedImageView.image = image as? UIImage
    }

    // MARK: Method
    
    /// AlchemyAPI連携
    /// - parameter image: 解析対象画像イメージ
    func callAlchemyAPI(image: UIImage) {
        let APIKey = "(AlchemyVisionのAPIKey)"
        let url = "https://gateway-a.watsonplatform.net/calls/image/ImageGetRankedImageFaceTags?imagePostMode=raw&outputMode=json&apikey=" + APIKey
        
        let destURL = NSURL(string: url)!
        
        // API仕様の画像サイズ（1MB）を超えないようにする
        let maxSize:Double = 1024 * 768
        var ratio: CGFloat = 1
        if Double(image.size.width * image.size.height) > maxSize {
            ratio = CGFloat(maxSize / Double(image.size.width * image.size.height))
        }        
        let imageData = UIImageJPEGRepresentation(image, ratio)
        
        let request = NSMutableURLRequest(URL: destURL)
        request.HTTPMethod = "POST"
        request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
        request.HTTPBody = imageData

        self.activityIndicator.startAnimating()
        
        let task = NSURLSession.sharedSession().dataTaskWithRequest(request) {
            data, response, error in
            
            if error == nil {
                let json = JSON(data: data!)
                print(json)
                
                // 解析結果はAppDelegateの変数を経由してSubViewに渡す
                let appDelegate: AppDelegate = UIApplication.sharedApplication().delegate as! AppDelegate
                appDelegate.analyzedFaces = []
                // レスポンスのimageFaces要素は配列となっている（複数人が映った画像の解析が可能）
                let facesJson = json["imageFaces"].arrayValue
                for faceJson in facesJson {
                    let face = AnalyzedFace()
                    face.height = faceJson["height"].string
                    face.width = faceJson["width"].string
                    face.positionX = faceJson["positionX"].string
                    face.positionY = faceJson["positionY"].string
                    face.gender = faceJson["gender"]["gender"].string
                    face.genderScore = faceJson["gender"]["score"].string
                    face.ageRange = faceJson["age"]["ageRange"].string
                    face.ageScore = faceJson["age"]["score"].string
                    if faceJson["identity"]["name"].string != nil {
                        face.name = faceJson["identity"]["name"].string
                    } else {
                        face.name = ""
                    }
                    appDelegate.analyzedFaces.append(face)
                }
                appDelegate.analyzedImage = UIImage.init(data: imageData!)
                
                // リクエストは非同期のため画面遷移をmainQueueで行わないとエラーになる
                NSOperationQueue.mainQueue().addOperationWithBlock(
                    {
                        self.activityIndicator.stopAnimating()
                        if appDelegate.analyzedFaces.count > 0 {
                            self.performSegueWithIdentifier("next", sender: self)
                        } else {
                            let actionSheet = UIAlertController(title:"エラー", message: "顔検出されませんでした", preferredStyle: UIAlertControllerStyle.Alert)
                            let actionCancel = UIAlertAction(title: "キャンセル", style: UIAlertActionStyle.Cancel, handler: {action in
                            })
                            actionSheet.addAction(actionCancel)
                            self.presentViewController(actionSheet, animated: true, completion: nil)
                        }
                    }
                )
            }
        }
        task.resume()
    }
    
 }

```

```lang:SubViewController.swift
import UIKit

class SubViewController: UIViewController, UIScrollViewDelegate {

    // 解析結果ImageViewの親であるScrollView
    @IBOutlet weak var resultScrollView: UIScrollView!
    // 解析結果画像のImageView
    @IBOutlet weak var resultImageView: UIImageView!
    
    // MARK: Event

    override func viewDidLoad() {
        super.viewDidLoad()
        self.resultScrollView.delegate = self
        self.resultScrollView.maximumZoomScale = 4.0
        self.resultScrollView.minimumZoomScale = 0.4
        self.setResult()
    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
    
    // MARK: Delegate

    /// UIScrollViewDelegate：解析結果ImageViewの親であるScrollViewのZoom時
    func viewForZoomingInScrollView(scrollView: UIScrollView) -> UIView? {
        return self.resultImageView
    }
    
    // MARK: Method
    
    /// 解析対象画像と解析結果（テキストと顔の矩形）を合成する
    func setResult() {
        
        // 解析結果はAppDelegateの変数を経由して受け取る
        let appDelegate: AppDelegate = UIApplication.sharedApplication().delegate as! AppDelegate
        let drawImage = appDelegate.analyzedImage!
        
        let imageWidth = appDelegate.analyzedImage!.size.width
        let imageHeight = appDelegate.analyzedImage!.size.height
        let rect = CGRectMake(0, 0, imageWidth, imageHeight)
        
        UIGraphicsBeginImageContext(appDelegate.analyzedImage!.size)
        drawImage.drawInRect(rect)

        let analyzedFaces = appDelegate.analyzedFaces
        var outputText: String
        let font = UIFont.boldSystemFontOfSize(30)
        let textStyle = NSMutableParagraphStyle.defaultParagraphStyle().mutableCopy() as! NSMutableParagraphStyle
        let textFontAttributes = [
            NSFontAttributeName: font,
            NSForegroundColorAttributeName: UIColor.orangeColor(),
            NSParagraphStyleAttributeName: textStyle
        ]

        for i in 0...analyzedFaces.count - 1 {
            // 顔の矩形描画
            let roundRect = UIBezierPath(
                roundedRect: CGRectMake(
                    CGFloat(Double(analyzedFaces[i].positionX!)!),
                    CGFloat(Double(analyzedFaces[i].positionY!)!),
                    CGFloat(Double(analyzedFaces[i].width!)!),
                    CGFloat(Double(analyzedFaces[i].height!)!)),
                cornerRadius: 10)
            UIColor.orangeColor().setStroke()
            roundRect.lineWidth = 6
            roundRect.stroke()
            
            // テキストの描画
            outputText = ""
            if let gender = analyzedFaces[i].gender {
                if gender == "MALE" {
                    outputText += "男性 "
                } else {
                    outputText += "女性 "
                }
            }
            if let genderScore = analyzedFaces[i].genderScore {
                let outputGenderScore: Double = floor(Double(genderScore)! * 1000) / 10
                outputText += "\(outputGenderScore)" + "%\n"
            }
            if let ageRange = analyzedFaces[i].ageRange {
                outputText += "\(ageRange)" + "才 "
            }
            if let ageScore = analyzedFaces[i].ageScore {
                let outputAgeScore: Double = floor(Double(ageScore)! * 1000) / 10
                outputText += "\(outputAgeScore)" + "%\n"
            }
            if let name = analyzedFaces[i].name {
                outputText += "\(name)"
            }
            let margin: Double = 10 //矩形とテキストのマージン
            let textRect = CGRectMake(
                CGFloat(Double(analyzedFaces[i].positionX!)!),
                CGFloat(Double(analyzedFaces[i].positionY!)! + Double(analyzedFaces[i].height!)! + margin),
                250,
                250)
            
            outputText.drawInRect(textRect, withAttributes: textFontAttributes)
        }
        
        let newImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        self.resultImageView.image = newImage
    }
}
```

# ソースファイル

https://github.com/y-some/AlchemyTest