1. 程式人生 > >頭部姿態估計 - Android

頭部姿態估計 - Android

概括

通過Dlib獲得當前人臉的特徵點,然後通過旋轉平移標準模型的特徵點進行擬合,計算標準模型求得的特徵點與Dlib獲得的特徵點之間的差,使用Ceres不斷迭代優化,最終得到最佳的旋轉和平移引數。

Android版本在原理上同C++版本:頭部姿態估計 - OpenCV/Dlib/Ceres。

主要介紹在移植過程中遇到的問題。

使用環境

系統環境:Ubuntu 18.04

Java環境:JRE 1.8.0

使用語言:C++(clang), Java

編譯工具:Android Studio 3.4.1

  • CMake 3.10.2
    • LLDB
      • NDK 20.0

上述工具在Android Studio中SDK的管理工具裡下載即可。

第三方工具

Dlib:用於獲得人臉特徵點
Ceres:用於進行非線性優化

原始碼

https://github.com/Great-Keith/head-pose-estimation/tree/master/android/landmark-fitting

準備工作

第三方庫的Android介面

Dlib

使用的GitHub上提供的現成介面:https://github.com/tzutalin/dlib-android

該專案還提供了具體的app樣例:https://github.com/tzutalin/dlib-android-app/

我們所做的app就是建立在該app樣例之上。

Ceres

具體使用可以參見前一篇隨筆:Android平臺使用Ceres Solver

總之最後我們整合Dlib和Ceres得到了我們app的基本框架:https://github.com/Great-Keith/dlib-android-app

增加前置攝像頭轉換

增設轉換按鈕

最初的樣例dlib-android-app僅提供了後置攝像頭,這對於單人測試很不方便,因此我們修改程式碼來實現一個切換前後攝像頭的按鈕。

首先找到相機檢視res/layout/camera_connection_fragment.xml,在其右上角增加 Switch 按鈕。

最後我們找到該app的實現細節,是通過自己新建一個CameraConnectionFragment類來替換原本的Fragment,從而實現的一系列操作。該類中setUpCameraOutputs

方法實現了對相機的選擇,其會便利移動裝置上可用的所有相機,優先選擇後置攝像頭。

給該方法增加一個boolean b引數,用於選擇攝像頭:

if(b) {
    // 只使用後置攝像頭
    // If facing back camera or facing external camera exist, we won't use facing front camera
    if (num_facing_back_camera != null && num_facing_back_camera > 0) {
    // 前置攝像頭跳過(如果有後置攝像頭)
    // We don't use a front facing camera in this sample if there are other camera device facing types
        if (facing != null && facing == CameraCharacteristics.LENS_FACING_FRONT) {
            continue;
        }
    }
} else {
    // 只使用前置攝像頭
    if (num_facing_front_camera != null && num_facing_front_camera > 0) {
    // 前置攝像頭跳過(如果有後置攝像頭)
    // We don't use a front facing camera in this sample if there are other camera device facing types
        if (facing != null && facing == CameraCharacteristics.LENS_FACING_BACK) {
          continue; 
        }
    }
}

然後在初始化的過程中關聯上我們的Switch按鈕:

        switchBtn = view.findViewById(R.id.cameraSwitch);
        switchBtn.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
            @Override
            public void onCheckedChanged(CompoundButton compoundButton, boolean b) {
                closeCamera();
                openCamera(textureView.getWidth(), textureView.getHeight(), b);
            }
        });

[NOTE] 通過openCamera將引數b傳輸給setUpCameraOutputs

修復前置攝像頭倒轉

修改完後我們執行程式,會發現出現預顯示視窗倒轉的情況,因此我們需要對預顯示視窗的顯示進行翻轉。

找到相機類處理捕捉到的畫面的監聽器OnGetImageListener,其中對捕捉到的畫面進行處理的函式即為drawResizedBitmap,在最終繪製之前,增加矩陣翻轉。

/* If using front camera, matrix should rotate 180 */
if(!switchBtn.isChecked()) {
    matrix.postTranslate(-dst.getWidth() / 2.0f, -dst.getHeight() / 2.0f);
    matrix.postRotate(180);
    matrix.postTranslate(dst.getWidth() / 2.0f, dst.getHeight() / 2.0f);
}
final Canvas canvas = new Canvas(dst);
canvas.drawBitmap(src, matrix, null);

