2014/04/30

AndroidのGCMでニコ生アラートを受信してみる

ニコニコ生放送の通知が携帯のキャリアメールにしか届かないので Android で受け取る方法を調べてみた。

ニコ生アラート API

PCアプリ用のニコニコのサーバから生放送開始を受け取る WebAPI があるようです。

ざっくり説明すると

  1. http://live.nicovideo.jp/api/getalertinfo でサーバの IPアドレス と ポート番号 が XML で取れる。
  2. IPアドレス:ポート番号 に生ソケットで繋げて リクエストXML を1つ投げると生放送の開始が XML で落ちてくる。
    • a) ソケットは繋ぎっぱなしで生放送の開始は PUSH で落ちてくる。
    • b) ログインしても全放送の開始が PUSH されてくる。
  3. https://secure.nicovideo.jp/secure/login?site=nicolive_antenna でログインして
  4. http://live.nicovideo.jp/api/getalertstatus でユーザの参加コミュニティを取ってくる。
  5. 2 で PUSH されてくるデータを自前でフィルタリング。

となります。

ここで問題が1つ発生。
全放送の情報が落ちてくると10件/秒くらいのペースとなり、 Android にバックグラウンド処理させるのは無理が有りすぎます。

実際、Google Play 上がっている非公式のニコ生アラートのアプリは電池があっという間に無くなるとコメントが入っていました。

サーバを使う

放送開始の受信はサーバで行うしか無いと言うのが結論です。
幸い、家には 24時間稼働の RasipberryPi が有るのでここで受信とフィルタリングを行い必要な通知のみ GCM メッセージで Android に送ります。

Android アプリは GCM メッセージを受信した時に Android の通知に変換するサービスだけとなるので電池を消耗しません。

実装してみる

得に技術的に新しい事はしていないのでソースの解説とかははしょります。

ソースのSVNだけ置いておきます。

新しいトピックとしては GCM のサーバがバグっていてアプリのアンインストールに 対応できていないことが分かりました。(2014年4月現在)

どういう事かと言うとAndroid側でアプリがアンインストールされてもサーバにそれが通知されない為、 自動でユーザ登録の削除等が行えません。
一度、修正されたようですがその後ロールバックされた状態のようです。

もう一つ、GCM のメッセージは大きく遅延する事が有るのが分かりました。 通常は数秒で届きますが時々数分の遅延が発生します。 最大20分の遅延を確認しています。

実行結果

ユーザ登録します。

ちゃんと参加中のコミュニティの生放送の通知が届きました。

タップすると生放送のURLを起動するのでニコ動の公式アプリがインストールされていればそのまま生放送が見れます。

試して見たい方用に野良ビルドのアプリを上げて置きます。(ご利用は自己責任で)

デフォルトの設定で我が家の RaspberryPi に接続されるのでサーバは不要です。
※アンインストール前には必ず登録解除を行ってください。
※サーバは予告無く停止する事が有ります。ご了承ください。

所感

iPhone のニコ動公式アプリでは通知がサポートされているようなので Android でもいずれサポートされると思いますが 取り合えず今必要だったので作ってみました。


2014/04/25

AndroidのGoogleMapsAPIを試してみた

Android の Google Maps API v2 を試してみました。

API Key の取得

Google のサービスを使うときには付き物のサービスの有効化と API Key を取得します。

プロジェクトの作成の説明ははしょります。

プロジェクトに入ってメニューから「APIs」を選択し「Google maps Android API v2」を有効にします。

メニューから「Cedentails」を選択し「CREATE NEW KEY」をクリックします。

「Android Key」をクリックします。

テスト用なら fingerprint は空でも良いようです。 本番用はちゃんと記述しましょう。
「Create」をクリックすれば API Key が生成されます。

API Key をコピーして控えて置きます。



ライブラリの準備

SDK Manager から extra の 「Google Play services」 をインストールします。

Google Play services を eclipse のプロジェクトにインポートします。
※ jar ファイルのみ持ってくると x86 環境で ClassNotFound が発生するようです。

インポート元は「Existing Android Code into workspace」を選択します。
ローカルファイルを選択しないようにしてください。

プロジェクトのディレクトリは結構深い所にあります。

念のため workspace にコピーは有効にします。

開発プロジェクトのライブラリとして指定します。

開発の準備完了です。

開発コード

公式のサンプルは ${android-sdks}\extras\google\googleplayservices\samples\maps に大量に有ります。

Manifest

パーミッションは記述してある物は基本的に全部必要です。

meta-data 2つは必須です。
API_KEY には先に取得した API Key を指定してください。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="org.kotemaru.android.gmaptest"
    android:versionCode="1"
    android:versionName="1.0" >

    <uses-sdk android:minSdkVersion="8" android:targetSdkVersion="16" />

    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="com.google.android.providers.gsf.permission.READ_GSERVICES" />
    <!-- External storage for caching. -->
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> 
    <!-- My Location -->
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

    <application
        android:allowBackup="true"
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme" >
        <meta-data
            android:name="com.google.android.maps.v2.API_KEY"
            android:value="{Google Developer Console で取得したAPI Key}" />
        <meta-data
            android:name="com.google.android.gms.version"
            android:value="@integer/google_play_services_version" />

        <activity
            android:name="org.kotemaru.android.gmaptest.MainActivity"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

レイアウト

基本的に SupportMapFragment を使うようです。
GMapView を使うサンプルも有りましたが面倒なだけでした。

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >

    <fragment
        android:id="@+id/map"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        class="com.google.android.gms.maps.SupportMapFragment" />

</RelativeLayout>

ソース

最低限必要となりそうな 初期位置の設定、GPS連動、マーカーの設置 をやってみました。

package org.kotemaru.android.gmaptest;

