20210221のiOSに関する記事は6件です。

【SwiftUI】APIとJSONで取得したものをList表示する方法【tutorial】

今回作成するアプリ

1.gif

はじめに

今回は初めてSwiftUIでAPIを叩いてJSONを取得し、List表示する方法をご紹介したいと思います。
よってこの記事は、SwiftUIは触ったことがあるが、APIやJSONは初めてと言う方向けの記事です。
尚、APIやJSONはなんぞやと言うことについては、様々な良記事が沢山ありますのでそちらを参照下さい。

環境

・ /macOS Big Sur 11.3
・ SwiftUI : 2.0
・ Xcode : 12.4

サンプルJSON

今回は初めてJSONを取得し、Listで表示させる為、オーソドックスな天気APIとかではなく、様々なJSONのサンプルが用意されているjsonplaceholderを使用してきます。
またなぜこの様なサンプルを使用するかと言うと、筆者がお天気APIなどのtutorialではなかなか使用の仕方が理解できず、こちらのサンプルを使用することにより、理解を深めることができたのでその様な方もいるのでは無いかと思い、今回はこちらのサンプルを使用した使用方法を紹介していきたいと思い書いている所存です。

jsonplaceholder

まず初めにこちらのリンクをクリックするとこの様な画面が表示されます。スクリーンショット 2021-02-20 23.49.52.jpg
今回はこちらのサイトでは様々なJSONサンプルが用意されているのでこちらの/postsを使用してList表示していきます。

jsonplaceholderを使用していく

jsonplaceholderは/posts、/comments、/albums、/photos、/todos、/usersに分かれており、それぞれに適したデータが用意されており、postならテキスト、その他には画像や人の写真などが用意されている。今回はテキストを抽出したいのでpostを使用していく。使用方法は下記の動画の通り進んでいき、postのURLを取得していきます。
7qm8o-79iiw.gif

まず初めにpostsではuserid,id,title,bodyがあり、今回id,title,bodyを使用していく為以下の構造体を作ります。

struct Post: Codable, Identifiable {
    var id = UUID()
    var title: String
    var body: String
}

次にApiのClassを作ります。

class Api {
    func getPosts(completion: @escaping ([Post]) -> ()) {
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else { return }
        URLSession.shared.dataTask(with: url) { (data, _, _) in
            let posts = try! JSONDecoder().decode([Post].self, from: data!)
            DispatchQueue.main.async {
                completion(posts)
            }
        }
        .resume()
    }
}

最後にViewを以下のように作ります。

struct JsonTestView: View {
    @State var posts: [Post] = []
    var body: some View {
        List(posts) { post in
            VStack(alignment: .leading) {
                Text(post.title)
                    .font(.system(.title, design: .rounded)).bold()
                Text(post.body)
            }
        }.onAppear{
                Api().getPosts { (posts) in
                    self.posts = posts
                }
            }
        }
    }

以上で取得できます!
1.gif

まとめ

とりあえず入門的な記事になれば良いかなと思ったので小難しいところは端折りましたが、是非ここからimageをurlで取得して表示するにはどうしたらいいのだろうか?とか自分でDB立てて、API叩くにはどうしたらいいのだろう?などに繋げていくきっかけになって頂けたらと思います!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Swift】Compact状態のDatePickerに戻る処理を行う

新しいDatePicker

iOS14からどうやらDatePickerの挙動が変わったらしく、今までのドラムロール式の日付・日時選択からカレンダー表示の様な選択方法が選べるようになりました(従来のDatePickerも引き続き使用出来る)。
しかしこのポップアップされるDatePickerは日付を選択時に一つ前に戻る機能が備わって居らず自分で書いて選択時に戻る処理を入れてあげる必要がある。
CompactDatePickerが表示されたiPhoneSimulator

コード

ViewController
@IBAction func CompactDatePicker(_ sender: UIDatePicker) {
    // 日付選択時に選択前の画面に戻る
    self.dismiss(animated: true, completion: nil)
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Multipeer ConnectivityでのP2P通信(Swift)

Xcode-12 Swift-5.3 iOS-14

はじめに

iOS の端末間で通信とかしたいなと思い Multipeer Connectivity について調べました。

ソース :point_right: GitHub

Multipeer Connectivityとは

Multipeer Connectivity は Wi-Fi と Bluetooth を使って iOS 端末間の近距離通信(ピア・ツー・ピア)ができるライブラリです(最大8個まで接続できる)。
Wi-Fi が OFF でも Bluetooth で接続できるのかと思いましたが、OFF だとアドバタイザーが見えないらしく ON にする必要があるようです(同一 Wi-Fi でなくても OK)。

接続の概要

接続の流れは下記のようになっています。

  1. MCPeerID を生成する。
    端末を識別する一意のもの
  2. MCSession を生成する。
    接続を管理するやつ
  3. MCNearbyServiceAdvertiser もしくは MCAdvertiserAssistant でアドバタイズする。
    他の端末から検知できるようにする
  4. MCNearbyServiceBrowser もしくは MCBrowserViewController で接続できる端末を探す。
  5. 発見した端末に対して招待を送る。
  6. 招待を受けた端末側で招待を許可すると接続が確立する。
  7. MCSession でデータの送受信を行う。
    データ受信は MCSessionDelegate を使う

各クラスの使い方

MCPeerID

MCPeerID

識別用の一意のもの。下記イニシャライザで displyaName(63 B 以下で UTF-8)を設定して生成する。
同名のものを設定しても別々の ID が生成される。空文字など異常値が設定されるとクラッシュする。

init(displayName: String)

アドバタイズや探索のたびに生成するのではなく同じものを利用するようにした方がよい(仮に advertiser と borowser に別の ID を設定すると自身が検知されてしまう)。

MCSession

MCSession

下記どちらかで生成する。

// identity: 証明書関連で使う(ちょっとわからない。。。)
// encryptionPreference: 通信を暗号化するかどうか
init(peer myPeerID: MCPeerID, securityIdentity identity: [Any]?, encryptionPreference: MCEncryptionPreference)

// encryptionPreferenceはrequiredになる
init(peer myPeerID: MCPeerID)

encryptionPreference は下記がある。

  • none: 暗号化しない
  • required: 暗号化する
  • optional: すべての端末が requiredoptional であれば暗号化する。
    none があれば暗号化しない

データ送信は下記を使う。

// Data型の送信
// mode: reliableなら送信順の保証、unreliableなら送信順は保証されないが即時送信
func send(_ data: Data, toPeers peerIDs: [MCPeerID], with mode: MCSessionSendDataMode) throws

// ファイル送信
// completionHandler: errorは成功時はnil
// 戻り値はキャンセルとかに使う
func sendResource(at resourceURL: URL, withName resourceName: String, toPeer peerID: MCPeerID, withCompletionHandler completionHandler: ((Error?) -> Void)? = nil) -> Progress?

// ストリーム?(ちょっとわからない。。。)
func startStream(withName streamName: String, toPeer peerID: MCPeerID) throws -> OutputStream

接続関連。

// 接続済み端末一覧
var connectedPeers: [MCPeerID]

// 接続を切断する
func disconnect()

// 手動接続の場合に利用する?(ちょっとわからない。。。)
func nearbyConnectionData(forPeer peerID: MCPeerID, withCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void)
func connectPeer(_ peerID: MCPeerID, withNearbyConnectionData data: Data)
func cancelConnectPeer(_ peerID: MCPeerID)

デリゲート(MCSessionDelegate)。

データ受信時などに UI を更新する場合はメインスレッドで処理するように注意。

// 接続状態変更通知(required)
// state: connected, notConnected, connectingがある
func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState)