[NOTE] 在該類中沒有辦法直接獲取CameraId來判斷當前使用的相機是前置還是後置,因此我們通過之前的Switch按鈕來進行判斷。查閱可能可以使用的Camera類在API 21以後淘汰使用了。

主要過程

還是在相機的監聽器當中,我們可以看到dlib獲得的特徵點資料,並進行繪製。

mInferenceHandler.post(
                new Runnable() {
                    @Override
                    public void run() {
                        // ...

                        long startTime = System.currentTimeMillis();
                        List<VisionDetRet> results;
                        synchronized (OnGetImageListener.this) {
                            results = mFaceDet.detect(mCroppedBitmap);
                        }
                        long endTime = System.currentTimeMillis();
                        mTransparentTitleView.setText("Time cost: " + String.valueOf((endTime - startTime) / 1000f) + " sec");
                        // Draw on bitmap
                        if (results != null) {
                            for (final VisionDetRet ret : results) {
                                // 繪製人臉框和特徵點
                                // ...
                                }
                            }
                        }
                        mWindow.setRGBBitmap(mCroppedBitmap);
                        mIsComputing = false;
                    }
                });

我們選擇在繪製人臉框和特徵點的for迴圈中增加優化。

首先將特徵點複製一份Point陣列,用於作為傳入引數。

/* Transform landmarks to array, which is needed by JNI */
Point[] tmp = landmarks.toArray(new Point[0]);

初始化好double x[]隨後我們可以呼叫我們的CeresSolver類來進行處理,得到的最優解通過指標x返回。

CeresSolver.solve(x, tmp);

最後我們再呼叫兩個方法來進行將三維特徵點轉化為二維的對映。

Point3f[] points3f = CeresSolver.transform(x);
Point[] points2d = CeresSolver.transformTo2d(points3f);

[NOTE] 專案中的二維點使用android.graphics.Point(對應C++中使用的dlib::point),而三維點使用我們自己建的一個類Point3f(對應C++中使用的dlib::vector<double, 3>)。

綜上,我們實際上要實現的是一個提供Ceres支援的工具類CeresSolver,下面具體描述。

CeresSolver類與其JNI介面

初始化

我們需要讀取標準模型特徵點的三維座標,該座標儲存於landmarks.txt檔案中。對於Android工程,我們將該檔案放在assets目錄下。在CameraActivity初始化onCreate的時候順帶進行初始化:

CeresSolver.init(getResources().getAssets().open("landmarks.txt"));

該初始化具體過程如下:

public static void init(InputStream in) {
    try {
        InputStreamReader inputReader = new InputStreamReader(in);
        BufferedReader bufReader = new BufferedReader(inputReader);
        String line;
        int i = 0;
        while((line = bufReader.readLine()) != null) {
            String[] nums = line.split(" ");
            modelLandmarks[i] = new Point3f(Double.valueOf(nums[0]),
                                            Double.valueOf(nums[1]),
                                            Double.valueOf(nums[2]));
            i++;
        }
    } catch (Exception e) {
        Log.e(TAG, "Loading model landmarks from file failed.");
        e.printStackTrace();
    }
    Log.i(TAG, "Loading model landmarks from file succeed.");
    init_();
}

init_是一個JNI的函式,用於將CeresSolver類中讀取的modelLandmark資料讀取到本地變數``model_landmark,並提前讀取一些jmethodIDjfieldID

[NOTE] 其實也可以通過呼叫jmethodID或者jfieldID來獲得Java類中的modelLandmark,但我目前不是很清楚兩種方法之間在效率上的差異。

[NOTE] 將這些資料提前在cpp檔案中讀取並儲存成靜態變數,這個過程有一些問題,由於Java的垃圾回收機制,JNI中的靜態型別,有些會失去關聯(可能是指標?)。比如jfieldID的呼叫往往沒有問題,但是jclass就會失效,因此jclass型別無法提前先初始化好。