import android.location.Location;
import android.os.Bundle;
import android.support.v4.app.FragmentActivity;
import android.widget.Toast;

import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GooglePlayServicesClient.ConnectionCallbacks;
import com.google.android.gms.common.GooglePlayServicesClient.OnConnectionFailedListener;
import com.google.android.gms.location.LocationClient;
import com.google.android.gms.location.LocationListener;
import com.google.android.gms.location.LocationRequest;
import com.google.android.gms.maps.CameraUpdateFactory;
import com.google.android.gms.maps.GoogleMap;
import com.google.android.gms.maps.GoogleMap.OnMarkerClickListener;
import com.google.android.gms.maps.SupportMapFragment;
import com.google.android.gms.maps.model.LatLng;
import com.google.android.gms.maps.model.Marker;
import com.google.android.gms.maps.model.MarkerOptions;

public class MainActivity extends FragmentActivity {
    private GoogleMap gMap;
    private LocationClient locationClient;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        setUpMapIfNeeded(); // サンプルに従っているがここでも呼ぶ意味は良く分からない。
    }

    @Override
    protected void onResume() {
        super.onResume();
        setUpMapIfNeeded();
        setUpLocation(true);
    }

    // ---------------------------------------------------------------------
    // マップ初期化処理
    private void setUpMapIfNeeded() {
        if (gMap == null) {
            gMap = ((SupportMapFragment) getSupportFragmentManager().findFragmentById(R.id.map))
                    .getMap();
            if (gMap != null) {
                setUpMap();
            }
        }
    }
    private void setUpMap() {
        // 初期座標、拡大率設定
        LatLng latLng = new LatLng(35.684699, 139.753897);
        float zoom = 13; // 2.0~21.0
        gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(latLng, zoom));

        // マーカー設置
        gMap.addMarker(new MarkerOptions().position(latLng).title("皇居"));
        gMap.setOnMarkerClickListener(new OnMarkerClickListener() {
            public boolean onMarkerClick(Marker marker) {
                // この marker は保存するとリークすると思われる。
                String msg = "Marker onClick:" + marker.getTitle();
                Toast.makeText(MainActivity.this, msg, Toast.LENGTH_LONG).show();
                return false;
            }
        });
    }

    // --------------------------------------------------------------------------------
    // 以下、GPS連動の設定。
    private void setUpLocation(boolean isManual) {
        if (isManual) {
            // 画面右上にGPSボタンが表示される。
            // タップすると現在地への移動までかってにやってくれる。
            gMap.setMyLocationEnabled(true);
        } else {
            // 現在地を定期的に取得する設定。
            if (locationClient == null) {
                locationClient = new LocationClient(
                        getApplicationContext(),
                        connectionCallbacks,
                        onConnectionFailedListener);
                locationClient.connect();
            }
        }
    }

    ConnectionCallbacks connectionCallbacks = new ConnectionCallbacks() {
        private final LocationRequest locationRequest = LocationRequest.create()
                .setInterval(5000)         // 5 seconds
                .setFastestInterval(5000)
                .setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY);

        @Override
        public void onConnected(Bundle arg0) {
            locationClient.requestLocationUpdates(locationRequest, locationListener);
        }
        @Override
        public void onDisconnected() {
            // nop.
        }
    };
    OnConnectionFailedListener onConnectionFailedListener = new OnConnectionFailedListener() {
        @Override
        public void onConnectionFailed(ConnectionResult connectionResult) {
            // nop.;
        }
    };
    LocationListener locationListener = new LocationListener() {
        @Override
        public void onLocationChanged(Location location) {
            LatLng latLng = new LatLng(location.getLatitude(), location.getLongitude());
            float zoom = 20;
            gMap.animateCamera(CameraUpdateFactory.newLatLngZoom(latLng, zoom));
        }
    };
}

GPS連動の所以外はかなり簡単なコードで済んでいます。

マーカーの扱い方がちょっと特殊な感じですがおそらく画面上に表示されていない マーカーをオンメモリにさせない為と思われます。 出典を見失いましたが Marker インスタンスをハンドラの外で使うなと言う記述をどこかで見ました。

実行結果

実機でGPSの動作も確認できています。

感想

JavaScript でやった時とだいぶ違う感じ。(JavaScriptはv3だったけど)

最初 MarkerOptions と Marker の関係が理解できなくて悩んだ。 マーカーに限らずレイヤーの表示は全てこの形態になる様子。

Google Map は機能が膨大なのでAPIを全部把握して置くのは無理そうw


2014/04/17

RaspberryPiにGW-450Dを繋ぐメモ

衝動買いで 5Gz(11ac) の無線LANのルータとドングルを購入したので RaspberryPi で試そうと思ったらえらくハマったのメモ。

購入した製品はこちら。

ルータは 4000円からさらにキャンペーン割引で 3600円と 5GHz 対応の無線LANルータとしては暴安だったので購入。

ドングルはこの機種(GW-450D)が RaspberryPi で動作したとの情報をチラと見たので深く考えず購入。 そしてハマるw

GW-450D の Linux 用ドライバ

Linux用ドライバのソースはチップメーカーから供給されていて Planex のサイトからも落とせるようになっています。

しかし、これを README にしたがってコンパイルしてもエラーになります。

make: *** /lib/modules/3.10.25+/build: No such file or directory.  Stop.

これは Rasbian のバイナリイメージにカーネルのヘッダソースが含まれていない為です。

じゃあインストールしてやれば良いかと言うとバージョンの合うパッケージがありません。

$ sudo apt-cache search linux-header
linux-headers-3.10-3-all - All header files for Linux 3.10 (meta-package)
linux-headers-3.10-3-all-armhf - All header files for Linux 3.10 (meta-package)
linux-headers-3.10-3-common - Common header files for Linux 3.10-3
linux-headers-3.10-3-rpi - Header files for Linux 3.10-3-rpi
linux-headers-3.2.0-4-all - All header files for Linux 3.2 (meta-package)
linux-headers-3.2.0-4-all-armhf - All header files for Linux 3.2 (meta-package)
linux-headers-3.2.0-4-common - Common header files for Linux 3.2.0-4
linux-headers-3.2.0-4-rpi - Header files for Linux 3.2.0-4-rpi
linux-headers-3.6-trunk-all - All header files for Linux 3.6 (meta-package)
linux-headers-3.6-trunk-all-armhf - All header files for Linux 3.6 (meta-package)
linux-headers-3.6-trunk-common - Common header files for Linux 3.6-trunk
linux-headers-3.6-trunk-rpi - Header files for Linux 3.6-trunk-rpi
linux-headers-rpi - Header files for Linux rpi configuration (meta-package)
linux-headers-rpi-rpfv - This metapackage will pull in the headers for the raspbian kernel based on

ちなみに linux-headers-3.10-3-rpi をインストールしてビルドしたバイナリは insmod しようとするとバージョンチェックで蹴られます。

$ sudo insmod mt7650u_sta.ko
Error: could not insert module mt7650u_sta.ko: Invalid module format
$ dmesg
[  375.421162] mt7650u_sta: disagrees about version of symbol module_layout

じゃあ、カーネルのソースを落としてくればと思ったのですが Git リポジトリには最新の 3.10.37 のソースしかなくバイナリリリースされている 3.10.25 のタグが切られていません。
何の為の Git なのか意味不明なのですがとにかくそういうソース管理のようです。

結論

対処方の結論はこちらの参考サイトに書いてありました。

自分で最新のカーネルをビルドしなおしてそのソースを使えと言うことらしいです。
ドライバのインストールの為にカーネルのビルドが必要というだいぶ時代が逆行した感が有りますがしかたありません。

私の行った手順は参考サイトと微妙に違っているのでメモして置きます。

尚、これは 2014年4月時点 2014-01-07-wheezy-raspbian.zip を前提とした手順です。

準備

$ sudo apt-get update
$ sudo apt-get -y dist-upgrade
$ sudo apt-get -y install gcc make bc screen ncurses-dev

カーネルソース取得

$ wget https://github.com/raspberrypi/linux/archive/rpi-3.10.y.tar.gz
$ wget https://github.com/raspberrypi/firmware/raw/master/extra/Module.symvers

※wget で Module.symvers が取得出来ない場合はブラウザで取得できます。

カーネルのビルド

$ cd /usr/src
$ sudo su
# tar -xvzf ~/linux-rpi-3.10.y.tar.gz 
# cd linux-rpi-3.10.y
# cp ~/Module.symvers .
# gzip -dc /proc/config.gz > .config
# make oldconfig
# make clean
# make
# make modules_install
# mkdir boot
# make INSTALL_PATH=/usr/src/linux/boot install
# cp /usr/src/linux/boot/vmlinuz-3.10.37 /boot
# vi /boot/config.txt
    kernel=vmlinuz-3.10.37
# ln -s /usr/src/linux-rpi-3.10.y /lib/modules/3.10.37/build
# reboot

   …再起動…

$ uname -r
    3.10.37         <-- カーネルが更新されて要ることを確認

GW-450Dドライバのビルド

$ unzip gw-450d_driver_linux_v3002.zip
$ cd gw-450d_driver_linux_v3002
$ tar xvjf mt7610u_wifi_sta_v3002_dpo_20130916.tar.bz2
$ cd mt7610u_wifi_sta_v3002_dpo_20130916
$ vi common/rtusb_dev_id.c (コード追加)
    USB_DEVICE_ID rtusb_dev_id[] = {
    #ifdef MT76x0
            {USB_DEVICE(0x2019,0xab31)}, /* GW-450D */  <-- 追加
$ vi os/linux/config.mk (設定変更)
    HAS_WPA_SUPPLICANT=y
    HAS_NATIVE_WPA_SUPPLICANT_SUPPORT=y
$ make
$ sudo make install
$ sudo rm -r /etc/Wireless/RT2860STA
$ sudo mkdir -p /etc/Wireless/RT2870STA
$ sudo cp RT2870STA.dat /etc/Wireless/RT2870STA/RT2870STA.dat
$ sudo insmod /lib/modules/3.10.37/kernel/drivers/net/wireless/mt7650u_sta.ko
$ iwconfig ra0
    ra0       Ralink STA  ESSID:"11n-AP"  Nickname:"MT7610U_STA"
              Mode:Auto  Frequency=2.412 GHz  Access Point: Not-Associated   
              Bit Rate:1 Mb/s   
              RTS thr:off   Fragment thr:off
              Link Quality=100/100  Signal level:0 dBm  Noise level:0 dBm
              Rx invalid nwid:0  Rx invalid crypt:0  Rx invalid frag:0
              Tx excessive retries:0  Invalid misc:0   Missed beacon:0

iwconfig が以下のようになる場合はビルドに失敗しています。

$ iwconfig ra0
ra0       No such device

ローカル環境の設定

環境毎に設定値は違います。

$ sudo vi /etc/modules (設定追加)
    mt7650u_sta
$ sudo vi /etc/Wireless/RT2870STA/RT2870STA.dat (設定変更)
    SSID=
    AuthMode=WPA2PSK     <-- ルータの設定に合わせる
    EncrypType=AES       <-- ルータの設定に合わせる
$ sudo vi /etc/network/interfaces (設定追加)
    allow-hotplug ra0
    auto ra0
    iface ra0 inet dhcp
    wpa-ssid “ルータのSSID”
    wpa-psk “ルータのパスワード”
# sudo reboot

   …再起動…

$ iwconfig ra0
    ra0       Ralink STA  ESSID:"ルータのSSID"  Nickname:"MT7610U_STA"
              Mode:Managed  Frequency=5.22 GHz  Access Point: 00:11:22:33:44:55   
              Bit Rate=135 Mb/s   
              RTS thr:off   Fragment thr:off
              Link Quality=100/100  Signal level:-49 dBm  Noise level:-68 dBm
              Rx invalid nwid:0  Rx invalid crypt:0  Rx invalid frag:0
              Tx excessive retries:0  Invalid misc:0   Missed beacon:0
$ ifconfig ra0
    ra0       Link encap:Ethernet  HWaddr 00:11:22:33:44:55  
              inet addr:192.168.0.201  Bcast:192.168.0.255  Mask:255.255.255.0
              UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
              RX packets:1618 errors:0 dropped:0 overruns:0 frame:0
              TX packets:79 errors:0 dropped:0 overruns:0 carrier:0
              collisions:0 txqueuelen:1000 
              RX bytes:468584 (457.6 KiB)  TX bytes:9181 (8.9 KiB)

ifconfig で DHCP のアドレスがちゃんと振られていれば接続完了です。


2014/04/12

Android で顔認識を試してみた

本当は顔認証をやりたかったのだけど現状では無理っぽいので とりあえず顔認識まで試してみた。

Camera.FaceDetection を使う方法

Level 14 から追加された API でカメラのプレビュー中に顔を認識してくれる機能があります。

但し、この機能はハードウェア依存らしく機種によって使えたり使えなかったりです。 さらに、顔の各パーツの認識機能も有りますが同様に機種依存です。

手持ちの機種での動作状況です。

  • Nexus5 : 顔認識=○、顔のパーツ=×
  • Nexus7(2012) : 顔認識=×、顔のパーツ=×

顔のパーツの位置がとれれば自前で認証機能を作る事も可能だったのですが顔の位置だけではどうしようもありません。

実装方法は通常のカメラプレビューに以下を追加するだけです。

camera.setFaceDetectionListener(new Camera.FaceDetectionListener(){
    @Override
    public void onFaceDetection(Camera.Face[] faces, Camera camera) {
        // 顔データ処理
    }
});
camera.startFaceDetection();

Camera.Face には以下のフィールドが有りますが Nexus5 で取得できたのは rect と score のみです。

フィールド名説明(API Doc より)
id An unique id per face while the face is visible to the tracker.
leftEyeThe coordinates of the center of the left eye.
rightEyeThe coordinates of the center of the right eye.
mouth The coordinates of the center of the mouth.
rect Bounds of the face.
score The confidence level for the detection of the face.

rect の座標系は特殊なため SurfaceView に書き込むには座標変換が必要になります。

  • プレビュー画像に対し -1000~1000 の相対座標である。
  • 座標(-1000,-1000)が左上、座標(0,0) が画像中心となる。
  • 座標系のプレビュー画像はlandscapeとなる。portraitの場合は90度回転が必要。

score は 50 以上なら高精度だそうです。

サンプル・アプリ

取得できた顔の矩形をプレビューに被せて表示するだけのアプリです。

package org.kotemaru.android.facetest;

import android.app.Activity;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Paint.Style;
import android.graphics.PixelFormat;
import android.graphics.PorterDuff;
import android.graphics.Rect;
import android.hardware.Camera;
import android.hardware.Camera.CameraInfo;
import android.hardware.Camera.Face;
import android.os.Bundle;
import android.util.Log;
import android.view.SurfaceHolder;
import android.view.SurfaceView;

public class FaceTest1Activity extends Activity {
    private static final String TAG = "FaceTest";

    private Camera camera;
    private SurfaceView preview;
    private SurfaceView overlay;
    private CameraListener cameraListener;
    private OverlayListener overlayListener;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_facetest);

        preview = (SurfaceView) findViewById(R.id.preview);
        cameraListener = new CameraListener(preview);

        overlay = (SurfaceView) findViewById(R.id.overlay);
        overlayListener = new OverlayListener(overlay);
    }
    @Override
    protected void onPostCreate(Bundle savedInstanceState) {
        super.onPostCreate(savedInstanceState);
        preview.getHolder().addCallback(cameraListener);
        overlay.getHolder().addCallback(overlayListener);
    }

    private class CameraListener implements
            SurfaceHolder.Callback,
            Camera.FaceDetectionListener
    {
        private SurfaceView surfaceView;
        private SurfaceHolder surfaceHolder;

        public CameraListener(SurfaceView surfaceView) {
            this.surfaceView = surfaceView;
        }

        @Override
        public void surfaceCreated(SurfaceHolder holder) {
            surfaceHolder = holder;
            try {
                int cameraId = -1;
                // フロントカメラを探す。
                Camera.CameraInfo info = new Camera.CameraInfo();
                for (int id = 0; id < Camera.getNumberOfCameras(); id++) {
                    Camera.getCameraInfo(id, info);
                    if (info.facing == CameraInfo.CAMERA_FACING_FRONT) {
                        cameraId = id;
                        break;
                    }
                }
                camera = Camera.open(cameraId);
                camera.setPreviewDisplay(holder);
                camera.getParameters().setPreviewFpsRange(1, 20);
                camera.setDisplayOrientation(90); // portrate 固定
                // 顔認証機能サポートチェック。
                if (camera.getParameters().getMaxNumDetectedFaces() == 0) {
                    throw new Error("Not supported face detected.");
                }
            } catch (Exception e) {
                Log.e(TAG, e.toString(), e);
            }
        }

        @Override
        public void surfaceChanged(SurfaceHolder holder, int format,
                int width, int height) {
            surfaceHolder = holder;
            camera.startPreview();
            camera.setFaceDetectionListener(cameraListener);
            camera.startFaceDetection();
        }

        @Override
        public void surfaceDestroyed(SurfaceHolder holder) {
            camera.setFaceDetectionListener(null);
            camera.release();
            camera = null;
        }

        @Override
        public void onFaceDetection(Face[] faces, Camera camera) {
            if (faces.length == 0) return;
            Face face = faces[0];
            if (face.score < 30) return;

            overlayListener.drawFace(faceRect2PixelRect(face), Color.RED);
        }

        /**
         * 顔認識範囲を描画用に座標変換する。
         * - Face.rect の座標系はプレビュー画像に対し -1000~1000 の相対座標。
         * - 座標(-1000,-1000)が左上、座標(0,0) が画像中心となる。
         * - 座標系のプレビュー画像はlandscapeとなる。portraitの場合が90度回転が必要。
         * @param face 顔認識情報
         * @return 描画用矩形範囲
         */
        private Rect faceRect2PixelRect(Face face) {
            int w = surfaceView.getWidth();
            int h = surfaceView.getHeight();
            Rect rect = new Rect();

            // フロントカメラなので左右反転、portraitなので座標軸反転
            rect.left = w * (-face.rect.top + 1000) / 2000;
            rect.right = w * (-face.rect.bottom + 1000) / 2000;
            rect.top = h * (-face.rect.left + 1000) / 2000;
            rect.bottom = h * (-face.rect.right + 1000) / 2000;
            //Log.d(TAG, "rect=" + face.rect + "=>" + rect);
            return rect;
        }

    }

    private class OverlayListener implements SurfaceHolder.Callback
    {
        private SurfaceView surfaceView;
        private SurfaceHolder surfaceHolder;

        private Paint paint = new Paint();

        public OverlayListener(SurfaceView surfaceView) {
            this.surfaceView = surfaceView;
        }

        @Override
        public void surfaceCreated(SurfaceHolder holder) {
            surfaceHolder = holder;
            surfaceHolder.setFormat(PixelFormat.TRANSPARENT);
            paint.setStyle(Style.STROKE);
            paint.setStrokeWidth(surfaceView.getWidth() / 100);
        }

        @Override
        public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
            surfaceHolder = holder;
        }

        @Override
        public void surfaceDestroyed(SurfaceHolder holder) {
            // nop.
        }

        public void drawFace(Rect rect1, int color) {
            try {
                Canvas canvas = surfaceHolder.lockCanvas();
                if (canvas != null) {
                    try {
                        canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
                        paint.setColor(color);
                        canvas.drawRect(rect1, paint);
                    } finally {
                        surfaceHolder.unlockCanvasAndPost(canvas);
                    }
                }
            } catch (IllegalArgumentException e) {
                Log.w(TAG, e.toString());
            }
        }

    }
}