// データ受信(required)
func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID)

// ファイル受信開始(required)
func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress)

// ファイル受信終了(required)
// localURL:一時ファイルのURLreturnするまでに保存とかしないと消える
func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?)

// バイトストリーム接続(required)
func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID)

// 証明書関連のやつ(optional)
func session(MCSession, didReceiveCertificate: [Any]?, fromPeer: MCPeerID, certificateHandler: (Bool) -> Void)

MCNearbyServiceAdvertiser

MCNearbyServiceAdvertiser

下記で生成する。serviceType は 1〜15 文字で、ASCII 小文字、数字、ハイフンが使えます(--のように連続したハイフンは不可)。空文字など異常値が設定されるとクラッシュする。

// discoveryInfo: アドバタイズ時に付加する情報
init(peer: MCPeerID, discoveryInfo: [String : String]?, serviceType: String)

アドバタイズ。

/// アドバタイズ開始
func startAdvertisingPeer()
/// アドバタイズ停止
func stopAdvertisingPeer()

デリゲート(MCNearbyServiceAdvertiserDelegate)。

// 招待を受けたときに呼ばれる(required)
// context: 招待相手の情報。ブラウザのinvitePeerで設定した値
// invitationHandler: 招待を許可するかどうか設定
func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void)

// アドバタイズ失敗時に呼ばれる(optional)
func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didNotStartAdvertisingPeer error: Error)

MCAdvertiserAssistant

原因はよくわかりませんが許可アラートが表示されず使えないようですとりあえず MCNearbyServiceAdvertiser を使えば接続できました。

MCAdvertiserAssistant

下記で生成する。

// discoveryInfo: アドバタイズ時に付加する情報
init(serviceType: String, discoveryInfo: [String : String]?, session: MCSession)

アドバタイズ。

// アドバタイズ開始
func start()
// アドバタイズ停止
func stop()

デリゲート(MCAdvertiserAssistantDelegate)。

// optional
func advertiserAssistantDidDismissInvitation(_ advertiserAssistant: MCAdvertiserAssistant)

// optional
func advertiserAssistantWillPresentInvitation(_ advertiserAssistant: MCAdvertiserAssistant)

MCNearbyServiceBrowser

MCNearbyServiceBrowser

細かい設定がいらない場合は MCBrowserViewController を使う方が楽。

下記で生成する。serviceType に設定できる値はアドバタイズで設定できるものと同様。

init(peer: MCPeerID, serviceType: String)

検索。

// 検索開始
func startBrowsingForPeers()
// 検索停止
func stopBrowsingForPeers()

招待。

// context: 招待相手に渡す情報。これで相手側で招待を受けるか判断したりする
// timeout: 秒単位で0以下を設定するとデフォルト値の30秒になる
func invitePeer(_ peerID: MCPeerID, to session: MCSession, withContext context: Data?, timeout: TimeInterval)

デリゲート(MCNearbyServiceBrowserDelegate)。

// 検索開始失敗に呼ばれる(optional)
func browser(_ browser: MCNearbyServiceBrowser, didNotStartBrowsingForPeers error: Error)

// 端末を発見したときに呼ばれる(required)
// info: アドバタイズで設定されているのdiscoveryInfo
func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?)

// 発見した端末ロスト時に呼ばれる(required)
// 対象の招待不可
func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID)

MCBrowserViewController

MCBrowserViewController

簡易に検索と画面表示をおこなってくれる ViewController。表示するときは push ではなく present を使う(Cancel, Done のナビゲーションバーがあるので push するとバーの下にバーが表示される)。iPad の場合はフルスクリーンではなく pageSheet?表示になるようです。

init(serviceType: String, session: MCSession)
init(browser: MCNearbyServiceBrowser, session: MCSession)

// 接続端末の最大値(デフォルト値と最大値は8)
var maximumNumberOfPeers: Int

// 接続端末の最小値(デフォルト値と最小値は2)
var minimumNumberOfPeers: Int

デリゲート(MCBrowserViewControllerDelegate)。

// 発見した端末を表示するかどうか設定する(optional)
// info: アドバタイズで設定されているのdiscoveryInfo
func browserViewController(_ browserViewController: MCBrowserViewController, shouldPresentNearbyPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) -> Bool

// Doneボタン押下時に呼ばれる(required)
func browserViewControllerDidFinish(_ browserViewController: MCBrowserViewController)

// Cancelボタン押下時に呼ばれる(required)
func browserViewControllerWasCancelled(_ browserViewController: MCBrowserViewController)

こんな感じです。発見したものが一覧表示されタップすると招待を送り相手側で許可されると Connected になります。

browser

簡易実装

iOS14 からは Info.plist に追記が必要です!
参考:iOS 14からMultipeer ConnectivityがErrorCode -72008で繋がらなくなったときの解決方法

実機で動かしてなかったんで気づかなかったのですが iOS14 からは下記のように Info.plist に追記が必要です(シミュレータだと勝手にアラートが出て接続できてた。。。)。

   <key>NSLocalNetworkUsageDescription</key>
    <string>ローカルネットワークを使う理由</string>
    <key>NSBonjourServices</key>
    <array>
        <string>_sample-chat._tcp</string>
        <string>_sample-chat._udp</string>
    </array>

NSBonjourServices に設定する値はアドバタイズや検索のときに使う serviceType のことで下記のような形式です。

_[serviceType]._tcp

下記のようなチャットアプリを作成します。

chat

こんな感じです。

import UIKit
import MultipeerConnectivity

final class ChatTableViewController: UITableViewController {

    private var messages = [String]()

    private let serviceType = "sample-chat"
    private var session: MCSession!
    private var advertiser: MCNearbyServiceAdvertiser!
    private var browser: MCNearbyServiceBrowser!

    override func viewDidLoad() {
        super.viewDidLoad()

        navigationItem.rightBarButtonItem = .init(title: "送信", style: .done, target: self, action: #selector(sendMessage(_:)))
        navigationItem.rightBarButtonItem?.isEnabled = false

        let peerID = MCPeerID(displayName: UIDevice.current.name)
        session = MCSession(peer: peerID)
        session.delegate = self

        advertiser = MCNearbyServiceAdvertiser(peer: peerID, discoveryInfo: nil, serviceType: serviceType)
        advertiser.delegate = self
        advertiser.startAdvertisingPeer()

        browser = MCNearbyServiceBrowser(peer: peerID, serviceType: serviceType)
        browser.delegate = self
        browser.startBrowsingForPeers()
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)

        // たまに切れない時があるのでここで切断
        browser.stopBrowsingForPeers()
        advertiser.stopAdvertisingPeer()
        session.disconnect()
    }

    @objc private func sendMessage(_ sender: Any) {
        let message = "\(session.myPeerID.displayName)からのメッセージ"
        do {
            try session.send(message.data(using: .utf8)!, toPeers: session.connectedPeers, with: .reliable)
            addMessage(message)
        } catch let error {
            print(error.localizedDescription)
        }
    }

    private func addMessage(_ message: String) {
        messages.append(message)
        tableView.beginUpdates()
        let indexPath = IndexPath(row: messages.count - 1, section: 0)
        tableView.insertRows(at: [indexPath], with: .automatic)
        tableView.endUpdates()
        tableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
    }
}

extension ChatTableViewController {

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return messages.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        cell.textLabel?.text = messages[indexPath.row]
        return cell
    }
}

extension ChatTableViewController: MCSessionDelegate {