解決最小二乘

同C++一樣,提前定義好CostFunctor:

struct CostFunctor {
public:
    explicit CostFunctor(JNIEnv *_env, jobjectArray _shape){
        env = _env;
        shape = _shape; }
    bool operator()(const double* const x, double* residual) const {
        /* Init landmarks to be transformed */
        fitting_landmarks.clear();
        for (auto &model_landmark : model_landmarks)
            fitting_landmarks.push_back(model_landmark);
        transform(fitting_landmarks, x);
        std::vector<Point2d> model_landmarks_2d;
        landmarks_3d_to_2d(fitting_landmarks, model_landmarks_2d);

        /* Calculate the energe (Euclid distance from two points) */
        for(unsigned long i=0; i<LANDMARK_NUM; i++) {
            jobject point = env->GetObjectArrayElement(shape, static_cast<jsize>(i));
            long tmp1 = env->GetIntField(point, getX2d) - model_landmarks_2d.at(i).x;
            long tmp2 = env->GetIntField(point, getY2d) - model_landmarks_2d.at(i).y;
            residual[i] = sqrt(tmp1 * tmp1 + tmp2 * tmp2);
        }
        return true;
    }
private:
    JNIEnv *env;
    jobjectArray shape; /* 3d landmarks coordinates got from dlib */
};

基本與C++相同,唯一不同的地方是shape的型別直接使用的JNI中的型別jobjectArray,並且需要使用到呼叫,因此需要在初始化的時候匯入JNIEnv環境。

其餘在呼叫部分就和C++部分基本相同,所有的JNI函式都需要注意在引數傳入和傳出的時候進行型別的轉變。

座標轉化

涉及三維點旋轉和平移的轉化以及三維點轉二維點的轉化,同C++中的涉及。

需要另外提供JNI介面給Java中的類使用,主要涉及jobject的方法呼叫、成員訪問等等。當然,也可以在Java中實現這些方法,感覺效率會更高一些。這一部分具體可以看原始碼,其中有詳細的註釋。

資訊列印(Debug)

在Android專案中,輸出的訊息很多,debug的難度是比較大的,因此需要靈活使用列印資訊來獲得所需要的資訊。其中Java程式中可以使用android.util.Log來進行輸出,可以在logcat或者run中進行檢視。具體比如:

Log.i(TAG, String.format("After Solve x: %f %f %f %f %f %f", 
                          x[0], x[1], x[2], x[3], x[4], x[5]));

JNI的cpp檔案中,定義如下巨集定義來進行輸出:

#define TAG        "CERES-CPP"
#define LOGD(...)  __android_log_print(ANDROID_LOG_DEBUG, TAG,__VA_ARGS__)
#define LOGI(...)  __android_log_print(ANDROID_LOG_INFO,  TAG,__VA_ARGS__)
#define LOGW(...)  __android_log_print(ANDROID_LOG_WARN,  TAG,__VA_ARGS__)
#define LOGE(...)  __android_log_print(ANDROID_LOG_ERROR, TAG,__VA_ARGS__)
#define LOGF(...)  __android_log_print(ANDROID_LOG_FATAL, TAG,__VA_ARGS__)

使用該Log需要在CMakeLists.txt中需要連結log庫。

結果測試

進入相機介面,並進行攝像頭的切換。

這邊可以看到,剛開啟的時候,這個求解得到的點是非常混亂的,這是由於初始值沒有設定好,在經過一段時間後就會進入正常狀態。

實時效果

總結

因為整體邏輯在C++已經實現了,所以複製這個邏輯的過程並不困難。難點主要是在JNI的使用上,沒有接觸過NDK的我在將Ceres移植到安卓平臺上花費了大量的時間,最後寫了Android平臺使用Ceres Solver總結了這個過程。當這一部分完成之後,後面的過程就快了起來,但關於JNI的很多特性,跟Java息息相關,還需要更多的摸索。

進一步可以優化

  • 初始值選擇問題;
  • 去除app中的識別行人模組;
  • 優化使用Ceres求解最小二乘的過程;
  • 前後攝像頭顯示區別;
  • 優化介面,使其更據擴充套件性。