簡介
這個 Hello World! 教學課程使用 MediaPipe Framework,開發可在 Android 上執行 MediaPipe 圖表的 Android 應用程式。
建構內容
簡易的相機應用程式,適用於 Android 裝置的即時影像串流,可進行即時 Sobel 邊緣偵測。
設定
- 在系統上安裝 MediaPipe 架構,詳情請參閱架構安裝指南。
- 安裝 Android Development SDK 和 Android NDK。請參閱「架構安裝指南」中的方法。
- 在 Android 裝置上啟用開發人員選項。
- 在系統上設定 Bazel,以建構及部署 Android 應用程式。
邊緣偵測圖形
我們將使用以下圖表 edge_detection_mobile_gpu.pbtxt
:
# MediaPipe graph that performs GPU Sobel edge detection on a live video stream.
# Used in the examples in
# mediapipe/examples/android/src/java/com/mediapipe/apps/basic and
# mediapipe/examples/ios/edgedetectiongpu.
# Images coming into and out of the graph.
input_stream: "input_video"
output_stream: "output_video"
# Converts RGB images into luminance images, still stored in RGB format.
node: {
calculator: "LuminanceCalculator"
input_stream: "input_video"
output_stream: "luma_video"
}
# Applies the Sobel filter to luminance images stored in RGB format.
node: {
calculator: "SobelEdgesCalculator"
input_stream: "luma_video"
output_stream: "output_video"
}
圖表如下:
此圖表有一個名為 input_video
的單一輸入串流,適用於裝置相機提供的所有影格。
圖表中的第一個節點 LuminanceCalculator
會使用單一封包 (圖片影格),並使用 OpenGL 著色器套用亮度變更。產生的圖片影格會傳送至 luma_video
輸出串流。
第二個節點 SobelEdgesCalculator
會對 luma_video
串流中的傳入封包套用邊緣偵測功能,並輸出 output_video
輸出串流。
我們的 Android 應用程式會顯示 output_video
串流的輸出圖片影格。
可直接設定應用程式
首先,我們先從在螢幕上顯示「Hello World!」的簡易 Android 應用程式開始著手。如果您熟悉使用 bazel
建構 Android 應用程式,可以略過這個步驟。
建立新的目錄,以便建立 Android 應用程式。舉例來說,您可以在 mediapipe/examples/android/src/java/com/google/mediapipe/apps/basic
找到本教學課程的完整程式碼。在本程式碼研究室中,我們會將這個路徑稱為 $APPLICATION_PATH
。
請注意,在應用程式路徑中:
- 應用程式的名稱是
helloworld
。 - 應用程式的
$PACKAGE_PATH
為com.google.mediapipe.apps.basic
。這將會用於本教學課程的程式碼片段中,因此在複製/使用程式碼片段時,請務必使用自己的$PACKAGE_PATH
。
將檔案 activity_main.xml
新增至 $APPLICATION_PATH/res/layout
。這會在含有 Hello
World!
字串的應用程式全螢幕中顯示 TextView
:
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</android.support.constraint.ConstraintLayout>
將簡單的 MainActivity.java
新增至 $APPLICATION_PATH
,以載入 activity_main.xml
版面配置的內容,如下所示:
package com.google.mediapipe.apps.basic;
import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;
/** Bare-bones main activity. */
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
}
新增資訊清單檔案 AndroidManifest.xml
至 $APPLICATION_PATH
,以便在應用程式啟動時啟動 MainActivity
:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.mediapipe.apps.basic">
<uses-sdk
android:minSdkVersion="19"
android:targetSdkVersion="19" />
<application
android:allowBackup="true"
android:label="${appName}"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:name="${mainActivity}"
android:exported="true"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
我們的應用程式使用的是 Theme.AppCompat
主題,因此需要適當的主題參照。新增 colors.xml
至 $APPLICATION_PATH/res/values/
:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#008577</color>
<color name="colorPrimaryDark">#00574B</color>
<color name="colorAccent">#D81B60</color>
</resources>
新增 styles.xml
至 $APPLICATION_PATH/res/values/
:
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
</resources>
如要建構應用程式,請將 BUILD
檔案新增至 $APPLICATION_PATH
,並將資訊清單中的 ${appName}
和 ${mainActivity}
替換為 BUILD
中指定的字串,如下所示。
android_library(
name = "basic_lib",
srcs = glob(["*.java"]),
manifest = "AndroidManifest.xml",
resource_files = glob(["res/**"]),
deps = [
"//third_party:android_constraint_layout",
"//third_party:androidx_appcompat",
],
)
android_binary(
name = "helloworld",
manifest = "AndroidManifest.xml",
manifest_values = {
"applicationId": "com.google.mediapipe.apps.basic",
"appName": "Hello World",
"mainActivity": ".MainActivity",
},
multidex = "native",
deps = [
":basic_lib",
],
)
android_library
規則會新增 MainActivity
、資源檔案和 AndroidManifest.xml
的依附元件。
android_binary
規則會使用產生的 basic_lib
Android 程式庫建構二進位 APK,以便在 Android 裝置上安裝。
如要建構應用程式,請使用下列指令:
bazel build -c opt --config=android_arm64 $APPLICATION_PATH:helloworld
使用 adb install
安裝產生的 APK 檔案。例如:
adb install bazel-bin/$APPLICATION_PATH/helloworld.apk
在裝置上開啟應用程式。畫面上應該會顯示含有文字 Hello World!
的畫面。
透過「CameraX
」使用相機
相機權限
如要在應用程式中使用相機,我們必須要求使用者提供相機存取權。如要要求相機權限,請在 AndroidManifest.xml
中加入以下內容:
<!-- For using the camera -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" />
將同一個檔案內的最低 SDK 版本變更為 21
,並將目標 SDK 版本變更為 27
:
<uses-sdk
android:minSdkVersion="21"
android:targetSdkVersion="27" />
這可以確保系統會提示使用者要求相機權限,並讓我們使用 CameraX 程式庫存取相機。
如要要求相機權限,我們可以使用 MediaPipe Framework 元件提供的公用程式,也就是 PermissionHelper
。如要使用,請在 BUILD
的 mediapipe_lib
規則中新增依附元件 "//mediapipe/java/com/google/mediapipe/components:android_components"
。
如要在 MainActivity
中使用 PermissionHelper
,請在 onCreate
函式中加入以下這行程式碼:
PermissionHelper.checkAndRequestCameraPermissions(this);
這會提示使用者在畫面上看到對話方塊,要求使用者授權這個應用程式使用相機。
新增以下程式碼來處理使用者回應:
@Override
public void onRequestPermissionsResult(
int requestCode, String[] permissions, int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
PermissionHelper.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
@Override
protected void onResume() {
super.onResume();
if (PermissionHelper.cameraPermissionsGranted(this)) {
startCamera();
}
}
public void startCamera() {}
我們暫時將 startCamera()
方法留空。使用者回應提示時,系統會繼續執行 MainActivity
並呼叫 onResume()
。程式碼會確認已取得使用相機的權限,然後再啟動相機。
重新建構並安裝應用程式。現在您應該會看到一則提示,要求應用程式存取相機。
攝影機存取權
取得相機權限後,我們就能從相機啟動及擷取畫面。
我們使用 SurfaceView
查看相機中的影格。相機的每個影格都會儲存在 SurfaceTexture
物件中。如要使用這些功能,首先必須變更應用程式的版面配置。
從 $APPLICATION_PATH/res/layout/activity_main.xml
中移除整個 TextView
程式碼區塊,然後改為新增下列程式碼:
<FrameLayout
android:id="@+id/preview_display_layout"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:layout_weight="1">
<TextView
android:id="@+id/no_camera_access_view"
android:layout_height="fill_parent"
android:layout_width="fill_parent"
android:gravity="center"
android:text="@string/no_camera_access" />
</FrameLayout>
這個程式碼區塊包含一個名為 preview_display_layout
的新 FrameLayout
,以及名為 no_camera_access_preview
的巢狀 TextView
。如果使用者未授予相機存取權限,應用程式會顯示含有字串訊息的 TextView
,且該訊息儲存在變數 no_camera_access
中。在 $APPLICATION_PATH/res/values/strings.xml
檔案中新增下列程式碼:
<string name="no_camera_access" translatable="false">Please grant camera permissions.</string>
使用者未授予相機權限時,畫面會如下所示:
現在,我們要將 SurfaceTexture
和 SurfaceView
物件新增至 MainActivity
:
private SurfaceTexture previewFrameTexture;
private SurfaceView previewDisplayView;
在 onCreate(Bundle)
函式中,在要求相機權限「之前」新增以下兩行:
previewDisplayView = new SurfaceView(this);
setupPreviewDisplayView();
現在,請新增定義 setupPreviewDisplayView()
的程式碼:
private void setupPreviewDisplayView() {
previewDisplayView.setVisibility(View.GONE);
ViewGroup viewGroup = findViewById(R.id.preview_display_layout);
viewGroup.addView(previewDisplayView);
}
我們會定義新的 SurfaceView
物件並新增至 preview_display_layout
FrameLayout
物件,以便使用名為 previewFrameTexture
的 SurfaceTexture
物件顯示相機影格。
如要使用 previewFrameTexture
取得相機畫面,我們會使用 CameraX。架構提供名為 CameraXPreviewHelper
的公用程式,以便使用 CameraX。這個類別會在透過 onCameraStarted(@Nullable SurfaceTexture)
啟動相機時更新事件監聽器。
如要使用這個公用程式,請修改 BUILD
檔案,在 "//mediapipe/java/com/google/mediapipe/components:android_camerax_helper"
中加入依附元件。
現在匯入 CameraXPreviewHelper
,並在 MainActivity
中新增下列指令行:
private CameraXPreviewHelper cameraHelper;
現在,我們可以將實作新增至 startCamera()
:
public void startCamera() {
cameraHelper = new CameraXPreviewHelper();
cameraHelper.setOnCameraStartedListener(
surfaceTexture -> {
previewFrameTexture = surfaceTexture;
// Make the display view visible to start showing the preview.
previewDisplayView.setVisibility(View.VISIBLE);
});
}
這項操作會建立新的 CameraXPreviewHelper
物件,並在物件上新增匿名事件監聽器。當 cameraHelper
發出相機已啟動且可使用擷取影格的 surfaceTexture
時,我們會將該 surfaceTexture
儲存為 previewFrameTexture
,並讓 previewDisplayView
顯示,這樣我們就能開始看到 previewFrameTexture
的影格。
不過,在啟動相機之前,我們需要決定要使用哪一個相機。CameraXPreviewHelper
繼承自 CameraHelper
,其提供 FRONT
和 BACK
這兩個選項。我們可以從 BUILD
檔案傳入決定,因此不必變更程式碼,就能用其他相機建構另一個應用程式版本。
假設我們想使用 BACK
相機針對從相機檢視的即時場景執行邊緣偵測,請將中繼資料新增至 AndroidManifest.xml
:
...
<meta-data android:name="cameraFacingFront" android:value="${cameraFacingFront}"/>
</application>
</manifest>
然後在 helloworld
Android 二進位規則的 BUILD
中指定選項 (在 manifest_values
中有新項目):
manifest_values = {
"applicationId": "com.google.mediapipe.apps.basic",
"appName": "Hello World",
"mainActivity": ".MainActivity",
"cameraFacingFront": "False",
},
現在,請在 MainActivity
中新增 ApplicationInfo
物件,以擷取 manifest_values
中指定的中繼資料:
private ApplicationInfo applicationInfo;
在 onCreate()
函式中新增:
try {
applicationInfo =
getPackageManager().getApplicationInfo(getPackageName(), PackageManager.GET_META_DATA);
} catch (NameNotFoundException e) {
Log.e(TAG, "Cannot find application info: " + e);
}
現在,請在 startCamera()
函式的結尾加上下列程式碼:
CameraHelper.CameraFacing cameraFacing =
applicationInfo.metaData.getBoolean("cameraFacingFront", false)
? CameraHelper.CameraFacing.FRONT
: CameraHelper.CameraFacing.BACK;
cameraHelper.startCamera(this, cameraFacing, /*unusedSurfaceTexture=*/ null);
此時,應用程式應可成功建構。不過,在裝置上執行應用程式時,即使有人授予相機權限,畫面也會顯示黑色畫面。這是因為雖然我們會儲存 CameraXPreviewHelper
提供的 surfaceTexture
變數,但 previewSurfaceView
並不會使用其輸出內容並將其顯示在螢幕上。
由於我們希望使用 MediaPipe 圖表中的影格,因此不會新增程式碼來直接在本教學課程中查看相機輸出內容。我們會直接跳到如何將相機影格傳送至 MediaPipe 圖表,並在畫面中顯示圖表的輸出內容。
「ExternalTextureConverter
」設定
SurfaceTexture
會從串流擷取圖片影格,做為 OpenGL ES 紋理。如要使用 MediaPipe 圖表,從相機擷取的影格應儲存在一般 Open GL 紋理物件中。架構提供 ExternalTextureConverter
類別,可將 SurfaceTexture
物件中儲存的圖片轉換為一般 OpenGL 紋理物件。
如要使用 ExternalTextureConverter
,您還必須擁有由 EglManager
物件建立及管理的 EGLContext
。在 BUILD
檔案新增依附元件,以使用 EglManager
、"//mediapipe/java/com/google/mediapipe/glutil"
。
在 MainActivity
中新增下列宣告:
private EglManager eglManager;
private ExternalTextureConverter converter;
在 onCreate(Bundle)
函式中新增陳述式,用於初始化 eglManager
物件,然後再要求相機權限:
eglManager = new EglManager(null);
提醒您,我們在 MainActivity
中定義了 onResume()
函式,藉此確認已授予相機權限,並呼叫 startCamera()
。在檢查之前,請在 onResume()
中新增以下這行程式碼,初始化 converter
物件:
converter = new ExternalTextureConverter(eglManager.getContext());
這個 converter
現在會使用由 eglManager
管理的 GLContext
。
我們也必須覆寫 MainActivity
中的 onPause()
函式,以便在應用程式進入暫停狀態時正確關閉 converter
:
@Override
protected void onPause() {
super.onPause();
converter.close();
}
如要向 converter
傳送 previewFrameTexture
的輸出內容,請將下列程式碼區塊新增至 setupPreviewDisplayView()
:
previewDisplayView
.getHolder()
.addCallback(
new SurfaceHolder.Callback() {
@Override
public void surfaceCreated(SurfaceHolder holder) {}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
// (Re-)Compute the ideal size of the camera-preview display (the area that the
// camera-preview frames get rendered onto, potentially with scaling and rotation)
// based on the size of the SurfaceView that contains the display.
Size viewSize = new Size(width, height);
Size displaySize = cameraHelper.computeDisplaySizeFromViewSize(viewSize);
// Connect the converter to the camera-preview frames as its input (via
// previewFrameTexture), and configure the output width and height as the computed
// display size.
converter.setSurfaceTextureAndAttachToGLContext(
previewFrameTexture, displaySize.getWidth(), displaySize.getHeight());
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {}
});
在這個程式碼區塊中,我們為 previewDisplayView
新增自訂 SurfaceHolder.Callback
,並實作 surfaceChanged(SurfaceHolder holder, int
format, int width, int height)
函式,以便計算裝置螢幕上相機影格的適當顯示大小,並將計算的 displaySize
畫面連結 previewFrameTexture
物件,並將計算好的 displaySize
影格傳送給 converter
。
現在可以在 MediaPipe 圖表中使用相機影格。
在 Android 中使用 MediaPipe 圖表
新增相關的依附元件
如要使用 MediaPipe 圖表,必須在 Android 上的 MediaPipe 架構中新增依附元件。我們會先新增建構規則,使用 MediaPipe 架構的 JNI 程式碼建構 cc_binary
,然後建構 cc_library
規則,在應用程式中使用這個二進位檔。在 BUILD
檔案中新增下列程式碼區塊:
cc_binary(
name = "libmediapipe_jni.so",
linkshared = 1,
linkstatic = 1,
deps = [
"//mediapipe/java/com/google/mediapipe/framework/jni:mediapipe_framework_jni",
],
)
cc_library(
name = "mediapipe_jni_lib",
srcs = [":libmediapipe_jni.so"],
alwayslink = 1,
)
將依附元件 ":mediapipe_jni_lib"
新增至 BUILD
檔案的 mediapipe_lib
建構規則。
接下來,我們需要新增要在應用程式中使用的 MediaPipe 圖表專用的依附元件。
首先,在 libmediapipe_jni.so
建構規則中所有計算機程式碼中新增依附元件:
"//mediapipe/graphs/edge_detection:mobile_calculators",
MediaPipe 圖表是 .pbtxt
檔案,但若要在應用程式中使用,我們必須使用 mediapipe_binary_graph
建構規則來產生 .binarypb
檔案。
在 helloworld
Android 二進位檔建構規則中,將圖表專屬的 mediapipe_binary_graph
目標新增為素材資源:
assets = [
"//mediapipe/graphs/edge_detection:mobile_gpu_binary_graph",
],
assets_dir = "",
在 assets
建構規則中,您也可以新增其他資產,例如圖形中使用的 TensorFlowLite 模型。
此外,請為圖表的特定屬性新增其他 manifest_values
,稍後要在 MainActivity
中擷取:
manifest_values = {
"applicationId": "com.google.mediapipe.apps.basic",
"appName": "Hello World",
"mainActivity": ".MainActivity",
"cameraFacingFront": "False",
"binaryGraphName": "mobile_gpu.binarypb",
"inputVideoStreamName": "input_video",
"outputVideoStreamName": "output_video",
},
請注意,binaryGraphName
代表二進位圖表的檔案名稱,由 mediapipe_binary_graph
目標中的 output_name
欄位決定。inputVideoStreamName
和 outputVideoStreamName
是圖形中分別指定的輸入和輸出影片串流名稱。
現在,MainActivity
需要載入 MediaPipe 架構。此外,該架構使用 OpenCV,因此 MainActvity
也應載入 OpenCV
。在 MainActivity
(類別中,但不在任何函式中) 使用下列程式碼,載入這兩個依附元件:
static {
// Load all native libraries needed by the app.
System.loadLibrary("mediapipe_jni");
System.loadLibrary("opencv_java3");
}
使用「MainActivity
」中的圖表
首先,我們必須載入素材資源,其中包含從圖表的 .pbtxt
檔案編譯的 .binarypb
。我們可以使用 MediaPipe 公用程式 AndroidAssetUtil
來達成此目的。
先在 onCreate(Bundle)
中初始化資產管理工具,然後再初始化 eglManager
:
// Initialize asset manager so that MediaPipe native libraries can access the app assets, e.g.,
// binary graphs.
AndroidAssetUtil.initializeNativeAssetManager(this);
現在,我們要設定 FrameProcessor
物件,將 converter
準備的相機影格傳送至 MediaPipe 圖表並執行圖形、準備輸出內容,然後更新 previewDisplayView
以顯示輸出內容。請加入以下程式碼來宣告 FrameProcessor
:
private FrameProcessor processor;
並在初始化 eglManager
後在 onCreate(Bundle)
中初始化:
processor =
new FrameProcessor(
this,
eglManager.getNativeContext(),
applicationInfo.metaData.getString("binaryGraphName"),
applicationInfo.metaData.getString("inputVideoStreamName"),
applicationInfo.metaData.getString("outputVideoStreamName"));
processor
需要從 converter
取用已轉換的影格進行處理。初始化 converter
後,將下列程式碼新增至 onResume()
:
converter.setConsumer(processor);
processor
應將其輸出內容傳送至 previewDisplayView
。為此,請將下列函式定義新增至自訂的 SurfaceHolder.Callback
:
@Override
public void surfaceCreated(SurfaceHolder holder) {
processor.getVideoSurfaceOutput().setSurface(holder.getSurface());
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
processor.getVideoSurfaceOutput().setSurface(null);
}
建立 SurfaceHolder
時,系統會將 Surface
設為 processor
的 VideoSurfaceOutput
。刪除後,我們就會將其從 processor
的 VideoSurfaceOutput
中移除。
大功告成!現在,您應該可以在裝置上成功建構及執行應用程式,並查看在攝影機即時影像中執行的 Sobel 邊緣偵測功能!恭喜!
如果您遇到任何問題,請在這裡查看教學課程的完整程式碼。