実行結果

実行結果はこんな感じになりました。


顔の矩形はほぼリアルタイムにプレビューに追従します。

FaceDetector を使う方法

Camera.FaceDetection は機種依存なので API Level-1 からある FaceDetector を使って同じ事をしてみます。

FaceDetector は低速なのでリアルタイム性は犠牲になります。

FaceDetector の使い方はこれだけです。

FaceDetector faceDetector = new FaceDetector(image.getWidth(), image.getHeight(), MAX_FACE);
FaceDetector.Face[] faces = new FaceDetector.Face[MAX_FACE];
int n = faceDetector.findFaces(image, faces);

プレビューで得た画像を渡せば顔の位置が得られます。

渡す画像は RGB_565 形式で顔が正立の状態でなければなりません。
面倒なのはプレビュー画像のデータは YUV420 形式の landscape 固定なので画像変換処理が必要となる事です。

FaceDetector.Face には以下のメソッドが有ります。 厳密には顔では無く目の位置が取得できると言うことになります。

メソッド名説明(API Doc より)
confidence() Returns a confidence factor between 0 and 1.
getMidPoint(PointF point) Sets the position of the mid-point between the eyes.
eyesDistance() Returns the distance between the eyes.
pose(int euler) Returns the face's pose.

座標系は画像と同じなのでそのまま使用できます。

サンプル・アプリ

取得できた目の位置をプレビューに被せて表示するだけのアプリです。

package org.kotemaru.android.facetest;

import java.util.List;

import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Paint.Style;
import android.graphics.PixelFormat;
import android.graphics.PointF;
import android.graphics.PorterDuff;
import android.graphics.Rect;
import android.hardware.Camera;
import android.hardware.Camera.CameraInfo;
import android.media.FaceDetector;
import android.os.Bundle;
import android.util.Log;
import android.view.SurfaceHolder;
import android.view.SurfaceView;

public class FaceTest2Activity extends Activity {
    private static final String TAG = "FaceTest";