    func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) {
        let message: String
        switch state {
        case .connected:
            message = "\(peerID.displayName)が接続されました"
        case .connecting:
            message = "\(peerID.displayName)が接続中です"
        case .notConnected:
            message = "\(peerID.displayName)が切断されました"
        @unknown default:
            message = "\(peerID.displayName)が想定外の状態です"
        }
        DispatchQueue.main.async {
            self.addMessage(message)
            self.navigationItem.rightBarButtonItem?.isEnabled = !self.session.connectedPeers.isEmpty
        }
    }

    func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
        guard let message = String(data: data, encoding: .utf8) else {
            return
        }
        DispatchQueue.main.async {
            self.addMessage(message)
        }
    }

    func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) {
        assertionFailure("非対応")
    }

    func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) {
        assertionFailure("非対応")
    }
d
    func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?) {
        assertionFailure("非対応")
    }
}

extension ChatTableViewController: MCNearbyServiceAdvertiserDelegate {

    func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) {
        invitationHandler(true, session)
    }
}

extension ChatTableViewController: MCNearbyServiceBrowserDelegate {

    func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) {
        guard let session = session else {
            return
        }
        browser.invitePeer(peerID, to: session, withContext: nil, timeout: 0)
    }

    func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) {
    }
}

1対1でやるだけなら接続した時点でアドバタイズと探索を停止させるべきかもしれない:thinking:

おまけ

sendResource を使えば下記のような画像の送受信もできます:v:

詳細はソースをどうぞ :point_right: GitHub

その他気になった点

アプリがバックグラウンドにいったとき

アプリがバックグラウンドになると、アドバタイズと検索を停止してセッションをすべて切断する模様。フォアグラウンドに復帰すると、自動的にアドバタイズと検索を再開するが、セッションの復帰はこちらでやる必要があるようです。

接続時のエラー

接続時に下記のようなログが表示されるが接続はできてる模様。

[GCKSession] Not in connected state, so giving up for participant [7AE9D215] on channel [0].

Wi-Fi必須?

Bluetooth で接続できるから端末の Wi-Fi 設定を OFF にしても接続できるのかと思いましたが Wi-Fi は ON にする必要があるようです(ソースは見つかってないです)。Wi-Fi の接続先は同一でなくてもいいようです。

Codableによるデータのやりとり

iOS12 と iOS13, 14 間で Codable のデータのやりとりができませんでした:frowning2:
下記のようにやってみると受信のところでエラーになりました(iOS13, 14 の場合は OK)。

enum Hoge: Int, Codable {
  case piyo
}

// 送信
func sendHoge(_ hoge: Hoge) {
    do {
        let data = try JSONEncoder().encode(hoge)
        try session.send(data, toPeers: session.connectedPeers, with: .reliable)
    } catch let error {
        print(error.localizedDescription)
    }
}

// 受信
func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
    do {
        let hoge = try JSONDecoder().decode(Hoge.self, from: data)
    } catch let error {
        // ここにはいる
        // The given data was not valid JSON.
        print(error.localizedDescription)
    }
}

TCP/UDP

iOS14 以降は Info.plist に追記が必要で _[serviceType]._tcp だけ追記していたんですが _[serviceType]._udp も必要かも?なくても接続できましたがいるのかも??(ちょっとよくわかってない。。。)

iOS14以降でたまに接続できない

iOS14 以降でたまに MCBrowserViewController で「接続中」の後「辞退」になることがありました。手元で再現せずストアのレビューで発生しリジェクトされました:frowning2:ちょっと原因はわからないです。。。

同じような人いました :eyes:
MultipeerConnectivity in SwiftUI?

iOS14以降でローカルネットワークの許可アラートが出ない

iOS14以降で NSLocalNetworkUsageDescription を追加していても許可アラートが出ないことがありました。でもなぜか接続はできました:see_no_evil:原因不明。。。

これかな:eyes:
NSLocalNetworkUsageDescription not displayed if provided via InfoPlist.string

手元では iOS14.1 は表示できて iOS14.4 は表示されませんでした。

おわりに

さくっと端末間の接続ができるのはすばらしい:clap:

ちょっと調べてると過去 OS バージョンではバグが多そうでしたが最近は安定してるのかどうなんだろう:innocent:
iOS 12.4.9(iPod touch 第6世代)、iOS 13.7(iPad 第5世代)、iOS 14.0(シミュレータ)で接続できるのは確認しました。

手動接続とストリームに関してはよくわかりませんでした!

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

M1 macでIPA processing failedを解決する

環境

  • m1チップのMacBook air
  • xcode12.3

症状

  • ipaを作成するためにarchiveする
  • Organizer画面からDistributeAppする
  • adhocを選択してnextをクリック
  • 次の画面に移動して1秒くらいでIPA processing failedと表示され、ipaの作成に失敗する
    スクリーンショット 2021-02-19 10.43.38.png

  • show logからIDEDistribution.standard.logを確認すると下記のようなlogがのこっている

description = "Configuration issue: platform AppleTVSimulator.platform doesn't have any non-simulator SDKs; ignoring it"

要因

iOS Deployment TargetがiOS13未満かつ、armネイティブのxcodeで行っていたことが要因

とはいえ、なぜm1 macでiOS13未満だとエラーとなるのかの根本的な原因は不明。誰かわかる人いたら教えてください。

解決方法

方法1

xcodeを一度終了し、finderからxcodeを右クリックして「情報をみる」から、「rossetaを利用」にチェックをいれてinetelアーキテクチャのxcodeでもう一度アーカイブから行う(もしかしたらアーカイブは必要なくてDistibuteAppだけでもよいかも)

方法2

iOS Deployment Targetを13以上にする
私の場合は、該当のプロジェクトのターゲットが11以上だったためこの方法は使えなかったため試していないが、参考した記事によるとこれでもいけるらしい

参考記事

MACM1を使用してXcode12.2で「IPA処理に失敗しました」を修正する方法

その他疑ったことたち

後書き

こんなん絶対わからないじゃん。無理じゃん。
m1 macはやっぱりまだ開発向きではない・・・ 情報が限られすぎてる。
電池持ちは圧倒的に快適なんだけどね

おもしろかったこと

参考にしたstackoverflowの記事内のコメント

->エラー「IPA処理に失敗しました」が表示される前に、ディスプレイ「Analyzing signnature」のロードプロセス中に、「次へ」ボタンをすばやく継続的にクリックしてエラーをスキップします。理由はわかりません。Appleが問題を解決できることを願っています。

たしかにこれで次の画面に進めたけどワロタ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vision+CoreML+Metal で信号機の点灯色を読み取ってみる

色の表現としてHSV色空間というものに触れる機会があったので、信号機の色を読み取ってみた。
信号機の画像認識には YOLOv3-Tiny のCore MLモデルを利用。
<完成イメージ>
demo.png demo.gif
※このgif画像は外で撮影した動画をモニタで再生し、それをiPhoneで撮影。

「色」をどう読み取るのか 〜HSV色空間について〜

業務でアプリ開発をしていて色の指定にはRGBを使っていて、
たまに見かける↓での色表現(HSV色空間)は 何が嬉しいのか?? と思っていた。

HSV色空間とは(Wikipediaより)

HSVモデル(英: HSV model)は色相(Hue)、彩度(Saturation・Chroma)、明度(Value・Brightness)の三つの成分からなる色空間。

 嬉しいことは色だけに色々あるようで、0~360度の色の輪(色相環)で表現される「色相(Hue)」を利用することで「赤っぽい色」「青っぽい色」といった人間の認識に近い処理ができる。

 ちなみに、AppleのCIImageのフィルターのドキュメント 『Applying a Chroma Key Effect』 ではクロマキー合成のため緑っぽい背景色を消すのに色相の108~144度で判定している。

 で、HSVを使って何かやるなら信号機読み取ってみるか、 ということで信号機が何色に点灯しているのかの判別をしてみた。以下、作成したサンプルプログラムについて解説します。

信号機の点灯色の識別

色相

 点灯色は次の色相で判定(家の近所の3つの信号機、かつ、天気の良い日中で判定できるように指定したので汎用性はないです)。

点灯色 色相 備考
140〜220度 「青」といっても「緑」に近い場合がある
5〜60度 「黄」もかなり「赤」に踏み込まないと認識せず。
340〜5度 比較的安定して認識できる
彩度

 色相だけだと信号機の枠の部分とかのランプ以外のところが 若干の青み があるだけで、青信号と認識してしまうので「彩度(Saturation)」を使って、しっかりとした青色 を判定できるようにしている。

彩度 備考
80%以上 【彩度とは】
色の鮮やかさの尺度(0~100%)。
白・グレー・黒(つまり、R = G = B)は鮮やかではないので 0%。
R, G, B すべて0ではないが、0があるのとき彩度が 100%

<彩度による判定効果の実例>
色相環(ここで撮影した色相環は中央に行くについれて白っぽくなる=彩度が落ちる)をiPhoneで撮影して、青・黄・赤の各色相を単色でぬり潰す加工(それ以外の色は黒)を行い、彩度別に表示範囲を絞ったものがこちら。

彩度
指定なし
彩度
30%以上を表示
彩度
80%以上を表示
s_0.PNG s_30.PNG s_80.PNG

 彩度の指定がない場合、何かしらの色に分類されてしまい、この例では白い部分が黄色に判定されている。彩度の大きな部分だけ表示することで、より鮮やかな部分だけ残る、という効果が確認できる。

色の読み取りステップ

  1. カメラ画像をキャプチャする
  2. 画像からオブジェクト(信号機)を識別する
  3. 信号機部分の画像だけ切り取る
  4. 3.にブラーをかけて画像内の色のばらつきを抑える
  5. 4.の画像の青信号・黄信号・赤信号の色区別する
  6. 何色が点灯しているのか判定する

1. カメラ画像をキャプチャする

これはAppleの『YOLOv3-Tinyのサンプルプログラム』と同じ。一点、キャプチャした画像からあとで画像の切り出しがしやすいようにCMSampleBuffer から CGImage に変換しておく。

if let pb = CMSampleBufferGetImageBuffer(sampleBuffer) {
    // 検出した信号機をキャプチャー画像の上に重ねて表示させるためCGImageにしてとっておく
    let ciImage = CIImage(cvPixelBuffer: pb)
    let pixelBufferWidth = CGFloat(CVPixelBufferGetWidth(pixelBuffer))
    let pixelBufferHeight = CGFloat(CVPixelBufferGetHeight(pixelBuffer))
    let imageRect:CGRect = CGRect(x: 0,y: 0,width: pixelBufferWidth, height: pixelBufferHeight)
    let ciContext = CIContext.init()

    // Metal側でピクセルフォーマットを BGRA8 にしているので、CGImage作る時も合わせておく。
    cgimage = ciContext.createCGImage(ciImage, from: imageRect, format: .BGRA8, colorSpace: nil)
}

ポイントは、あとでMetalで加工する際のピクセルフォーマットを BGRA にしているので、CGImageを作る際にピクセルフォーマットを合わせているところ。

2. 画像からオブジェクト(信号機)を識別する

これも『YOLOv3-Tinyのサンプルプログラム』と同様。ここでは信号機以外の認識情報は捨てる。

