頭部姿態估計 - 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庫。

結果測試

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

operation

這邊可以看到,剛打開的時候,這個求解得到的點是非常混亂的,這是由於初始值沒有設置好,在經過一段時間后就會進入正常狀態。
*[NOTE] 后來我在讀入的模型特征點的時候加入一個縮放系數(1/100),效果得到很好的改善。

實時效果

work

總結

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

進一步可以優化

  • 初始值選擇問題;
  • 去除app中的識別行人模塊;
  • 優化使用Ceres求解最小二乘的過程;
  • 前后攝像頭顯示區別;
  • 優化接口,使其更據擴展性。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM