頭部姿態估計 - 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
- LLDB
上述工具在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
,並提前讀取一些jmethodID
和jfieldID
。
[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求解最小二乘的過程;
- 前後攝像頭顯示區別;
- 優化介面,使其更據擴充套件性。