let objectRecognition = VNCoreMLRequest(model: model, completionHandler: { (request, error) in
    DispatchQueue.main.async(execute: {
        self.cropedCGImage = nil

        guard let results = request.results else { return }
        for observation in results {
            guard let objectObservation = observation as? VNRecognizedObjectObservation,
                  objectObservation.labels[0].identifier == "traffic light" else {
                // 信号機以外の認識画像は捨てる
                continue
            }
// 略

3. 信号機部分の画像だけ切り取る

1.で取得しておいたキャプチャ画像から信号機部分の画像を切りだす。

// 信号機部分の画像だけ切り出す
let objectBounds = VNImageRectForNormalizedRect(objectObservation.boundingBox, self.videoWidth, self.videoHeight)
self.cropedCGImage = self.cgimage?.cropping(to: objectBounds)

あと、Metalで加工するため、MTLTexture に変換しておく。

let textureLoader = MTKTextureLoader(device: device)
// CGImageからMTLTexture作る時はusage指定が必要。
let usage = MTLTextureUsage.renderTarget.rawValue | MTLTextureUsage.shaderRead.rawValue | MTLTextureUsage.shaderWrite.rawValue
let texture = try? textureLoader.newTexture(cgImage: cropped,
      options: [.textureUsage : NSNumber.init(value:usage)])

ポイントはMTKTextureLoaderによる CGImage->MTLTextureへの変換時に、usage を設定するところ。これを設定しないと「usageを設定しろ」と実行時におこられる。shaderRead はシェーダー側で読めるようにする、renderTarget は色の情報を取り扱えるようにする、という指定。shaderWrite は、このあとここで作成したMTLTextureに対し、Metal Performance Shadersでブラーをかけて書き込みをするので指定が必要。

4. 3.にブラーをかけて画像内の色のばらつきを抑える

このサンプルでの点灯色の判定方法は後述するが、画像上、いくつかの「点」の色を調べて、点灯色を判定している。LED信号機のように点が密集していたり、色のノイズがあると、判定精度が落ちるので、ブラーをかけて判定精度を上げる。

// LED信号機のつぶつぶを潰して信号判定精度を上げる
let blurKernek = MPSImageGaussianBlur(device: device, sigma: 2)
blurKernek.encode(commandBuffer: commandBuffer,
                  inPlaceTexture: &texture, fallbackCopyAllocator: nil)

ブラーの強さは適当。

5. 4.の画像の青信号・黄信号・赤信号の色区別する

ここはMetalのコンピュートシェーダーで処理。入力となるMTLTexture(信号機画像)から、色相・彩度により青信号は青(R,G,B=0,0,1)、黄信号は黄色(R,G,B=1,1,0)、赤信号は赤(R,G,B=1,0,0)に変換したMTLTextureを出力。

Shader.metal
// 色相・彩度に応じて、色を青・黄・青に塗りつぶす
kernel void colorClassify(texture2d<half, access::read> inTexture [[ texture(0) ]],
                          texture2d<half, access::write> outTexture [[ texture(1) ]],
                          uint2 gid [[thread_position_in_grid]]) {

    half3 color = inTexture.read(gid).rgb;

    half s = saturation(color.r, color.g, color.b);
    // 彩度が低い場合は判定NG
    if(!isGoodSaturation(s)) {
        outTexture.write(half4(0.0), gid);
        return;
    }

    half h = hue(color.r, color.g, color.b);
    if(isBlue(h)) {
        outTexture.write(half4(0.0, 0.0, 1.0, 1.0), gid);
        return;
    }
    if(isYellow(h)) {
        outTexture.write(half4(1.0, 1.0, 0.0, 1.0), gid);
        return;
    }
    if(isRed(h)) {
        outTexture.write(half4(1.0, 0.0, 0.0, 1.0), gid);
        return;
    }
    outTexture.write(half4(0.0), gid);
}

RGB→HSVへの変換は、こちらのサイト『RGBとHSVの相互変換[色見本/サンプル付き]』を参考にさせてもらいました。ちなみにwikiの「色相」を見るとこの変換方法は「大まかな値を求める」方法らしい。より正確な変換方法は、こちらのサイト『HSVの計算について』1 にある方法が良さそうだが、ちょっと面倒なのでスキップ。

ちなみに、
サンプルを作り始めたときは重い処理もあるだろうからと考えてMetalを使ったが、結局、識別した色を可視化するのに使っただけで、点灯色を把握するだけならMetalを使う必要なかった。可視化するにしても CIColorKernel がお手軽でよかったかも。

6. 何色が点灯しているのか判定する

ここが一番悩ましかった。手法は無数にあるような気がするが、この辺り詳しくないので、最低限、次を実現できる方法を考えた。

  • 処理が軽いこと。
  • 青空の影響を受けにくいこと。

で、実装したのは次のように信号機の縦中央・横に7箇所の色を取得して、色の多数決で点灯色を判定する、と言う方法(7箇所にしたのは適当)。
<判定箇所のイメージ>
imga.png

これなら青空の影響(青信号の誤判定)を避けやすく、かつ、判定処理も軽め。

// MTLTextureをUInt8の配列でアクセスできるようにする
let bytesPerPixel: Int = 4
let imageByteCount = changedTexture.width * changedTexture.height * bytesPerPixel
let bytesPerRow = changedTexture.width * bytesPerPixel
var src = [UInt8](repeating: 0, count: Int(imageByteCount))
let region = MTLRegionMake2D(0, 0, changedTexture.width, changedTexture.height)
classifiedTexture.getBytes(&src, bytesPerRow: bytesPerRow, from: region, mipmapLevel: 0)

// 画像の(見た目の)縦中央の画素を、右から左に7箇所等間隔で色を取得し、多数決で点灯色を判定
var signalCount: [(UIColor, Int)] = [(.blue, 0), (.yellow, 0), (.red, 0)]
let step = changedTexture.height / 8
for y in stride(from: step, to: changedTexture.height - step, by: step) {
    // 【注意】①BGRAの順番に色があること ②横向き画像であるといこと
    let baseIndex = (y * changedTexture.width + (changedTexture.width / 2)) * bytesPerPixel
    if src[baseIndex + 0] != 0 {
        signalCount[0].1 += 1   // 青+1
    } else if src[baseIndex + 1] != 0 {
        signalCount[1].1 += 1   // 黄+1 ※黄色=赤+緑なので赤より先に判定すること。
    } else if src[baseIndex + 2] != 0 {
        signalCount[2].1 += 1   // 赤+1
    }
}

なお、MTLTextureから画素を取り出す方法については、こちらの記事『Metalシェーディング言語を使用したUIImageの加工』を参考にさせていただきました。

あと、上記の方法だけだと、信号機の画像認識ができなかったり、色のノイズで誤判定されたり、信号機を撮影するとフリッカーがでたり、で安定して認識できなかったので、過去10回の個別の点灯色の認識結果の中で一番多く認識できた色=点灯色、という判定も入れた。

if signalHistory.count > 10 {
    // 過去の判定結果は10個まで
    signalHistory.removeFirst()
}

var signalCount: [(UIColor, Int)] = [(.blue, 0), (.yellow, 0), (.red, 0)]
signalHistory.forEach { signal in
    switch signal {
    case .blue:   signalCount[0].1 += 1
    case .yellow: signalCount[1].1 += 1
    case .red:    signalCount[2].1 += 1
    default: break
    }
}

if let signal = signalCount.max(by: { $0.1 < $1.1 }) {
    return signal.0
} else {
    return .clear
}

いろいろと条件が整っているときには点灯色は読み取れている。
(安定して認識できないケースは多々ある。最後に課題を記載)

最後に

目的がHSV色空間を使って何かする、、なので目的は達成したが、真剣に信号機の色を読み取るとなると課題は多い。思いつく課題は次の通り。

  1. そもそも信号機の画像認識精度がよくない(YOLOv3-Tinyは日本の信号機で学習されてないのでしょう)。日本の信号機を使って、信号機だけで学習すれば改善されそう。
  2. 色の判定を、ベタに色で判定した。これだと、夜とか、逆光とか撮影条件による影響が大きいように思われる。撮影条件×各色を含めた信号機の機械学習されたモデルがあれば、色で判定しなくていいかもしれない。画像判定前(&トレーニングデータ画像)に適切な前処理は必要そう。
  3. 撮影すると信号機って結構小さい。信号機を識別させるにはより信号機が大きく見える条件付けが必要かも。例えば、画像の上半分にしか信号機は存在しない前提を置くとか。

全体ソースコード

ViewController.swift
import UIKit
import AVFoundation
import Vision
import Metal
import MetalKit
import MetalPerformanceShaders

class ViewController: UIViewController {

    @IBOutlet weak var preview: UIView!
    @IBOutlet weak var lblColor: UILabel!

    // キャプチャ画像サイズ
    private var videoWidth = 0
    private var videoHeight = 0
    // キャプチャ画像の入出力
    private var rootLayer: CALayer! = nil
    private let session = AVCaptureSession()
    private let videoDataOutput = AVCaptureVideoDataOutput()
    private let videoDataOutputQueue = DispatchQueue(label: "VideoDataOutput",
                                                     qos: .userInitiated,
                                                     attributes: [],
                                                     autoreleaseFrequency: .workItem)
    private var previewLayer: AVCaptureVideoPreviewLayer! = nil

    private var request: VNCoreMLRequest?
    // 信号機画像を加工した画像を乗せるレイヤー
    private var detectionOverlay: CALayer! = nil
    private var cgimage: CGImage?
    // Metal
    private let device = MTLCreateSystemDefaultDevice()!
    private lazy var commandQueue = device.makeCommandQueue()!
    // 描画用
    private var pipelineState: MTLRenderPipelineState!
    private var imagePlaneVertexBuffer: MTLBuffer!
    private var timer: CADisplayLink!
    private var metalLayer: CAMetalLayer!
    // 色分類用
    private var colorClassComputeState: MTLComputePipelineState!
    private var threadgroupSize = MTLSizeMake(16, 16, 1)
    private var classifiedTexture: MTLTexture!
    // Metal 画像頂点座標(x,y), uv座標(u,v)
    private let kImagePlaneVertexData: [Float] = [
        -1.0, -1.0, 0.0, 1.0,
        1.0, -1.0, 1.0, 1.0,
        -1.0, 1.0, 0.0, 0.0,
        1.0, 1.0, 1.0, 0.0
    ]
    // 信号機部分を切り出した画像
    var cropedCGImage: CGImage?
    // 点灯色の切り出し記録。チラつき抑止のために使う。
    var signalHistory: [UIColor] = []

    override func viewDidLoad() {
        super.viewDidLoad()

        setupAVCapture()
        setupLayers()
        setupVision()
        setupMetal()
        self.view.bringSubviewToFront(lblColor)
        // キャプチャ開始
        session.startRunning()
        // CAMetalLayerを使うので描画タイミングを生成
        timer = CADisplayLink(target: self, selector: #selector(ViewController.drawLoop))
        timer.add(to: RunLoop.main, forMode: .default)
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        // 画像認識は 640x480 で行うのでLayer全体を右90度回転する
        let bounds = rootLayer.bounds
        let xScale = bounds.size.width / CGFloat(videoHeight)
        let yScale = bounds.size.height / CGFloat(videoWidth)
        let scale = fmax(xScale, yScale)
        detectionOverlay.setAffineTransform(CGAffineTransform(rotationAngle: CGFloat(.pi / 2.0)).scaledBy(x: scale, y: scale))
        detectionOverlay.position = CGPoint(x: bounds.midX, y: bounds.midY)
    }

    // CAMetalLayerに信号機画像を描画
    @objc func drawLoop() {
        guard let cropped = cropedCGImage else {
            metalLayer.isHidden = true
            return
        }
        metalLayer.isHidden = false
        autoreleasepool {
            let textureLoader = MTKTextureLoader(device: device)
            // CGImageからMTLTexture作る時はusage指定が必要。
            let usage = MTLTextureUsage.renderTarget.rawValue | MTLTextureUsage.shaderRead.rawValue | MTLTextureUsage.shaderWrite.rawValue
            let texture = try? textureLoader.newTexture(cgImage: cropped,
                  options: [.textureUsage : NSNumber.init(value:usage)])

            // 切り抜いた信号機画像を点灯色が目立つように加工
            guard let changedTexture = changeColor(texture: texture) else {
                // 画像変換に失敗しているので点灯色識別結果に青・黄・赤以外を設定
                signalHistory.append(.clear)
                return
            }

            // MTLTextureをUInt8の配列でアクセスできるようにする
            let bytesPerPixel = 4
            let imageByteCount = changedTexture.width * changedTexture.height * bytesPerPixel
            let bytesPerRow = changedTexture.width * bytesPerPixel
            var src = [UInt8](repeating: 0, count: Int(imageByteCount))
            let region = MTLRegionMake2D(0, 0, changedTexture.width, changedTexture.height)
            classifiedTexture.getBytes(&src, bytesPerRow: bytesPerRow, from: region, mipmapLevel: 0)

            // 画像の(見た目の)縦中央の画素を、右から左に7箇所等間隔で色を取得し、多数決で点灯色を判定
            var signalCount: [(UIColor, Int)] = [(.blue, 0), (.yellow, 0), (.red, 0)]
            let step = changedTexture.height / 8
            for y in stride(from: step, to: changedTexture.height - step, by: step) {
                // 【注意】①BGRAの順番に色があること ②横向き画像であるといこと
                let baseIndex = (y * changedTexture.width + (changedTexture.width / 2)) * bytesPerPixel
                if src[baseIndex + 0] != 0 {
                    signalCount[0].1 += 1   // 青+1
                } else if src[baseIndex + 1] != 0 {
                    signalCount[1].1 += 1   // 黄+1 ※黄色=赤+緑なので赤より先に判定すること。
                } else if src[baseIndex + 2] != 0 {
                    signalCount[2].1 += 1   // 赤+1
                }
            }

            if let signal = signalCount.max(by: { $0.1 < $1.1 }), signal.1 > 0 {
                signalHistory.append(signal.0)
            } else {
                signalHistory.append(.clear)
            }

            // 過去10回の点灯色認識結果の中で一番多い点灯色を採用
            let detectedColor = detectedColorWithouitNoise()
            lblColor.text = detectedColor.jaText
            lblColor.backgroundColor = detectedColor
            lblColor.textColor = detectedColor.textColor

            // 加工済み信号機画像を描画
            drawSignal()
        }
    }

    private func detectedColorWithouitNoise() -> UIColor {
        if signalHistory.count > 10 {
            // 過去の判定結果は10個まで
            signalHistory.removeFirst()
        }

        var signalCount: [(UIColor, Int)] = [(.blue, 0), (.yellow, 0), (.red, 0)]
        signalHistory.forEach { signal in
            switch signal {
            case .blue:   signalCount[0].1 += 1
            case .yellow: signalCount[1].1 += 1
            case .red:    signalCount[2].1 += 1
            default: break
            }
        }

        if let signal = signalCount.max(by: { $0.1 < $1.1 }) {
            return signal.0
        } else {
            return .clear
        }
    }

    private func changeColor(texture: MTLTexture?) -> MTLTexture? {
        guard var texture = texture else { return nil }
        let commandBuffer = self.commandQueue.makeCommandBuffer()!

        // LED信号機のつぶつぶを潰して信号判定精度を上げる
        let blurKernek = MPSImageGaussianBlur(device: device, sigma: 2)
        blurKernek.encode(commandBuffer: commandBuffer,
                          inPlaceTexture: &texture, fallbackCopyAllocator: nil)

        // 色相・彩度により信号機画像を青・黄・赤の単色に塗りつぶす
        let computeEncoder = commandBuffer.makeComputeCommandEncoder()!
        let colorDesc = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .bgra8Unorm,
                                                                 width: texture.width, height: texture.height, mipmapped: false)
        colorDesc.usage = [.shaderRead, .shaderWrite]
        classifiedTexture = device.makeTexture(descriptor: colorDesc)

        let threadCountW = (texture.width + self.threadgroupSize.width - 1) / self.threadgroupSize.width
        let threadCountH = (texture.height + self.threadgroupSize.height - 1) / self.threadgroupSize.height
        let threadgroupCount = MTLSizeMake(threadCountW, threadCountH, 1)

        computeEncoder.setComputePipelineState(colorClassComputeState)
        computeEncoder.setTexture(texture, index: 0)
        computeEncoder.setTexture(classifiedTexture, index: 1)
        computeEncoder.dispatchThreadgroups(threadgroupCount, threadsPerThreadgroup: threadgroupSize)
        computeEncoder.endEncoding()
        commandBuffer.commit()
        commandBuffer.waitUntilCompleted()

        return classifiedTexture
    }

    private func drawSignal() {
        guard let drawable = metalLayer.nextDrawable() else { return  }

        let commandBuffer = self.commandQueue.makeCommandBuffer()!

        let renderPassDescriptor = MTLRenderPassDescriptor()
        renderPassDescriptor.colorAttachments[0].texture = drawable.texture
        renderPassDescriptor.colorAttachments[0].loadAction = .clear
        renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColorMake(0.0, 0.0, 0.0, 1.0)

        let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor)!
        renderEncoder.setCullMode(.none)
        renderEncoder.setRenderPipelineState(pipelineState)
        renderEncoder.setVertexBuffer(imagePlaneVertexBuffer, offset: 0, index: 0)
        renderEncoder.setFragmentTexture(classifiedTexture, index: 0)
        renderEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4)
        renderEncoder.endEncoding()

        commandBuffer.present(drawable)
        commandBuffer.commit()
        commandBuffer.waitUntilCompleted()
    }
}

extension ViewController {

    private func setupAVCapture() {
        let videoDevice = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera],
                                                           mediaType: .video,
                                                           position: .back).devices.first
        guard let deviceInput = try? AVCaptureDeviceInput(device: videoDevice!) else { return }
        // capture セッション セットアップ
        session.beginConfiguration()
        session.sessionPreset = .vga640x480

        // 入力デバイス指定
        session.addInput(deviceInput)

        // 出力先の設定
        session.addOutput(videoDataOutput)
        videoDataOutput.alwaysDiscardsLateVideoFrames = true
        videoDataOutput.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange)]
        videoDataOutput.setSampleBufferDelegate(self, queue: videoDataOutputQueue)
        let captureConnection = videoDataOutput.connection(with: .video)
        captureConnection?.isEnabled = true

        // ビデオの画像サイズ取得
        try? videoDevice!.lockForConfiguration()
        let dimensions = CMVideoFormatDescriptionGetDimensions((videoDevice?.activeFormat.formatDescription)!)
        videoWidth = Int(dimensions.width)
        videoHeight = Int(dimensions.height)
        videoDevice!.unlockForConfiguration()

        session.commitConfiguration()

        // プレビューセットアップ
        previewLayer = AVCaptureVideoPreviewLayer(session: session)
        previewLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill
        rootLayer = preview.layer
        previewLayer.frame = rootLayer.bounds
        rootLayer.addSublayer(previewLayer)
    }

    private func setupVision() {
        guard let model = try? VNCoreMLModel(for: YOLOv3Tiny(configuration: MLModelConfiguration()).model) else { return }

        let objectRecognition = VNCoreMLRequest(model: model, completionHandler: { (request, error) in
            DispatchQueue.main.async(execute: {
                self.cropedCGImage = nil

                guard let results = request.results else { return }
                for observation in results {
                    guard let objectObservation = observation as? VNRecognizedObjectObservation,
                          objectObservation.labels[0].identifier == "traffic light" else {
                        // 信号機以外の認識画像は捨てる
                        continue
                    }

                    // 信号機部分の画像だけ切り出す
                    let objectBounds = VNImageRectForNormalizedRect(objectObservation.boundingBox, self.videoWidth, self.videoHeight)
                    self.cropedCGImage = self.cgimage?.cropping(to: objectBounds)
                    // 信号機画像(加工済み)を表示する位置を設定する
                    self.metalLayer.bounds = objectBounds
                    self.metalLayer.position = CGPoint(x: objectBounds.midX, y: objectBounds.midY)
                    break
                }
            })
        })
        request = objectRecognition
    }

    private func setupLayers() {
        // CAMetalLayerを乗せるレイヤー。あとで全体を90度回転して使う。
        detectionOverlay = CALayer()
        detectionOverlay.bounds = CGRect(x: 0.0,
                                         y: 0.0,
                                         width: CGFloat(videoWidth),
                                         height: CGFloat(videoHeight))
        detectionOverlay.position = CGPoint(x: rootLayer.bounds.midX, y: rootLayer.bounds.midY)
        rootLayer.addSublayer(detectionOverlay)
        // Metalで作ったMTLTextureの描画レイヤー
        metalLayer = CAMetalLayer()
        metalLayer.device = device
        metalLayer.pixelFormat = .bgra8Unorm
        detectionOverlay.addSublayer(metalLayer)
    }

    private func setupMetal() {
        // 頂点座標バッファ確保&頂点情報流し込み
        let vertexDataCount = kImagePlaneVertexData.count * MemoryLayout<Float>.size
        imagePlaneVertexBuffer = device.makeBuffer(bytes: kImagePlaneVertexData, length: vertexDataCount, options: [])
        // シェーダー指定
        let defaultLibrary = device.makeDefaultLibrary()!
        let pipelineStateDescriptor = MTLRenderPipelineDescriptor()
        pipelineStateDescriptor.vertexFunction = defaultLibrary.makeFunction(name: "imageVertex")!
        pipelineStateDescriptor.fragmentFunction = defaultLibrary.makeFunction(name: "imageFragment")!
        pipelineStateDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
        pipelineStateDescriptor.sampleCount = 1

        try? pipelineState = device.makeRenderPipelineState(descriptor: pipelineStateDescriptor)

        // 画像分類色設定
        let labelConvertShader = defaultLibrary.makeFunction(name: "colorClassify")!
        colorClassComputeState = try! self.device.makeComputePipelineState(function: labelConvertShader)
    }
}