    private Camera camera;
    private SurfaceView preview;
    private SurfaceView overlay;
    private CameraListener cameraListener;
    private OverlayListener overlayListener;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_facetest);

        preview = (SurfaceView) findViewById(R.id.preview);
        cameraListener = new CameraListener(preview);

        overlay = (SurfaceView) findViewById(R.id.overlay);
        overlayListener = new OverlayListener(overlay);
    }
    @Override
    protected void onPostCreate(Bundle savedInstanceState) {
        super.onPostCreate(savedInstanceState);
        preview.getHolder().addCallback(cameraListener);
        overlay.getHolder().addCallback(overlayListener);
    }

    private class CameraListener implements
            SurfaceHolder.Callback,
            Camera.PreviewCallback
    {
        private SurfaceView surfaceView;
        private SurfaceHolder surfaceHolder;
        private Rect faceRect = new Rect();;

        public CameraListener(SurfaceView surfaceView) {
            this.surfaceView = surfaceView;
        }

        @Override
        public void surfaceCreated(SurfaceHolder holder) {
            surfaceHolder = holder;
            try {
                int cameraId = -1;
                Camera.CameraInfo info = new Camera.CameraInfo();
                for (int id = 0; id < Camera.getNumberOfCameras(); id++) {
                    Camera.getCameraInfo(id, info);
                    if (info.facing == CameraInfo.CAMERA_FACING_FRONT) {
                        cameraId = id;
                        break;
                    }
                }
                camera = Camera.open(cameraId);
                camera.setPreviewDisplay(holder);
                List<Camera.Size> sizes = camera.getParameters().getSupportedPreviewSizes();
                camera.getParameters().setPreviewSize(sizes.get(0).width, sizes.get(0).height);
                camera.getParameters().setPreviewFpsRange(1, 20);
                camera.setDisplayOrientation(90); // portrate 固定
            } catch (Exception e) {
                Log.e(TAG, e.toString(), e);
            }
        }

        @Override
        public void surfaceChanged(SurfaceHolder holder, int format,
                int width, int height) {
            surfaceHolder = holder;
            camera.setPreviewCallback(this);
            camera.startPreview();
        }

        @Override
        public void surfaceDestroyed(SurfaceHolder holder) {
            camera.setPreviewCallback(null);
            camera.release();
            camera = null;
        }

        @Override
        public void onPreviewFrame(byte[] data, Camera camera) {
            Bitmap image = decodePreview(data);

            FaceDetector faceDetector = new FaceDetector(image.getWidth(), image.getHeight(), 1);
            FaceDetector.Face[] faces = new FaceDetector.Face[1];
            int n = faceDetector.findFaces(image, faces);

            if (n>0) {
                PointF midPoint = new PointF(0, 0);
                faces[0].getMidPoint(midPoint); // 顔認識結果を取得
                float eyesDistance = faces[0].eyesDistance(); // 顔認識結果を取得
                faceRect.left = (int) (midPoint.x - eyesDistance * 0.8 );
                faceRect.right = (int) (midPoint.x + eyesDistance * 0.8 );
                faceRect.top = (int) (midPoint.y - eyesDistance * 0.2);
                faceRect.bottom = (int) (midPoint.y + eyesDistance * 0.2 );
            }
            overlayListener.drawFace(faceRect, Color.YELLOW, image);
        }


        private int[] rgb;
        private Bitmap tmpImage ;
        private Bitmap decodePreview(byte[] data) {
            int width = camera.getParameters().getPreviewSize().width;
            int height = camera.getParameters().getPreviewSize().height;
            if (rgb == null) {
                rgb = new int[width*height];
                tmpImage = Bitmap.createBitmap(height ,width , Bitmap.Config.RGB_565);
            }

            decodeYUV420SP(rgb, data, width, height);
            tmpImage.setPixels(rgb, 0, height, 0, 0, height, width);
            return tmpImage;
        }

    }

    private class OverlayListener implements SurfaceHolder.Callback
    {
        private SurfaceView surfaceView;
        private SurfaceHolder surfaceHolder;

        private Paint paint = new Paint();

        public OverlayListener(SurfaceView surfaceView) {
            this.surfaceView = surfaceView;
        }

        @Override
        public void surfaceCreated(SurfaceHolder holder) {
            surfaceHolder = holder;
            surfaceHolder.setFormat(PixelFormat.TRANSPARENT);
            paint.setStyle(Style.STROKE);
            paint.setStrokeWidth(1);
        }

        @Override
        public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
            surfaceHolder = holder;
        }

        @Override
        public void surfaceDestroyed(SurfaceHolder holder) {
            // nop.
        }

        public void drawFace(Rect rect1, int color, Bitmap previewImage) {
            try {
                Canvas canvas = surfaceHolder.lockCanvas();
                if (canvas != null) {
                    try {
                        //canvas.drawBitmap(previewImage,0,0, paint);
                        canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
                        canvas.scale(
                                (float)surfaceView.getWidth()/previewImage.getWidth(), 
                                (float)surfaceView.getHeight()/previewImage.getHeight());
                        paint.setColor(color);
                        canvas.drawRect(rect1, paint);
                    } finally {
                        surfaceHolder.unlockCanvasAndPost(canvas);
                    }
                }
            } catch (IllegalArgumentException e) {
                Log.w(TAG, e.toString());
            }
        }

    }

    // from https://code.google.com/p/android/issues/detail?id=823
    private void decodeYUV420SP(int[] rgb, byte[] yuv420sp, int width, int height) {
        final int frameSize = width * height;

        for (int j = 0; j < height; j++) {
            int uvp = frameSize + (j >> 1) * width, u = 0, v = 0;
            for (int i = 0; i < width; i++) {
                int srcp = j*width + i;
                int y = (0xff & ((int) yuv420sp[srcp])) - 16;
                if (y < 0) y = 0;
                if ((i & 1) == 0) {
                    v = (0xff & yuv420sp[uvp++]) - 128;
                    u = (0xff & yuv420sp[uvp++]) - 128;
                }

                int y1192 = 1192 * y;
                int r = (y1192 + 1634 * v);
                int g = (y1192 - 833 * v - 400 * u);
                int b = (y1192 + 2066 * u);

                if (r < 0) r = 0; else if (r > 262143) r = 262143;
                if (g < 0) g = 0; else if (g > 262143) g = 262143;
                if (b < 0) b = 0; else if (b > 262143) b = 262143;

                // 90度回転
                int xx = height-j-1;
                int yy = width-i-1;
                int dstp = yy * height + xx;
                rgb[dstp] = 0xff000000 | ((r << 6) & 0xff0000) | ((g >> 2) & 0xff00) | ((b >> 10) & 0xff);
            }
        }
    }
}

実行結果

Nexus7 での実行結果はこんな感じになりました。


