概括
通過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
,並提前讀取一些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
庫。
結果測試
進入相機界面,並進行攝像頭的切換。
這邊可以看到,剛打開的時候,這個求解得到的點是非常混亂的,這是由於初始值沒有設置好,在經過一段時間后就會進入正常狀態。
*[NOTE] 后來我在讀入的模型特征點的時候加入一個縮放系數(1/100),效果得到很好的改善。
實時效果
總結
因為整體邏輯在C++已經實現了,所以復制這個邏輯的過程並不困難。難點主要是在JNI的使用上,沒有接觸過NDK的我在將Ceres移植到安卓平台上花費了大量的時間,最后寫了Android平台使用Ceres Solver總結了這個過程。當這一部分完成之后,后面的過程就快了起來,但關於JNI的很多特性,跟Java息息相關,還需要更多的摸索。
進一步可以優化
- 初始值選擇問題;
- 去除app中的識別行人模塊;
- 優化使用Ceres求解最小二乘的過程;
- 前后攝像頭顯示區別;
- 優化接口,使其更據擴展性。