extension ViewController: AVCaptureVideoDataOutputSampleBufferDelegate {
    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        // 信号機画像の検出開始
        guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
        let imageRequestHandler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer,
                                                        orientation: .up,
                                                        options: [:])
        guard let request = self.request else { return }
        try? imageRequestHandler.perform([request])

        // 検出した信号機をキャプチャー画像の上に重ねて表示させるためCGImageにしてとっておく
        guard let pb = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
        let ciImage = CIImage(cvPixelBuffer: pb)
        let pixelBufferWidth = CGFloat(CVPixelBufferGetWidth(pixelBuffer))
        let pixelBufferHeight = CGFloat(CVPixelBufferGetHeight(pixelBuffer))
        let imageRect:CGRect = CGRect(x: 0,y: 0,width: pixelBufferWidth, height: pixelBufferHeight)
        let ciContext = CIContext.init()

        // Metal側でピクセルフォーマットを BGRA8 にしているので、CGImage作る時も合わせておく。
        cgimage = ciContext.createCGImage(ciImage, from: imageRect, format: .BGRA8, colorSpace: nil)
    }
}

extension UIColor {
    var jaText: String {
        switch self {
        case .blue: return "青"
        case .yellow: return "黄"
        case .red: return "赤"
        default: return ""
        }
    }