思った以上にプレビューに追従してきます。
計ってませんが 5fps くらいは出ている感じで十分実用的です。 プレビュー画像の解像度を最小の 176x144 に指定しているためだと思われます。 (指定できる解像度は機種依存です。)

所感

FaceDetector でもプレビューに耐えられる事が分かったのは大きな収穫。

OpenCV を使って詳細な情報が取れれば自前で顔認証を実装することも不可能じゃ無さそう。


2014/04/03

Android で OCR を試してみた

Nexus5 を買いました。 実機でカメラが使えるようになったので OCR をやって見たいと思います。

Android 用の OCR ライブラリ

Google の OSS である Tesseract OCR をAndroid用に NDK でコンパイルするのがスタンダードなようです。

Tesseract OCR は C++ で記述されているので Android 向けの JavaAPI を被せた tess-two というプロジェクトを使います。

その他、NHocr と言うのが有るようですが Android 用の環境が良く分からないので今回はパスします。

tess-two プロジェクトのチェックアウトとコンパイル

tess-two は NDK の開発環境を必要とするので事前に構築してください。 -> Android NDK の環境構築

Eclipse で以下の git をクローンします。

  • https://github.com/rmtheis/tess-two.git

GITリポジトリの中から tess-two フォルダをインポートします。

プロジェクトのメニューから「Androidツール」->「Add Native Support」を選択します。

コンソールにこんなエラーが出ている場合は API Level 8 の SDK を追加するか AndroidManifest.xml と product.properties の API Level を書き換えて下さい。

JNI は「自動的にビルド」は効かないので手動でプロジェクトのメニューから「プロジェクトをビルド」を選択します。

結構長いことコンパイルが走って4種類のCPU用の .so ファイルが生成されます。

これでライブラリの準備が完了です。

サンプルプロジェクトのチェックアウトとコンパイル

インド人の Gautam Gupta さんのサンプルを使わせてもらいます。

Eclipse で以下の git をクローンしてプロジェクトをインポートします。

  • https://github.com/GautamGupta/Simple-Android-OCR.git

プロジェクトのプロパティの Android に tess-two プロジェクトをライブラリとして追加します。



SimpleAndroidOCRActivity.javaを開いてソースコードを一部修正します。

super.onCreate()の呼び出しをメソッドの最初に移動します。
※Kitkatで動かなかったため

94:         super.onCreate(savedInstanceState);
↓
47:     public void onCreate(Bundle savedInstanceState) {
48:         super.onCreate(savedInstanceState);

言語指定を英語から日本語に変更します。

34:     public static final String lang = "eng";
                                           ↓
34:     public static final String lang = "jpn";

tess-two のプロジェクトから日本語用の学習データをダウンロードします。

学習データファイルを assets/tessdata に配置します。

これで準備完了です。

結果

実機を繋げて実行してみます。

レシートをカメラで撮影し数秒待たされて出てきた結果がこれです。

=>

なんか惜しい感じw

所感

このままでは使えない感じですが、ボールド体のはっきりした文字は結構正確に認識しているように見えます。

アプリ側で画像処理を行ってスキャナで取り込んだような画像をライブラリに渡せればもっと精度をあげられるのではないでしょうか。


2014/04/02

Android NDK の環境構築

Android の NDK を試そうと開発環境を作ろうとしたら参考サイトのどれも古くて現状と合わないので とりあえず自分でメモ。 現状はEclipseのプラグインが全部やってくれるようです。 と言ってもこれもすぐ古くなっちゃうんだろうな。

前提条件

  • 2014年4月時点です。
  • Eclipse 4.2 の pleiades です。
  • Android-SDK は設定済みです。
  • OSは Windows7/64 です。

NDK のインストール

以下のサイトから環境に合う NDK をダウンロードして任意の場所に展開します。

この時点のバージョンは r9d でした。

Eclipse のプラグインのインストール

C/C++ の開発環境

「ヘルプ」->「新規ソフトウェアのインストール」から以下の3つをインストールする。

  • URL: http://download.eclipse.org/releases/juno/
    • ▽ プログラミング言語
      • ■ C/C++ 開発ツール
      • ■ C/C++ 開発ツール SDK
      • ■ ライブラリ API ドキュメンテーション~

NDK プラグイン

「ヘルプ」->「新規ソフトウェアのインストール」から以下の1つをインストールする。

  • URL: https://dl-ssl.google.com/android/eclipse/
    • ▽ NDK プラグイン
      • ■ Android ネイティブ開発ツール

Eclipse 再起動後、「ウィンドウ」->「設定」から「Android」->「NDK」を選んで NDK Location に展開した NDK のフォルダを設定する。

サンプルプロジェクト

新規プロジェクトで「既存コードからのAndroidプロジェクト」を選びます。

展開した NDK のフォルダの samples フォルダからプロジェクトを選択します。

プロジェクトのメニューから「Androidツール」->「Add Native Support」を選択します。

jni フォルダが認識されます。

JNI は「自動的にビルド」は効かないので手動でプロジェクトのメニューから「プロジェクトをビルド」を選択します。

正常にコンパイルされると各CPUアーキテクチャ用の .so ファイルが生成されます。

この状態で apk を作成すると .so を含んだ形で生成されます。

感想

Eclipseのプラグインが全部やってくれるのでかなり簡単です。

古い NDK の環境設定の解説にはコマンド操作が書いて有ったりしてかえって混乱するので注意してください。


2014/03/05

ViewPagerのループ

リモコンアプリを作って気がついたのだが ViewPager ってページの循環(ループ)が出来ない。

どうしても循環させたい場合は ViewPager を全部作り直す必要が有るっぽい。

このライブラリ使わせてもらえ、で話は終わってしまうのですがもう少し突っ込んでみました。

PagerAdapter だけで頑張ってみた。

ViewPager のソースを見ると子要素として持っている View は表示中のページとその左右のページの3枚のみでした。

ならば最初と最後に擬似的なページを用意してやればループしたように見せられるじゃないでしょうか。

こういうことです。

C'A B C A'
  • A と A' の View は共有します。
  • A' に遷移した時は A にジャンプします。
  • C,C' も同じです。

具体的に実装してみた物がこれです。

public class LoopPagerAdapter extends PagerAdapter {
    private static final String TAG = "LoopPagerAdapter";

    private ViewPager viewPager;
    private Page[] pages;

    public LoopPagerAdapter(ViewPager viewPager, View[] views) {
        super();
        this.viewPager = viewPager;
        this.pages = new Page[views.length+2];

        for (int i=0;i<views.length; i++) {
            pages[i+1] = new Page(views[i]);
        }
        pages[0] = new Page(views[views.length-1]);
        pages[pages.length-1] = new Page(views[0]);
        viewPager.setOnPageChangeListener(onPageChangeListener);
    }

    private OnPageChangeListener onPageChangeListener = new OnPageChangeListener() {
        @Override
        public void onPageSelected(int position) {
            if (position == 0) viewPager.setCurrentItem(getCount()-2,false);
            if (position == getCount()-1) viewPager.setCurrentItem(1,false);
        }
        @Override
        public void onPageScrollStateChanged(int state) {}
        @Override
        public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {}
    };

    @Override
    public int getCount() {
        return pages.length;
    }

    @Override
    public Object instantiateItem(ViewGroup viewGroup, int position) {
        Log.d(TAG, "instantiateItem:" + position);
        Page page = pages[position];
        View view = page.getView();
        for (int i=0;i<pages.length; i++) {
            if (pages[i].getView() == view) pages[i].setValid(false);
        }
        page.setValid(true);

        viewGroup.removeView(view);
        viewGroup.addView(view);
        return page;
    }

    @Override
    public boolean isViewFromObject(View view, Object object) {
        Page page = (Page) object;
        return page.isValid() && page.getView() == view;
    }

    @Override
    public void destroyItem(ViewGroup viewGroup, int position, Object object) {
        Page page = (Page) object;
        Log.d(TAG, "destroyItem:" + position+" : "+((TextView)page.getView()).getText());
        if (page.isValid()) {
            viewGroup.removeView(page.getView());
        }
        page.setValid(false);
    }
}

public class Page {
    private View view;
    private boolean isValid = true;

    public Page(View view) {
        this.view = view;
    }
    public View getView() {
        return view;
    }
    public boolean isValid() {
        return isValid;
    }
    public void setValid(boolean isValid) {
        this.isValid = isValid;
    }
}

A と A' は同時に存在できないのでどちらが有効なのかを管理する為に Page クラスを用意しています。

実際に動かしてみます。

ちゃんと A と C を行き来できました。
但、必ず3ページ以上必要な上、ViewPager の実装に依存するため将来動かなくなるかもしれませんw

ViewFlipper で代用

ページング用のクラスとしてもう一つ ViewFlipper が有ります。 こちらは最初からループ可能です。
但、Fragment に対応していませんし操作が Swipe でなく Fling になります。

フリックに反応して左右にページ遷移する ViewFlipper のサンプルはこうなります。

public class FlingViewFlipper extends ViewFlipper {
    private static final String TAG = "FlingViewFlipper";
    private final Animation right_in_trans_anim = createAnim(1, 0);
    private final Animation right_out_trans_anim = createAnim(0, 1);
    private final Animation left_in_trans_anim = createAnim(-1, 0);
    private final Animation left_out_trans_anim = createAnim(0, -1);
    private GestureDetector gestureDetector;

    public FlingViewFlipper(Context context, AttributeSet attrSet) {
        super(context, attrSet);
        this.gestureDetector = new GestureDetector(context, onGestureListener);
        setFlipInterval(0);
    }

    // Require delegate from Activiy.onTouchEvent()
    public boolean onTouchEvent(MotionEvent ev) {
        gestureDetector.onTouchEvent(ev);
        return false;
    }

    private OnGestureListener onGestureListener = new SimpleOnGestureListener() {
        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            Log.d(TAG, "onFling:" + velocityX);
            if (velocityX < -300) {
                setOutAnimation(left_out_trans_anim);
                setInAnimation(right_in_trans_anim);
                showNext();
                return true;
            } else if (velocityX > 300) {
                setOutAnimation(right_out_trans_anim);
                setInAnimation(left_in_trans_anim);
                showPrevious();
                return true;
            }
            return false;
        }
    };

    private static Animation createAnim(float startX,float entX) {
        Animation anim = new TranslateAnimation(
            Animation.RELATIVE_TO_PARENT, startX, Animation.RELATIVE_TO_PARENT, entX,
            Animation.RELATIVE_TO_PARENT, 0, Animation.RELATIVE_TO_PARENT, 0
        );
        anim.setDuration(300);
        anim.setStartOffset(0);
        return anim;
    }
}

感想

PageViewer はちょと実装が複雑過ぎる気がしました。
PagerAdapter がどう呼ばれるのか PageViewer のソースを見ないと理解が難しいです。

どうしても Fragment でページを管理したいと言う要求がなければ普通に ViewFlipper を使うのがおすすめです。


2014/03/01

祝、月間PV1万

気がついたら Google Analytics ではじめて月間PVが1万越えてた。
旧ブログから数えると5年かかっとる。

まぁ、読者層がニッチなのでしょうがないかw

入門書のコピペみたいな記事書いても意味無いしこのままニッチな方向に進んで行きます。


プロフィール
20年勤めた会社がリーマンショックで消滅、紆余曲折を経て現在はフリーランスのSE。 失業をきっかけにこのブログを始める。

サイト内検索

登録
RSS/2.0

カテゴリ

最近の投稿

リンク

アーカイブ