    var textColor: UIColor {
        switch self {
        case .blue: return .white
        case .yellow: return .black
        case .red: return .white
        default: return .black
        }
    }
}
Shader.metal
#include <metal_stdlib>
using namespace metal;

typedef struct {
    float2 position;
    float2 texCoord;
} ImageVertex;

typedef struct {
    float4 position [[position]];
    float2 texCoord;
} ColorInOut;

vertex ColorInOut imageVertex(const device ImageVertex* vertices [[ buffer(0) ]],
                              unsigned int vid [[ vertex_id ]]) {
    ColorInOut out;
    const device ImageVertex& cv = vertices[vid];
    out.position = float4(cv.position, 0.0, 1.0);
    out.texCoord = cv.texCoord;
    return out;
}

// rgb -> 色相変換
half hue(half r, half g, half b) {
    half rgbMax = max3(r, g, b);
    half rgbMin = min3(r, g, b);
    if(rgbMax == rgbMin) {
        return 0;
    } else {
        half hue;
        if(rgbMax == r) {
            hue = 60.0 * (g - b) / (rgbMax - rgbMin);
        } else if(rgbMax == g) {
            hue = 60.0 * (b - r) / (rgbMax - rgbMin) + 120;
        } else {
            hue = 60.0 * (r - g) / (rgbMax - rgbMin) + 240;
        }
        if(hue < 0) {
            hue += 360;
        }
        return hue;
    }
}

// rgb -> 彩度変換
half saturation(half r, half g, half b) {
    half rgbMax = max3(r, g, b);
    half rgbMin = min3(r, g, b);

    if(rgbMax == rgbMin) {
        return 0;
    } else {
        return (rgbMax - rgbMin) / rgbMax;
    }
}

bool isBlue(half hue) {
    return hue > 140 && hue < 220;
}

bool isYellow(half hue) {
    return hue > 5 && hue < 60;
}

bool isRed(half hue) {
    return hue < 5 || hue > 340;
}

bool isGoodSaturation(half saturation) {
    return saturation > 0.8;
}

fragment half4 imageFragment(ColorInOut in [[ stage_in ]],
                             texture2d<half, access::sample> signalTexture [[ texture(0) ]]) {
    constexpr sampler colorSampler(mip_filter::linear,
                                   mag_filter::linear,
                                   min_filter::linear);

    half4 color = signalTexture.sample(colorSampler, in.texCoord.xy);
    return color;
}

// 色相・彩度に応じて、色を青・黄・青に塗りつぶす
kernel void colorClassify(texture2d<half, access::read> inTexture [[ texture(0) ]],
                          texture2d<half, access::write> outTexture [[ texture(1) ]],
                          uint2 gid [[thread_position_in_grid]]) {

    half3 color = inTexture.read(gid).rgb;

    half s = saturation(color.r, color.g, color.b);
    // 彩度が低い場合は判定NG
    if(!isGoodSaturation(s)) {
        outTexture.write(half4(0.0), gid);
        return;
    }

    half h = hue(color.r, color.g, color.b);
    if(isBlue(h)) {
        outTexture.write(half4(0.0, 0.0, 1.0, 1.0), gid);
        return;
    }
    if(isYellow(h)) {
        outTexture.write(half4(1.0, 1.0, 0.0, 1.0), gid);
        return;
    }
    if(isRed(h)) {
        outTexture.write(half4(1.0, 0.0, 0.0, 1.0), gid);
        return;
    }
    outTexture.write(half4(0.0), gid);
}

参考URL


  1. 最初、wikiにあるatan()使う方法で作り始めたが正しく変換ができないのに気がついて、このサイトに辿り着いた 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[tf2.x] VisionTransformer(ViT)を用いて画像分類(ついでにiOSで動かしてみる)

はじめに

この記事ではVisionTransformer(ViT)を用いて画像分類を行います。
ViTって何?って方はこちらの記事が参考になるかと思います。

簡単にいうと自然言語処理等で猛威を奮っているTransformerを画像に応用したものです。自分は画像の研究はしていませんでしたが、自然言語処理においてRNNが駆逐されていったのと同じように画像でもCNNが駆逐されていく日も近いのかもしれません。

以前はInceptionV3というものを使って遊びましたが、今回はViTを使って遊んでみたいと思います。

ViTのメリット・デメリット

InceptionV3を用いた画像分類と比較するとメリットは以下の通りだと思います。

  • CNNを用いずTransformerのみで計算を行うため、より高速に推論が可能
  • いろんなタスクでSoTAはViTがとってるらしい
  • 流行りそう

逆にデメリットは以下が挙げられると思います。

  • 空間使用量が(おそらく)大きい
    • 最終的にはiOS端末に乗せたいと考えているため、動かないと困る・・・
  • かなり新しいモデルのため情報がまだ少ない

実践!

今回の実験に用いるタスクは前回と同じ250種類の鳥の画像分類です。

まずViTを簡単に使うことができる vit-keras をインストールしましょう。
pip install vit-keras とかでインストールできます。詳しくは https://github.com/faustomorales/vit-keras

imagenetを用いてpre-trainしたものを以下のようにしてロードします。

ライブラリのバグなのか include_top=False にしていても pretrained_top=False を指定しないとエラーが出ました。

from vit_keras import vit

vit_model = vit.vit_l32(
    image_size=224,
    pretrained=True,
    include_top=False,
    pretrained_top=False,
)

モデルは以下のようにして定義します。
ViTの先端に1つ線形層をかませてsoftmaxを出力させるだけです。

finetune_at = 28

# 出力付近以外をフリーズ
for layer in vit_model.layers[:finetune_at - 1]:
    layer.trainable = False

# ノイズの追加
noise = GaussianNoise(0.01, input_shape=(224, 224, 3))

model = models.Sequential()
model.add(noise)
model.add(vit_model)
model.add(layers.Dense(num_classes, activation="softmax"))

また、学習率を動的に下げてより高い精度を狙いに行きます。
warm-upなど、いろいろ試しましたが、以下のシンプルなものが一番いい感じになりました。

# 7 epoch ごとに 0.1 かけする
def scheduler(epoch: int, lr: float) -> float:
    if epoch != 0 and epoch % 7 == 0:
        return lr * 0.1
    else:
        return lr
lr_scheduler_callback = tf.keras.callbacks.LearningRateScheduler(scheduler)

あとは以下のようにして学習させます。
trainのgeneratorやその他のコールバックは前回のものを参考にしてください。

history = model.fit(
          train_generator_augmented,
          epochs=100,
          validation_data=validation_generator,
          verbose=1, 
          shuffle=True,
          callbacks=[
              EarlyStopping(monitor='val_accuracy', patience=10, restore_best_weights=True),
              cp_callback,
              tb_callback,
              lr_scheduler_callback,
          ])

ピンクがViTで水色が前回のInceptionV3をファインチューニングしたモデルに同様のスケジューラやノイズを加えたものの結果になります。いずれもvalidationデータに対する結果です。

スクリーンショット 2021-02-21 3.34.24.png

スクリーンショット 2021-02-21 3.34.39.png

InceptionV3ベースのものでもなかなか健闘しており、Accuracyは最大0.98を示しています。
しかしViTベースのものが圧倒的に強いです。Accuracyは1epoch終わった時点でかなり高く、最大0.992を示しています。

さらにlossもViTのほうが低く、そして安定しています。
Accuracyが高くなることはもちろん重要なのですが、分類問題であるため、lossが低ければ低いほど、より高い確信度をもって当てに行っているため、より良いモデルができていると考えることができます。

実際、InceptionV3を用いたモデルをiOSに搭載し、カメラ画像から推論をさせると白い鳥ではない画像に対して99.8%のスコアで「シロフクロウ」であると言い張っていました。(すなわち鳥でない画像に簡単に反応してしまうモデルになってしまいました)
shiro_hukurou.gif

シロフクロウの参考画像(Wikipediaより) 確かに真っ白
500px-Snowy.owl.overall.arp.750pix.jpg

先にネタバレしてしまいますが、一方のViTベースのモデルは白い画像に対してもシロフクロウであると言わず、基本的に鳥ではない画像に対しては反応しなくなりました。つまりめっちゃいいモデルができてしまいました

tf-liteへ変換

さて、iOSに乗せることはできるのでしょうか?
先にネタバレしましたが、普通に乗りますし、普通に動きました(iPhone11 Proで実験)

変換は前回と同じ感じで大丈夫です。

まずはモデルのロードと最高のモデルでtestデータに対して推論させてみましょう。

checkpoint_file = "[一番いいモデルのパス]"

vit_model = vit.vit_l32(
    image_size=224,
    pretrained=True,
    include_top=False,
    pretrained_top=False
)

model = models.Sequential()
model.add(vit_model)
model.add(layers.Dense(num_classes, activation="softmax"))

model.compile(optimizer = optimizers.Adam(),
               loss = 'sparse_categorical_crossentropy',
               metrics = ['accuracy'])

model.load_weights(checkpoint_file)

model.trainable = False

loss, acc = model.evaluate_generator(test_generator)
print("Test Acc:", acc)

Test Acc: 0.9944000244140625

前回が0.988程度だったことを考えると凄まじい精度の向上ですね。
たかが0.5%、そう思ってませんか?(CV ケイスケ・ホンダ)

# saved_modelにする
model.save("saved_model/bird_model_vit")

import tensorflow as tf

tf.compat.v1.enable_eager_execution()

# Weight Quantization - Input/Output=float32
converter = tf.lite.TFLiteConverter.from_saved_model('./saved_model/bird_model_vit/')
converter.experimental_new_converter = True   #<--- Tensorflow v2.2.x以降を使用している場合は不要
converter.optimizations = [tf.lite.Optimize.OPTIMIZE_FOR_SIZE]
tflite_quant_model = converter.convert()
with open('./bird_model_vit.tflite', 'wb') as w:
    w.write(tflite_quant_model)
print("Weight Quantization complete! - bird_model_vit.tflite")

あとはこれをゴニョゴニョしてiOSに乗せます。
iOSで実際にどう使うかはこのあたりを参考にしました。
えー、かなりつらい道のり、かつ、難解なコードになってしまったため、ここでは伏せておきます。。。

モデルのサイズは以前のものと比べて約12倍になっているため、アプリ起動時のロードが長くなった気がします。

動作画面

tori_zukan.gif
早送りしてるとかじゃないですよ。
このようにリアルタイムで推論ができていることがわかると思います。
体感時間も以前のものより高速です。

実物の鳥でやりたかったのですが、手元にいないので諦めました。
この画像の鳥がデータセットにある可能性も考慮(おそらくKaggleのデータセットは画像検索から持ってきたものであると推測される)してYouTubeで可愛い鳥の動画とかを調べてやってみましたが、そこでもうまくいきました。

おわりに

ViTすげえ。速くて高精度、ホントにCNNも消えるかも・・・?
まさにAttention is All You Needですね!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む