new android example
@@ -1,16 +1,26 @@
|
||||
# --- Gradle ---
|
||||
.gradle/
|
||||
build/
|
||||
/captures
|
||||
|
||||
# --- Local machine config (regenerated by Android Studio) ---
|
||||
local.properties
|
||||
|
||||
# --- IDE ---
|
||||
.idea/
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
*.hprof
|
||||
|
||||
# --- Native build intermediates ---
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
|
||||
# --- OS noise ---
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
Thumbs.db
|
||||
|
||||
# --- Signing & release artifacts (keep keys out of the repo) ---
|
||||
*.jks
|
||||
*.keystore
|
||||
/keystore.properties
|
||||
/app/release/
|
||||
|
||||
179
cpp-package/inspireface/android/InspireFaceExample/README.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# InspireFace Android Example
|
||||
|
||||
A CameraX-based InspireFace Android SDK (1.2.0) example. The launcher is a square-grid
|
||||
feature menu with a global model selector (`Pikachu` / `Megatron`). The selected model is
|
||||
loaded when a feature page opens and is shown in a small label on every feature page.
|
||||
|
||||
## Try the Android app
|
||||
|
||||
<p>
|
||||
<a href="http://fir.tunm.top/pro/pz7b3dgv">
|
||||
<img src="docs/images/inspireface-android-example-app-download.png" width="220" alt="Download the InspireFace Android example">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Download the Android example and try it now.</strong><br>
|
||||
Scan the QR code or <strong><a href="http://fir.tunm.top/pro/pz7b3dgv">Download App</a></strong>.
|
||||
</p>
|
||||
|
||||
The current menu contains eight dedicated pages:
|
||||
|
||||
- **Silent liveness (RGB anti-spoofing)**: streams a per-frame liveness score for the current face, averages it over a sliding window, and labels the face real/spoof against a threshold.
|
||||
- **Action liveness (cooperative)**: generates a random challenge sequence (blink / head shake / mouth open / head raise), prompts each action in turn, and detects completion with per-action timeouts and face-loss failure handling.
|
||||
- **Pose recognition**: displays whatever action you perform — the latest action in large text, with a fading row of smaller history entries below (at most 6 shown; rising-edge debounced, so a held pose is recorded once).
|
||||
- **Face 1:1**: selects two local images, detects and numbers every face, selects face 1 by default, and lets you tap any A/B face box to immediately rerun the comparison. The circular gauge shows the converted similarity percentage and the SDK-recommended threshold verdict.
|
||||
- **Face management**: searches, adds, renames, replaces, and deletes identities in the currently selected model library. Enrollment supports either gallery multi-face selection or an automatic camera flow that tracks face 1, waits for 1 stable second, then fills a 2-second red/yellow/green ring. Motion immediately resets and hides the ring.
|
||||
- **Face recognition**: switches between Photo input and Video stream tabs. Photo mode detects and numbers faces, searches a single face immediately, and reruns the search when a numbered face is tapped. Its collapsed-by-default settings panel sits below the photo picker; detection input px, maximum face count, and minimum face px rebuild the Session and persist locally. Video mode reuses the CameraX tracking pipeline, tracks only face 0, searches after roughly one stable second, renders the result at the bottom, and supports front/rear cameras.
|
||||
- **Face tracking**: detects and numbers every image face, then displays the selected face's SDK-native 106 dense landmarks. Medium and large faces are annotated directly; genuinely small faces use a bottom-right 148dp magnifier cropped to 2.4× the detected box. The Video tracking tab renders track-ID colors, four-corner boxes and 106 points with OpenGL.
|
||||
- **Face attributes**: analyzes a selected image face for mask state, age bracket, image quality, expression state, ethnicity, gender and left/right eye state. Tapping another numbered face updates the result immediately, and small faces reuse the expanded bottom-right crop magnifier.
|
||||
|
||||
Debug aids on each camera page:
|
||||
|
||||
- **Euler angles** switch: live Yaw / Pitch / Roll readout for the tracked face (~10 Hz; note that in the 1.2.0 JNI only `angles[0]` is trustworthy, so with multiple faces the first face is shown).
|
||||
- **Landmarks** rendering is currently hidden; `LandmarkGlView` remains available as an OpenGL ES 2.0 overlay for a future menu entry or debug switch.
|
||||
|
||||
A **Flip camera** chip switches between the front and back lens at runtime. No SDK-side
|
||||
changes are needed for that: every frame is pre-rotated by its own `rotationDegrees`
|
||||
before being handed to InspireFace as an upright `CAMERA_ROTATION_0` buffer, so the new
|
||||
lens's sensor orientation is absorbed per frame — only the display mirroring flips, and
|
||||
the mode state machine restarts.
|
||||
|
||||
Language: the first launch defaults to **English** regardless of the system language. The
|
||||
language chip switches between English and Chinese, and Android 13+ also exposes both in
|
||||
the system per-app language settings. The selected language persists across launches.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
CameraX ImageAnalysis (YUV_420_888, 640x480, KEEP_ONLY_LATEST)
|
||||
└─ UprightFaceCameraAnalyzer (single-threaded analysis executor)
|
||||
├─ Nv21Converter.convert() YUV_420_888 → tight NV21 (VU interleave probed once, then bulk-copied)
|
||||
├─ Nv21Converter.rotateUpright() Java-side rotation to upright
|
||||
├─ CreateImageStreamFromByteBuffer(nv21, CAMERA_ROTATION_0)
|
||||
├─ ExecuteFaceTrack LIGHT_TRACK mode
|
||||
├─ FaceAnalyzer / EnrollmentFaceAnalyzer
|
||||
│ └─ mode pipeline or first-face stability state machine
|
||||
└─ ReleaseImageStream released within the same frame
|
||||
```
|
||||
|
||||
- `HomeActivity` — square-grid feature menu and global model selection
|
||||
- `FaceCompareActivity` — local image decoding, face feature extraction and 1:1 comparison
|
||||
- `FaceManagementActivity` — CRUD UI for model-isolated identities, crops and FeatureHub data
|
||||
- `FaceRecognitionActivity` / `StillImageSessionSettings` — photo multi-face selection, model-scoped 1:N search and persisted Session parameters
|
||||
- `view/RecognitionFaceAnalyzer` — video face-0 stability gate, feature extraction and model-library search
|
||||
- `FaceDetectionActivity` / `widget/FaceLandmarkOverlayView` — image multi-face detection, 106-point overlay and small-face magnifier
|
||||
- `FaceAttributeActivity` / `face/FaceAttributeProcessor` — selectable still-image mask, quality, demographic and interaction attributes
|
||||
- `view/FaceCaptureActivity` / `EnrollmentFaceAnalyzer` — first-face stable camera enrollment and automatic capture
|
||||
- `view/CameraPreviewController` — reusable CameraX preview, 4:3 analysis, lens fallback and front/rear switching
|
||||
- `view/UprightFaceCameraAnalyzer` — shared YUV→upright NV21, face tracking and native stream/session lifecycle
|
||||
- `face/FaceImageProcessor` / `face/FaceCropUtils` / `widget/FaceImageOverlayView` — shared multi-face extraction, expanded crop and tappable numbered boxes
|
||||
- `face/FaceRepository` — model-scoped persistent FeatureHub, crop files and metadata
|
||||
- `view/LivenessActivity` — shared CameraX screen and the silent-liveness entry
|
||||
- `view/ActionLivenessActivity` / `PoseActivity` — dedicated routes that select their fixed controller mode
|
||||
- `view/FaceAnalyzer` — liveness-mode pipeline, performance stats and debug readouts
|
||||
- `view/LivenessController` — the state machines for all three modes (tunables live at the top of this class)
|
||||
- `view/Nv21Converter` — fast YUV→NV21 conversion + NV21 rotation
|
||||
- `view/FaceOverlayView` — face bracket overlay (center-crop mapping + front mirror)
|
||||
- `view/LandmarkGlView` — OpenGL landmark overlay
|
||||
- `view/FaceEngine` — model-aware GlobalLaunch/GlobalTerminate and session creation
|
||||
- `FaceModelPrefs` / `LocalePrefs` / `App` — persisted global model and per-app language
|
||||
|
||||
## Model-isolated face storage
|
||||
|
||||
Pikachu and Megatron never share face features, crop images, metadata, or ID sequences.
|
||||
The app stores them under separate app-private paths:
|
||||
|
||||
```text
|
||||
files/face_hub/Pikachu/features.db
|
||||
files/face_hub/Pikachu/crops/
|
||||
shared_prefs/face_records_Pikachu.xml
|
||||
|
||||
files/face_hub/Megatron/features.db
|
||||
files/face_hub/Megatron/crops/
|
||||
shared_prefs/face_records_Megatron.xml
|
||||
```
|
||||
|
||||
FeatureHub uses manual primary keys and persistent storage. Switching the global model
|
||||
therefore opens a different native database as well as a different crop/metadata set.
|
||||
|
||||
## Key design decisions (verified against SDK source)
|
||||
|
||||
1. **Pre-rotate NV21 on the Java side and always pass `CAMERA_ROTATION_0`.**
|
||||
The SDK (≤1.2.3) crops RGB-liveness input using "the rotated upright full frame + an
|
||||
un-rotated face rect", so passing 90/270 rotation constants misplaces the crop and
|
||||
corrupts silent-liveness scores. Pre-rotating (~1–2 ms at 640×480) sidesteps that
|
||||
entirely, and every SDK output coordinate lands directly in display orientation, so
|
||||
the overlays only need the front-camera mirror. Also note the SDK's rotation
|
||||
constants are the *opposite* of Android's `rotationDegrees` (Android 90 → SDK
|
||||
ROTATION_270); pre-rotation avoids that trap too.
|
||||
|
||||
2. **Action liveness has three hard prerequisites** (miss one and actions never fire):
|
||||
- `DETECT_MODE_LIGHT_TRACK` (other modes rebuild tracked faces every frame, so the
|
||||
temporal action window never accumulates);
|
||||
- `enableInteractionLiveness` at session creation;
|
||||
- `enableFaceQuality` at session creation (loads the pose model — without it yaw/pitch
|
||||
stay 0 and shake/head-raise can never trigger).
|
||||
|
||||
3. **Action flag semantics** (SDK-internal 10-frame sliding window + rules):
|
||||
blink is a one-call pulse (the window resets after it); shake latches while both yaw
|
||||
extremes sit in the rolling window (~10 calls); mouth-open/head-raise are
|
||||
level-triggered. The controller therefore uses **edge gates**: each challenge step
|
||||
must observe the flag at 0 before a 1 counts, and the SDK's `normal` flag (warm-up
|
||||
indicator, also raised for ~9 calls after every blink-induced reset) quarantines the
|
||||
placeholder zeros so a pose held through a natural blink is neither double-counted
|
||||
(pose mode) nor accepted as fresh (action mode).
|
||||
|
||||
4. **`CreateImageStreamFromByteBuffer` does not copy** — the native stream aliases the
|
||||
byte[] until `ReleaseImageStream`. Safe pattern: create → track → pipeline → release
|
||||
within one frame, never overwriting the byte[] before release (this implementation
|
||||
reuses two persistent buffers on a single thread, which satisfies that naturally).
|
||||
|
||||
5. **Silent liveness**: single-frame score with the author-encoded 0.88 decision
|
||||
boundary; every pipeline call converts the full frame internally (a known SDK hot
|
||||
spot), so the pipeline runs every 2nd frame with an 8-sample sliding average — same
|
||||
accuracy, half the cost.
|
||||
|
||||
6. **`MultipleFaceData.angles[i]` is only valid for `i == 0`** (a 1.2.0 JNI bug writes
|
||||
face[0]'s angles into every slot); this app only reads it in single-face flows.
|
||||
|
||||
## Tunables
|
||||
|
||||
At the top of `LivenessController`:
|
||||
|
||||
| Parameter | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `RGB_LIVENESS_THRESHOLD` | 0.88 | silent-liveness real/spoof boundary |
|
||||
| `SCORE_WINDOW` | 8 | sliding average window for scores |
|
||||
| `SILENT_PIPELINE_INTERVAL` | 2 | run the anti-spoofing pipeline every N frames |
|
||||
| `ACTIONS_PER_RUN` | 3 | challenge actions per round |
|
||||
| `ACTION_TIMEOUT_MS` | 8000 | per-action timeout |
|
||||
| `MIN_FACE_WIDTH_RATIO` | 0.18 | minimum face width as a fraction of frame width |
|
||||
| `POSE_HISTORY_MAX` | 6 | pose-mode entries shown (1 large + 5 history) |
|
||||
|
||||
## Requirements
|
||||
|
||||
- JDK 17 (required by AGP 8.6.1; Android Studio's embedded JDK works)
|
||||
- Android Studio Ladybug+ — the Gradle 8.7 wrapper is committed, no local Gradle needed
|
||||
- Network access to `google()`, `mavenCentral()` and `jitpack.io` on first sync
|
||||
(the InspireFace SDK and its bundled model packs resolve from JitPack)
|
||||
- An ARM Android device running Android 7.0 / API 24 or newer. The app compiles and targets
|
||||
Android 15 / API 35; Android has no declared upper install limit.
|
||||
- The SDK ships arm64-v8a / armeabi-v7a only, so x86/x86_64 emulators and Intel-only
|
||||
ChromeOS devices cannot run the native face engine. The arm64 native libraries and the
|
||||
compatibility bridge are built/aligned for Android 15's 16 KB page-size devices.
|
||||
|
||||
`local.properties` is intentionally not committed; Android Studio regenerates it, or set
|
||||
`ANDROID_HOME` for command-line builds.
|
||||
|
||||
## Running
|
||||
|
||||
The first feature launch is slower while the bundled model packs are unpacked from assets.
|
||||
|
||||
```bash
|
||||
./gradlew :app:installDebug
|
||||
```
|
||||
|
||||
This project now covers liveness, pose, 1:1 comparison, face management, and FeatureHub
|
||||
1:N photo search. For other SDK capabilities, continue with the upstream
|
||||
[InspireFace](https://github.com/HyperInspire/InspireFace) Android example.
|
||||
@@ -2,22 +2,55 @@ plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
}
|
||||
|
||||
def releaseKeystoreProperties = new Properties()
|
||||
def releaseKeystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
def hasReleaseKeystore = releaseKeystorePropertiesFile.exists()
|
||||
if (hasReleaseKeystore) {
|
||||
releaseKeystorePropertiesFile.withInputStream {
|
||||
releaseKeystoreProperties.load(it)
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.example.inspireface_example'
|
||||
compileSdk 34
|
||||
compileSdk 35
|
||||
ndkVersion '28.1.13356709'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.example.inspireface_example"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
targetSdk 35
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
versionName "1.0.0"
|
||||
|
||||
ndk {
|
||||
// InspireFace 1.2.0 ships these two ABIs only.
|
||||
abiFilters 'arm64-v8a', 'armeabi-v7a'
|
||||
}
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
if (hasReleaseKeystore) {
|
||||
storeFile rootProject.file(releaseKeystoreProperties['storeFile'])
|
||||
storePassword releaseKeystoreProperties['storePassword']
|
||||
storeType releaseKeystoreProperties.getProperty('storeType', 'JKS')
|
||||
keyAlias releaseKeystoreProperties['keyAlias']
|
||||
keyPassword releaseKeystoreProperties['keyPassword']
|
||||
enableV1Signing true
|
||||
enableV2Signing true
|
||||
enableV3Signing true
|
||||
enableV4Signing true
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
if (hasReleaseKeystore) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
@@ -26,6 +59,11 @@ android {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
externalNativeBuild {
|
||||
ndkBuild {
|
||||
path file('src/main/cpp/Android.mk')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -34,8 +72,13 @@ dependencies {
|
||||
implementation libs.material
|
||||
implementation libs.activity
|
||||
implementation libs.constraintlayout
|
||||
implementation libs.camera.core
|
||||
implementation libs.camera.camera2
|
||||
implementation libs.camera.lifecycle
|
||||
implementation libs.camera.view
|
||||
implementation libs.exifinterface
|
||||
testImplementation libs.junit
|
||||
androidTestImplementation libs.ext.junit
|
||||
androidTestImplementation libs.espresso.core
|
||||
implementation libs.inspireface.android.sdk
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,27 +2,69 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Phone information -->
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<!-- ************************************* -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<!-- Create and delete file permissions in SD card -->
|
||||
<uses-permission android:name="android.permission.MOUNT_UNMOUNT_FILESYSTEMS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera.autofocus"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera.front"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.InspireFaceExample"
|
||||
tools:targetApi="31">
|
||||
tools:targetApi="33">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
android:name=".view.FaceCaptureActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.InspireFaceExample.Liveness" />
|
||||
<activity
|
||||
android:name=".FaceManagementActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.InspireFaceExample.Home" />
|
||||
<activity
|
||||
android:name=".FaceRecognitionActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.InspireFaceExample.Home" />
|
||||
<activity
|
||||
android:name=".FaceDetectionActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.InspireFaceExample.Home" />
|
||||
<activity
|
||||
android:name=".FaceAttributeActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.InspireFaceExample.Home" />
|
||||
<activity
|
||||
android:name=".FaceCompareActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.InspireFaceExample.Home" />
|
||||
<activity
|
||||
android:name=".view.PoseActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.InspireFaceExample.Liveness" />
|
||||
<activity
|
||||
android:name=".view.ActionLivenessActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.InspireFaceExample.Liveness" />
|
||||
<activity
|
||||
android:name=".view.LivenessActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.InspireFaceExample.Liveness" />
|
||||
<activity
|
||||
android:name=".HomeActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.InspireFaceExample.Home">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -31,4 +73,4 @@
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
LOCAL_PATH := $(call my-dir)
|
||||
|
||||
include $(CLEAR_VARS)
|
||||
LOCAL_MODULE := inspireface_session_bridge
|
||||
LOCAL_SRC_FILES := inspireface_session_bridge.cpp
|
||||
LOCAL_CPPFLAGS := -std=c++17 -Wall -Wextra
|
||||
LOCAL_LDLIBS := -ldl -llog
|
||||
include $(BUILD_SHARED_LIBRARY)
|
||||
@@ -0,0 +1,4 @@
|
||||
APP_ABI := arm64-v8a armeabi-v7a
|
||||
APP_PLATFORM := android-24
|
||||
APP_STL := c++_static
|
||||
APP_SUPPORT_FLEXIBLE_PAGE_SIZES := true
|
||||
@@ -0,0 +1,50 @@
|
||||
#include <android/log.h>
|
||||
#include <dlfcn.h>
|
||||
#include <jni.h>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* kLogTag = "InspireFaceBridge";
|
||||
using CreateSessionOptional = long (*)(int custom_option,
|
||||
int detect_mode,
|
||||
int max_detect_face_num,
|
||||
int detect_pixel_level,
|
||||
int track_by_detect_fps,
|
||||
void** session);
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" JNIEXPORT jlong JNICALL
|
||||
Java_com_example_inspireface_1example_view_NativeSessionBridge_nativeCreateLandmarkSession(
|
||||
JNIEnv*, jclass, jint custom_options, jint detect_mode, jint max_faces,
|
||||
jint detect_pixel_level, jint track_by_detect_fps) {
|
||||
void* library = dlopen("libInspireFace.so", RTLD_NOW | RTLD_LOCAL);
|
||||
if (library == nullptr) {
|
||||
__android_log_print(ANDROID_LOG_ERROR, kLogTag,
|
||||
"Could not load libInspireFace.so: %s", dlerror());
|
||||
return 0;
|
||||
}
|
||||
|
||||
dlerror();
|
||||
auto create_session = reinterpret_cast<CreateSessionOptional>(
|
||||
dlsym(library, "HFCreateInspireFaceSessionOptional"));
|
||||
const char* symbol_error = dlerror();
|
||||
if (symbol_error != nullptr || create_session == nullptr) {
|
||||
__android_log_print(ANDROID_LOG_ERROR, kLogTag,
|
||||
"Optional Session API unavailable: %s",
|
||||
symbol_error == nullptr ? "unknown error" : symbol_error);
|
||||
dlclose(library);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void* session = nullptr;
|
||||
long result = create_session(custom_options, detect_mode, max_faces,
|
||||
detect_pixel_level, track_by_detect_fps, &session);
|
||||
dlclose(library);
|
||||
if (result != 0 || session == nullptr) {
|
||||
__android_log_print(ANDROID_LOG_ERROR, kLogTag,
|
||||
"Could not create landmark Session, error=%ld", result);
|
||||
return 0;
|
||||
}
|
||||
return reinterpret_cast<jlong>(session);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.example.inspireface_example;
|
||||
|
||||
import android.app.Application;
|
||||
|
||||
public class App extends Application {
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
LocalePrefs.applyStored(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.example.inspireface_example;
|
||||
|
||||
/** Shared defaults used by every still-image and camera face detector. */
|
||||
public final class DetectorDefaults {
|
||||
|
||||
public static final int INPUT_PX = 320;
|
||||
|
||||
private DetectorDefaults() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package com.example.inspireface_example;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.ColorRes;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import com.example.inspireface_example.face.FaceAttributeProcessor;
|
||||
import com.example.inspireface_example.face.FaceCropUtils;
|
||||
import com.example.inspireface_example.face.FaceImageProcessor;
|
||||
import com.example.inspireface_example.face.ImageBitmapLoader;
|
||||
import com.example.inspireface_example.view.FaceEngine;
|
||||
import com.example.inspireface_example.widget.FaceImageOverlayView;
|
||||
import com.google.android.material.button.MaterialButton;
|
||||
import com.insightface.sdk.inspireface.base.Session;
|
||||
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/** Selectable still-image face attribute analysis. */
|
||||
public final class FaceAttributeActivity extends AppCompatActivity {
|
||||
|
||||
private static final int MAX_IMAGE_DIMENSION = 2048;
|
||||
private static final float DIRECT_FACE_AREA_RATIO = 0.05f;
|
||||
private static final float MAGNIFIER_CROP_SCALE = 2.4f;
|
||||
private static final float BINARY_CONFIDENCE_THRESHOLD = 0.5f;
|
||||
|
||||
private final ExecutorService sdkExecutor = Executors.newSingleThreadExecutor();
|
||||
|
||||
private Session session;
|
||||
private volatile boolean destroyed;
|
||||
private volatile int imageVersion;
|
||||
private boolean busy = true;
|
||||
|
||||
private MaterialButton choosePhotoButton;
|
||||
private ImageView imageView;
|
||||
private View imagePlaceholder;
|
||||
private TextView imageStatus;
|
||||
private FaceImageOverlayView faceOverlay;
|
||||
private View magnifierCard;
|
||||
private ImageView magnifierImage;
|
||||
private View resultValues;
|
||||
private TextView resultPlaceholder;
|
||||
private TextView maskValue;
|
||||
private TextView ageValue;
|
||||
private TextView qualityValue;
|
||||
private TextView expressionValue;
|
||||
private TextView raceValue;
|
||||
private TextView genderValue;
|
||||
private TextView leftEyeValue;
|
||||
private TextView rightEyeValue;
|
||||
|
||||
private Bitmap imageBitmap;
|
||||
private Bitmap magnifierBitmap;
|
||||
private FaceAttributeProcessor.Result analysis;
|
||||
private int selectedIndex = -1;
|
||||
|
||||
private final ActivityResultLauncher<String> photoPicker =
|
||||
registerForActivityResult(new ActivityResultContracts.GetContent(), uri -> {
|
||||
if (uri != null && !busy && session != null) {
|
||||
loadPhoto(uri);
|
||||
}
|
||||
});
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView())
|
||||
.setAppearanceLightNavigationBars(false);
|
||||
setContentView(R.layout.activity_face_attribute);
|
||||
applyWindowInsets();
|
||||
bindViews();
|
||||
|
||||
((TextView) findViewById(R.id.currentModel)).setText(getString(
|
||||
R.string.current_model, FaceModelPrefs.get(this).sdkName()));
|
||||
findViewById(R.id.btnBack).setOnClickListener(v -> finish());
|
||||
choosePhotoButton.setOnClickListener(v -> photoPicker.launch("image/*"));
|
||||
findViewById(R.id.attributeImageCard).setOnClickListener(v -> {
|
||||
if (!busy && session != null) {
|
||||
photoPicker.launch("image/*");
|
||||
}
|
||||
});
|
||||
faceOverlay.setOnFaceSelectedListener(this::selectFace);
|
||||
setControlsEnabled(false);
|
||||
showResultPlaceholder(R.string.attribute_result_empty);
|
||||
sdkExecutor.execute(this::initializeEngine);
|
||||
}
|
||||
|
||||
private void bindViews() {
|
||||
choosePhotoButton = findViewById(R.id.btnChooseAttributePhoto);
|
||||
imageView = findViewById(R.id.attributeImage);
|
||||
imagePlaceholder = findViewById(R.id.attributeImagePlaceholder);
|
||||
imageStatus = findViewById(R.id.attributeImageStatus);
|
||||
faceOverlay = findViewById(R.id.attributeFaceOverlay);
|
||||
magnifierCard = findViewById(R.id.attributeMagnifierCard);
|
||||
magnifierImage = findViewById(R.id.attributeMagnifierImage);
|
||||
resultValues = findViewById(R.id.attributeResultValues);
|
||||
resultPlaceholder = findViewById(R.id.attributeResultPlaceholder);
|
||||
maskValue = findViewById(R.id.attributeMaskValue);
|
||||
ageValue = findViewById(R.id.attributeAgeValue);
|
||||
qualityValue = findViewById(R.id.attributeQualityValue);
|
||||
expressionValue = findViewById(R.id.attributeExpressionValue);
|
||||
raceValue = findViewById(R.id.attributeRaceValue);
|
||||
genderValue = findViewById(R.id.attributeGenderValue);
|
||||
leftEyeValue = findViewById(R.id.attributeLeftEyeValue);
|
||||
rightEyeValue = findViewById(R.id.attributeRightEyeValue);
|
||||
}
|
||||
|
||||
private void initializeEngine() {
|
||||
boolean ready = FaceEngine.ensureLaunched(this);
|
||||
if (ready) {
|
||||
session = FaceEngine.createAttributeSession();
|
||||
ready = session != null;
|
||||
}
|
||||
boolean finalReady = ready;
|
||||
runOnUiThread(() -> {
|
||||
if (destroyed) {
|
||||
return;
|
||||
}
|
||||
busy = false;
|
||||
setControlsEnabled(finalReady);
|
||||
if (finalReady) {
|
||||
setImageStatus(R.string.no_image_selected, R.color.home_text_secondary);
|
||||
} else {
|
||||
setImageStatus(R.string.compare_engine_failed, R.color.liveness_fail);
|
||||
showResultPlaceholder(R.string.attribute_analysis_failed);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void loadPhoto(Uri uri) {
|
||||
int requestVersion = ++imageVersion;
|
||||
busy = true;
|
||||
analysis = null;
|
||||
selectedIndex = -1;
|
||||
faceOverlay.clearFace();
|
||||
clearMagnifier();
|
||||
showResultPlaceholder(R.string.attribute_analyzing);
|
||||
setImageStatus(R.string.attribute_analyzing, R.color.home_text_secondary);
|
||||
setControlsEnabled(false);
|
||||
|
||||
sdkExecutor.execute(() -> {
|
||||
Bitmap bitmap = null;
|
||||
FaceAttributeProcessor.Result result = null;
|
||||
try {
|
||||
bitmap = ImageBitmapLoader.decode(this, uri, MAX_IMAGE_DIMENSION);
|
||||
if (destroyed || imageVersion != requestVersion) {
|
||||
recycle(bitmap);
|
||||
return;
|
||||
}
|
||||
if (session != null) {
|
||||
result = FaceAttributeProcessor.analyze(session, bitmap);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Null result is presented as a load or analysis failure.
|
||||
}
|
||||
Bitmap deliveredBitmap = bitmap;
|
||||
FaceAttributeProcessor.Result deliveredResult = result;
|
||||
runOnUiThread(() -> applyResult(
|
||||
requestVersion, deliveredBitmap, deliveredResult));
|
||||
});
|
||||
}
|
||||
|
||||
private void applyResult(int requestVersion, @Nullable Bitmap bitmap,
|
||||
@Nullable FaceAttributeProcessor.Result result) {
|
||||
if (destroyed || imageVersion != requestVersion) {
|
||||
recycle(bitmap);
|
||||
return;
|
||||
}
|
||||
busy = false;
|
||||
setControlsEnabled(session != null);
|
||||
Bitmap previous = imageBitmap;
|
||||
imageBitmap = bitmap;
|
||||
analysis = result;
|
||||
selectedIndex = result != null && result.candidates.length > 0 ? 0 : -1;
|
||||
|
||||
if (bitmap != null) {
|
||||
imageView.setImageBitmap(bitmap);
|
||||
imagePlaceholder.setVisibility(View.GONE);
|
||||
faceOverlay.showFaces(bitmap.getWidth(), bitmap.getHeight(),
|
||||
result == null ? null : result.faceRects(), selectedIndex);
|
||||
} else {
|
||||
imageView.setImageDrawable(null);
|
||||
imagePlaceholder.setVisibility(View.VISIBLE);
|
||||
faceOverlay.clearFace();
|
||||
}
|
||||
if (previous != bitmap) {
|
||||
recycle(previous);
|
||||
}
|
||||
|
||||
if (result == null) {
|
||||
setImageStatus(bitmap == null ? R.string.image_load_failed
|
||||
: R.string.attribute_analysis_failed, R.color.liveness_fail);
|
||||
showResultPlaceholder(R.string.attribute_analysis_failed);
|
||||
} else if (result.status == FaceAttributeProcessor.Status.NO_FACE) {
|
||||
setImageStatus(R.string.image_no_face, R.color.liveness_fail);
|
||||
showResultPlaceholder(R.string.attribute_no_face_result);
|
||||
} else if (result.status != FaceAttributeProcessor.Status.READY
|
||||
|| result.attributes.length != result.candidates.length) {
|
||||
setImageStatus(R.string.attribute_analysis_failed, R.color.liveness_fail);
|
||||
showResultPlaceholder(R.string.attribute_analysis_failed);
|
||||
} else {
|
||||
renderSelectedFace();
|
||||
}
|
||||
}
|
||||
|
||||
private void selectFace(int index) {
|
||||
if (busy || analysis == null || index < 0
|
||||
|| index >= analysis.candidates.length) {
|
||||
return;
|
||||
}
|
||||
selectedIndex = index;
|
||||
faceOverlay.setSelectedIndex(index);
|
||||
renderSelectedFace();
|
||||
}
|
||||
|
||||
private void renderSelectedFace() {
|
||||
if (analysis == null || selectedIndex < 0
|
||||
|| selectedIndex >= analysis.attributes.length) {
|
||||
showResultPlaceholder(R.string.attribute_analysis_failed);
|
||||
return;
|
||||
}
|
||||
setImageStatus(getString(R.string.attribute_face_selected,
|
||||
selectedIndex + 1, analysis.candidates.length), R.color.liveness_accent);
|
||||
updateMagnifier(analysis.candidates[selectedIndex]);
|
||||
FaceAttributeProcessor.Attribute attribute = analysis.attributes[selectedIndex];
|
||||
maskValue.setText(binaryValue(attribute.maskConfidence,
|
||||
R.string.attribute_mask_yes, R.string.attribute_mask_no));
|
||||
ageValue.setText(age(attribute.ageBracket));
|
||||
qualityValue.setText(validConfidence(attribute.qualityScore)
|
||||
? getString(R.string.attribute_score_format, attribute.qualityScore)
|
||||
: getString(R.string.attribute_unknown));
|
||||
expressionValue.setText(attribute.jawOpen > 0
|
||||
? R.string.attribute_expression_mouth_open
|
||||
: attribute.jawOpen == 0
|
||||
? R.string.attribute_expression_neutral
|
||||
: R.string.attribute_unknown);
|
||||
raceValue.setText(race(attribute.race));
|
||||
genderValue.setText(gender(attribute.gender));
|
||||
leftEyeValue.setText(binaryValue(attribute.leftEyeConfidence,
|
||||
R.string.attribute_eye_open, R.string.attribute_eye_closed));
|
||||
rightEyeValue.setText(binaryValue(attribute.rightEyeConfidence,
|
||||
R.string.attribute_eye_open, R.string.attribute_eye_closed));
|
||||
resultPlaceholder.setVisibility(View.GONE);
|
||||
resultValues.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
private void updateMagnifier(FaceImageProcessor.Candidate selected) {
|
||||
clearMagnifier();
|
||||
Bitmap source = imageBitmap;
|
||||
if (source == null || source.isRecycled()) {
|
||||
return;
|
||||
}
|
||||
float imageArea = (float) source.getWidth() * source.getHeight();
|
||||
float faceArea = Math.max(0f, selected.rect.width())
|
||||
* Math.max(0f, selected.rect.height());
|
||||
if (imageArea > 0f && faceArea / imageArea >= DIRECT_FACE_AREA_RATIO) {
|
||||
return;
|
||||
}
|
||||
FaceCropUtils.SquareCrop crop = FaceCropUtils.createSquare(
|
||||
source, selected.rect, MAGNIFIER_CROP_SCALE);
|
||||
if (crop != null) {
|
||||
magnifierBitmap = crop.bitmap;
|
||||
magnifierImage.setImageBitmap(crop.bitmap);
|
||||
magnifierCard.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
private CharSequence binaryValue(float confidence,
|
||||
@StringRes int high, @StringRes int low) {
|
||||
return validConfidence(confidence)
|
||||
? getString(confidence >= BINARY_CONFIDENCE_THRESHOLD ? high : low)
|
||||
: getString(R.string.attribute_unknown);
|
||||
}
|
||||
|
||||
private CharSequence age(int bracket) {
|
||||
switch (bracket) {
|
||||
case 0: return getString(R.string.attribute_age_0_2);
|
||||
case 1: return getString(R.string.attribute_age_3_9);
|
||||
case 2: return getString(R.string.attribute_age_10_19);
|
||||
case 3: return getString(R.string.attribute_age_20_29);
|
||||
case 4: return getString(R.string.attribute_age_30_39);
|
||||
case 5: return getString(R.string.attribute_age_40_49);
|
||||
case 6: return getString(R.string.attribute_age_50_59);
|
||||
case 7: return getString(R.string.attribute_age_60_69);
|
||||
case 8: return getString(R.string.attribute_age_70_plus);
|
||||
default: return getString(R.string.attribute_unknown);
|
||||
}
|
||||
}
|
||||
|
||||
private CharSequence race(int value) {
|
||||
switch (value) {
|
||||
case 0: return getString(R.string.attribute_race_black);
|
||||
case 1: return getString(R.string.attribute_race_asian);
|
||||
case 2: return getString(R.string.attribute_race_latino);
|
||||
case 3: return getString(R.string.attribute_race_middle_eastern);
|
||||
case 4: return getString(R.string.attribute_race_white);
|
||||
default: return getString(R.string.attribute_unknown);
|
||||
}
|
||||
}
|
||||
|
||||
private CharSequence gender(int value) {
|
||||
if (value == 0) {
|
||||
return getString(R.string.attribute_gender_female);
|
||||
}
|
||||
if (value == 1) {
|
||||
return getString(R.string.attribute_gender_male);
|
||||
}
|
||||
return getString(R.string.attribute_unknown);
|
||||
}
|
||||
|
||||
private void showResultPlaceholder(@StringRes int message) {
|
||||
resultValues.setVisibility(View.GONE);
|
||||
resultPlaceholder.setText(message);
|
||||
resultPlaceholder.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
private void clearMagnifier() {
|
||||
magnifierImage.setImageDrawable(null);
|
||||
magnifierCard.setVisibility(View.GONE);
|
||||
recycle(magnifierBitmap);
|
||||
magnifierBitmap = null;
|
||||
}
|
||||
|
||||
private void setControlsEnabled(boolean enabled) {
|
||||
choosePhotoButton.setEnabled(enabled && !busy);
|
||||
}
|
||||
|
||||
private void setImageStatus(@StringRes int text, @ColorRes int colorRes) {
|
||||
setImageStatus(getString(text), colorRes);
|
||||
}
|
||||
|
||||
private void setImageStatus(CharSequence text, @ColorRes int colorRes) {
|
||||
imageStatus.setText(text);
|
||||
imageStatus.setTextColor(ContextCompat.getColor(this, colorRes));
|
||||
}
|
||||
|
||||
private static boolean validConfidence(float value) {
|
||||
return !Float.isNaN(value) && !Float.isInfinite(value)
|
||||
&& value >= 0f && value <= 1f;
|
||||
}
|
||||
|
||||
private static void recycle(@Nullable Bitmap bitmap) {
|
||||
if (bitmap != null && !bitmap.isRecycled()) {
|
||||
bitmap.recycle();
|
||||
}
|
||||
}
|
||||
|
||||
private void applyWindowInsets() {
|
||||
View root = findViewById(R.id.attributeRoot);
|
||||
int horizontal = root.getPaddingLeft();
|
||||
int top = root.getPaddingTop();
|
||||
int bottom = root.getPaddingBottom();
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root, (v, insets) -> {
|
||||
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
|
||||
v.setPadding(horizontal, top + bars.top, horizontal, bottom + bars.bottom);
|
||||
return insets;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
destroyed = true;
|
||||
imageVersion++;
|
||||
clearMagnifier();
|
||||
recycle(imageBitmap);
|
||||
imageBitmap = null;
|
||||
sdkExecutor.execute(() -> {
|
||||
if (session != null) {
|
||||
FaceEngine.releaseSession(session);
|
||||
session = null;
|
||||
}
|
||||
});
|
||||
sdkExecutor.shutdown();
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package com.example.inspireface_example;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.ColorRes;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import com.example.inspireface_example.face.FaceImageProcessor;
|
||||
import com.example.inspireface_example.face.ImageBitmapLoader;
|
||||
import com.example.inspireface_example.view.FaceEngine;
|
||||
import com.example.inspireface_example.widget.FaceImageOverlayView;
|
||||
import com.example.inspireface_example.widget.SimilarityGaugeView;
|
||||
import com.insightface.sdk.inspireface.InspireFace;
|
||||
import com.insightface.sdk.inspireface.base.FaceFeature;
|
||||
import com.insightface.sdk.inspireface.base.Session;
|
||||
import com.insightface.sdk.inspireface.base.SimilarityConverterConfig;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/** Still-image 1:1 face comparison using the globally selected InspireFace model. */
|
||||
public class FaceCompareActivity extends AppCompatActivity {
|
||||
|
||||
private static final int MAX_IMAGE_DIMENSION = 2048;
|
||||
|
||||
private final ExecutorService sdkExecutor = Executors.newSingleThreadExecutor();
|
||||
private final ImageSlot slotA = new ImageSlot();
|
||||
private final ImageSlot slotB = new ImageSlot();
|
||||
|
||||
private SimilarityGaugeView similarityGauge;
|
||||
private TextView comparisonDetails;
|
||||
private Session session;
|
||||
private volatile boolean destroyed;
|
||||
|
||||
private final ActivityResultLauncher<String> imageAPicker =
|
||||
registerForActivityResult(new ActivityResultContracts.GetContent(), uri -> {
|
||||
if (uri != null) {
|
||||
selectImage(slotA, uri);
|
||||
}
|
||||
});
|
||||
|
||||
private final ActivityResultLauncher<String> imageBPicker =
|
||||
registerForActivityResult(new ActivityResultContracts.GetContent(), uri -> {
|
||||
if (uri != null) {
|
||||
selectImage(slotB, uri);
|
||||
}
|
||||
});
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView())
|
||||
.setAppearanceLightNavigationBars(false);
|
||||
setContentView(R.layout.activity_face_compare);
|
||||
applyWindowInsets();
|
||||
|
||||
bindSlot(slotA, R.id.imageA, R.id.faceOverlayA, R.id.placeholderA, R.id.statusA,
|
||||
R.id.compareCropCardA, R.id.compareCropA);
|
||||
bindSlot(slotB, R.id.imageB, R.id.faceOverlayB, R.id.placeholderB, R.id.statusB,
|
||||
R.id.compareCropCardB, R.id.compareCropB);
|
||||
similarityGauge = findViewById(R.id.similarityGauge);
|
||||
comparisonDetails = findViewById(R.id.comparisonDetails);
|
||||
|
||||
((TextView) findViewById(R.id.currentModel)).setText(
|
||||
getString(R.string.current_model, FaceModelPrefs.get(this).sdkName()));
|
||||
findViewById(R.id.btnBack).setOnClickListener(v -> finish());
|
||||
findViewById(R.id.cardImageA).setOnClickListener(v -> imageAPicker.launch("image/*"));
|
||||
findViewById(R.id.cardImageB).setOnClickListener(v -> imageBPicker.launch("image/*"));
|
||||
|
||||
similarityGauge.showMessage(getString(R.string.compare_select_two));
|
||||
sdkExecutor.execute(this::initializeEngine);
|
||||
}
|
||||
|
||||
private void bindSlot(ImageSlot slot, int imageId, int overlayId,
|
||||
int placeholderId, int statusId, int cropCardId, int cropId) {
|
||||
slot.imageView = findViewById(imageId);
|
||||
slot.faceOverlay = findViewById(overlayId);
|
||||
slot.faceOverlay.setOnFaceSelectedListener(index -> selectFace(slot, index));
|
||||
slot.placeholder = findViewById(placeholderId);
|
||||
slot.statusView = findViewById(statusId);
|
||||
slot.cropCard = findViewById(cropCardId);
|
||||
slot.cropView = findViewById(cropId);
|
||||
}
|
||||
|
||||
private void initializeEngine() {
|
||||
boolean launched = FaceEngine.ensureLaunched(this);
|
||||
if (launched) {
|
||||
session = FaceEngine.createRecognitionSession();
|
||||
}
|
||||
if (!launched || session == null) {
|
||||
postUi(() -> {
|
||||
similarityGauge.showMessage(getString(R.string.compare_engine_failed));
|
||||
setSlotStatus(slotA, R.string.compare_engine_failed, R.color.liveness_fail);
|
||||
setSlotStatus(slotB, R.string.compare_engine_failed, R.color.liveness_fail);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void selectImage(ImageSlot slot, Uri uri) {
|
||||
int requestVersion = ++slot.version;
|
||||
FaceImageProcessor.Candidate[] previousCandidates = slot.candidates;
|
||||
clearSelectedCrop(slot);
|
||||
slot.feature = null;
|
||||
slot.candidates = null;
|
||||
slot.selectedIndex = -1;
|
||||
slot.pending = true;
|
||||
FaceImageProcessor.recycleCrops(previousCandidates, -1);
|
||||
slot.faceOverlay.clearFace();
|
||||
setSlotStatus(slot, R.string.image_analyzing, R.color.home_text_secondary);
|
||||
comparisonDetails.setText(null);
|
||||
updateGaugeForSlots();
|
||||
sdkExecutor.execute(() -> processImage(slot, uri, requestVersion));
|
||||
}
|
||||
|
||||
private void processImage(ImageSlot slot, Uri uri, int requestVersion) {
|
||||
Bitmap bitmap = null;
|
||||
FaceImageProcessor.Candidate[] candidates = null;
|
||||
int statusRes;
|
||||
try {
|
||||
bitmap = ImageBitmapLoader.decode(this, uri, MAX_IMAGE_DIMENSION);
|
||||
if (slot.version != requestVersion || destroyed) {
|
||||
bitmap.recycle();
|
||||
return;
|
||||
}
|
||||
if (session == null) {
|
||||
statusRes = R.string.compare_engine_failed;
|
||||
} else {
|
||||
FaceImageProcessor.Result result =
|
||||
FaceImageProcessor.detect(session, bitmap, true);
|
||||
candidates = result.candidates;
|
||||
if (result.status == FaceImageProcessor.Status.NO_FACE) {
|
||||
statusRes = R.string.image_no_face;
|
||||
} else if (result.status != FaceImageProcessor.Status.READY) {
|
||||
statusRes = R.string.face_extract_failed;
|
||||
} else if (candidates[0].feature == null) {
|
||||
statusRes = R.string.face_extract_failed;
|
||||
} else {
|
||||
statusRes = R.string.image_face_ready;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
statusRes = R.string.image_load_failed;
|
||||
}
|
||||
|
||||
Bitmap deliveredBitmap = bitmap;
|
||||
FaceImageProcessor.Candidate[] deliveredCandidates = candidates;
|
||||
int deliveredStatus = statusRes;
|
||||
// applyImageResult owns cleanup for obsolete results, including Activity teardown.
|
||||
runOnUiThread(() -> applyImageResult(slot, requestVersion, deliveredBitmap,
|
||||
deliveredCandidates, deliveredStatus));
|
||||
}
|
||||
|
||||
private void applyImageResult(ImageSlot slot, int requestVersion, Bitmap bitmap,
|
||||
FaceImageProcessor.Candidate[] candidates,
|
||||
@StringRes int statusRes) {
|
||||
if (destroyed || slot.version != requestVersion) {
|
||||
if (bitmap != null && !bitmap.isRecycled()) {
|
||||
bitmap.recycle();
|
||||
}
|
||||
FaceImageProcessor.recycleCrops(candidates, -1);
|
||||
return;
|
||||
}
|
||||
slot.pending = false;
|
||||
FaceImageProcessor.Candidate[] previousCandidates = slot.candidates;
|
||||
if (previousCandidates != candidates) {
|
||||
clearSelectedCrop(slot);
|
||||
FaceImageProcessor.recycleCrops(previousCandidates, -1);
|
||||
}
|
||||
slot.candidates = candidates;
|
||||
slot.selectedIndex = candidates != null && candidates.length > 0 ? 0 : -1;
|
||||
slot.feature = slot.selectedIndex >= 0 ? candidates[0].feature : null;
|
||||
if (bitmap != null) {
|
||||
Bitmap previous = slot.bitmap;
|
||||
slot.bitmap = bitmap;
|
||||
slot.imageView.setImageBitmap(bitmap);
|
||||
slot.faceOverlay.showFaces(bitmap.getWidth(), bitmap.getHeight(),
|
||||
candidates == null ? null : faceRects(candidates), slot.selectedIndex);
|
||||
slot.placeholder.setVisibility(View.GONE);
|
||||
if (previous != null && previous != bitmap && !previous.isRecycled()) {
|
||||
previous.recycle();
|
||||
}
|
||||
}
|
||||
updateSelectedCrop(slot);
|
||||
if (slot.feature != null && candidates.length > 1) {
|
||||
setSlotStatus(slot, getString(R.string.image_face_selected,
|
||||
slot.selectedIndex + 1, candidates.length), R.color.liveness_accent);
|
||||
} else {
|
||||
setSlotStatus(slot, getString(statusRes),
|
||||
slot.feature != null ? R.color.liveness_accent : R.color.liveness_fail);
|
||||
}
|
||||
updateGaugeForSlots();
|
||||
}
|
||||
|
||||
private void selectFace(ImageSlot slot, int index) {
|
||||
if (slot.pending || slot.candidates == null
|
||||
|| index < 0 || index >= slot.candidates.length) {
|
||||
return;
|
||||
}
|
||||
slot.selectedIndex = index;
|
||||
slot.feature = slot.candidates[index].feature;
|
||||
slot.faceOverlay.setSelectedIndex(index);
|
||||
updateSelectedCrop(slot);
|
||||
comparisonDetails.setText(null);
|
||||
if (slot.feature == null) {
|
||||
setSlotStatus(slot, getString(R.string.face_extract_failed), R.color.liveness_fail);
|
||||
} else if (slot.candidates.length > 1) {
|
||||
setSlotStatus(slot, getString(R.string.image_face_selected,
|
||||
index + 1, slot.candidates.length), R.color.liveness_accent);
|
||||
} else {
|
||||
setSlotStatus(slot, getString(R.string.image_face_ready), R.color.liveness_accent);
|
||||
}
|
||||
updateGaugeForSlots();
|
||||
}
|
||||
|
||||
private void updateSelectedCrop(ImageSlot slot) {
|
||||
if (slot.candidates == null || slot.selectedIndex < 0
|
||||
|| slot.selectedIndex >= slot.candidates.length) {
|
||||
clearSelectedCrop(slot);
|
||||
return;
|
||||
}
|
||||
Bitmap crop = slot.candidates[slot.selectedIndex].crop;
|
||||
if (crop == null || crop.isRecycled()) {
|
||||
clearSelectedCrop(slot);
|
||||
return;
|
||||
}
|
||||
slot.cropView.setImageBitmap(crop);
|
||||
slot.cropCard.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
private static void clearSelectedCrop(ImageSlot slot) {
|
||||
if (slot.cropView != null) {
|
||||
slot.cropView.setImageDrawable(null);
|
||||
}
|
||||
if (slot.cropCard != null) {
|
||||
slot.cropCard.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
private static android.graphics.RectF[] faceRects(
|
||||
FaceImageProcessor.Candidate[] candidates) {
|
||||
android.graphics.RectF[] rects = new android.graphics.RectF[candidates.length];
|
||||
for (int i = 0; i < candidates.length; i++) {
|
||||
rects[i] = candidates[i].rect;
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
private void updateGaugeForSlots() {
|
||||
if (slotA.pending || slotB.pending) {
|
||||
similarityGauge.showMessage(getString(R.string.compare_analyzing));
|
||||
return;
|
||||
}
|
||||
if (slotA.feature == null || slotB.feature == null) {
|
||||
similarityGauge.showMessage(getString(
|
||||
slotA.bitmap == null && slotB.bitmap == null
|
||||
? R.string.compare_select_two : R.string.compare_waiting_face));
|
||||
return;
|
||||
}
|
||||
|
||||
int versionA = slotA.version;
|
||||
int versionB = slotB.version;
|
||||
FaceFeature featureA = slotA.feature;
|
||||
FaceFeature featureB = slotB.feature;
|
||||
similarityGauge.showMessage(getString(R.string.compare_comparing));
|
||||
sdkExecutor.execute(() -> compareFeatures(
|
||||
versionA, versionB, featureA, featureB));
|
||||
}
|
||||
|
||||
private void compareFeatures(int versionA, int versionB,
|
||||
FaceFeature featureA, FaceFeature featureB) {
|
||||
float cosine = InspireFace.FaceComparison(featureA, featureB);
|
||||
float threshold = InspireFace.GetRecommendedCosineThreshold();
|
||||
SimilarityConverterConfig converter = InspireFace.GetCosineSimilarityConverter();
|
||||
float converted = InspireFace.CosineSimilarityConvertToPercentage(cosine);
|
||||
boolean converterUsesUnitRange = converter == null
|
||||
? converted >= 0f && converted <= 1f
|
||||
: converter.outputMax <= 1.0001f;
|
||||
float percent = converterUsesUnitRange ? converted * 100f : converted;
|
||||
if (Float.isNaN(percent) || Float.isInfinite(percent)) {
|
||||
postUi(() -> similarityGauge.showMessage(getString(R.string.compare_failed)));
|
||||
return;
|
||||
}
|
||||
percent = Math.max(0f, Math.min(100f, percent));
|
||||
boolean matched = cosine >= threshold;
|
||||
float finalPercent = percent;
|
||||
postUi(() -> {
|
||||
if (slotA.version != versionA || slotB.version != versionB
|
||||
|| slotA.feature != featureA || slotB.feature != featureB) {
|
||||
return;
|
||||
}
|
||||
similarityGauge.showResult(finalPercent, matched,
|
||||
getString(matched ? R.string.compare_same_person
|
||||
: R.string.compare_different_person));
|
||||
comparisonDetails.setText(String.format(Locale.US,
|
||||
getString(R.string.compare_details), cosine, threshold));
|
||||
});
|
||||
}
|
||||
|
||||
private void setSlotStatus(ImageSlot slot, @StringRes int statusRes,
|
||||
@ColorRes int colorRes) {
|
||||
setSlotStatus(slot, getString(statusRes), colorRes);
|
||||
}
|
||||
|
||||
private void setSlotStatus(ImageSlot slot, CharSequence status,
|
||||
@ColorRes int colorRes) {
|
||||
slot.statusView.setText(status);
|
||||
slot.statusView.setTextColor(ContextCompat.getColor(this, colorRes));
|
||||
}
|
||||
|
||||
private void applyWindowInsets() {
|
||||
View root = findViewById(R.id.compareRoot);
|
||||
int left = root.getPaddingLeft();
|
||||
int top = root.getPaddingTop();
|
||||
int right = root.getPaddingRight();
|
||||
int bottom = root.getPaddingBottom();
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root, (v, insets) -> {
|
||||
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
|
||||
v.setPadding(left + bars.left, top + bars.top,
|
||||
right + bars.right, bottom + bars.bottom);
|
||||
return insets;
|
||||
});
|
||||
}
|
||||
|
||||
private void postUi(Runnable action) {
|
||||
runOnUiThread(() -> {
|
||||
if (!destroyed && !isFinishing()) {
|
||||
action.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
destroyed = true;
|
||||
slotA.version++;
|
||||
slotB.version++;
|
||||
Bitmap bitmapA = slotA.bitmap;
|
||||
Bitmap bitmapB = slotB.bitmap;
|
||||
FaceImageProcessor.Candidate[] candidatesA = slotA.candidates;
|
||||
FaceImageProcessor.Candidate[] candidatesB = slotB.candidates;
|
||||
clearSelectedCrop(slotA);
|
||||
clearSelectedCrop(slotB);
|
||||
slotA.bitmap = null;
|
||||
slotB.bitmap = null;
|
||||
slotA.candidates = null;
|
||||
slotB.candidates = null;
|
||||
sdkExecutor.execute(() -> {
|
||||
if (session != null) {
|
||||
FaceEngine.releaseSession(session);
|
||||
session = null;
|
||||
}
|
||||
FaceImageProcessor.recycleCrops(candidatesA, -1);
|
||||
FaceImageProcessor.recycleCrops(candidatesB, -1);
|
||||
if (bitmapA != null && !bitmapA.isRecycled()) {
|
||||
bitmapA.recycle();
|
||||
}
|
||||
if (bitmapB != null && bitmapB != bitmapA && !bitmapB.isRecycled()) {
|
||||
bitmapB.recycle();
|
||||
}
|
||||
});
|
||||
sdkExecutor.shutdown();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private static final class ImageSlot {
|
||||
volatile int version;
|
||||
boolean pending;
|
||||
Bitmap bitmap;
|
||||
FaceFeature feature;
|
||||
FaceImageProcessor.Candidate[] candidates;
|
||||
int selectedIndex = -1;
|
||||
ImageView imageView;
|
||||
FaceImageOverlayView faceOverlay;
|
||||
View placeholder;
|
||||
TextView statusView;
|
||||
View cropCard;
|
||||
ImageView cropView;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,914 @@
|
||||
package com.example.inspireface_example;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.RectF;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.ColorRes;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.camera.view.PreviewView;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import com.example.inspireface_example.face.FaceImageProcessor;
|
||||
import com.example.inspireface_example.face.FaceCropUtils;
|
||||
import com.example.inspireface_example.face.ImageBitmapLoader;
|
||||
import com.example.inspireface_example.permission.CameraPermissionCoordinator;
|
||||
import com.example.inspireface_example.view.CameraPreviewController;
|
||||
import com.example.inspireface_example.view.FaceEngine;
|
||||
import com.example.inspireface_example.view.FaceTrackingAnalyzer;
|
||||
import com.example.inspireface_example.view.FaceTrackingGlView;
|
||||
import com.example.inspireface_example.widget.FaceImageOverlayView;
|
||||
import com.example.inspireface_example.widget.FaceLandmarkOverlayView;
|
||||
import com.google.android.material.button.MaterialButton;
|
||||
import com.google.android.material.chip.ChipGroup;
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial;
|
||||
import com.google.android.material.tabs.TabLayout;
|
||||
import com.insightface.sdk.inspireface.InspireFace;
|
||||
import com.insightface.sdk.inspireface.base.Session;
|
||||
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/** Still-image first step of the face detection and tracking demo. */
|
||||
public final class FaceDetectionActivity extends AppCompatActivity {
|
||||
|
||||
private static final int MAX_IMAGE_DIMENSION = 2048;
|
||||
/** Only genuinely small faces need the landmark magnifier. */
|
||||
private static final float DIRECT_LANDMARK_FACE_AREA_RATIO = 0.05f;
|
||||
private static final float LANDMARK_MAGNIFIER_CROP_SCALE = 2.4f;
|
||||
|
||||
private final ExecutorService sdkExecutor = Executors.newSingleThreadExecutor();
|
||||
private final ExecutorService trackingExecutor = Executors.newSingleThreadExecutor();
|
||||
|
||||
private Session session;
|
||||
private StillImageSessionSettings.Values savedSettings;
|
||||
private FaceTrackingSessionSettings.Values trackingSettings;
|
||||
private volatile boolean destroyed;
|
||||
private volatile int imageVersion;
|
||||
private boolean sessionBusy = true;
|
||||
private boolean engineReady;
|
||||
|
||||
private ChipGroup inputPxGroup;
|
||||
private ChipGroup maxFacesGroup;
|
||||
private ChipGroup minFaceGroup;
|
||||
private MaterialButton applySessionButton;
|
||||
private MaterialButton resetSessionButton;
|
||||
private MaterialButton choosePhotoButton;
|
||||
private TextView sessionStatus;
|
||||
private View sessionSettingsContent;
|
||||
private TextView sessionSettingsToggleText;
|
||||
private SwitchMaterial landmarkSwitch;
|
||||
private ImageView imageView;
|
||||
private View imagePlaceholder;
|
||||
private TextView imageStatus;
|
||||
private FaceImageOverlayView faceOverlay;
|
||||
private FaceLandmarkOverlayView sourceLandmarkOverlay;
|
||||
private View magnifierCard;
|
||||
private ImageView magnifierImage;
|
||||
private FaceLandmarkOverlayView magnifierLandmarkOverlay;
|
||||
private View magnifierLandmarkBadge;
|
||||
private TextView landmarkDisplayHint;
|
||||
|
||||
private PreviewView trackingPreview;
|
||||
private FaceTrackingGlView trackingGlOverlay;
|
||||
private TextView trackingStatus;
|
||||
private TextView trackingSessionStatus;
|
||||
private TextView trackingSettingsToggleText;
|
||||
private View trackingSettingsContent;
|
||||
private ChipGroup trackingModeGroup;
|
||||
private ChipGroup trackingInputPxGroup;
|
||||
private ChipGroup trackingMaxFacesGroup;
|
||||
private ChipGroup trackingMinFaceGroup;
|
||||
private MaterialButton resetTrackingButton;
|
||||
private View trackingLoadingIndicator;
|
||||
private View flipTrackingButton;
|
||||
private CameraPreviewController trackingCameraController;
|
||||
private FaceTrackingAnalyzer trackingAnalyzer;
|
||||
private boolean videoSelected;
|
||||
private boolean trackingStarting;
|
||||
private CameraPermissionCoordinator cameraPermission;
|
||||
private boolean trackingFrontCamera = true;
|
||||
private boolean suppressTrackingSettingChanges;
|
||||
private int trackingGeneration;
|
||||
|
||||
private Bitmap imageBitmap;
|
||||
private Bitmap magnifierBitmap;
|
||||
private FaceImageProcessor.Candidate[] candidates;
|
||||
private int selectedIndex = -1;
|
||||
|
||||
private final ActivityResultLauncher<String> photoPicker =
|
||||
registerForActivityResult(new ActivityResultContracts.GetContent(), uri -> {
|
||||
if (uri != null && !sessionBusy && session != null) {
|
||||
loadPhoto(uri);
|
||||
}
|
||||
});
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
cameraPermission = new CameraPermissionCoordinator(this,
|
||||
new CameraPermissionCoordinator.Listener() {
|
||||
@Override
|
||||
public void onCameraPermissionGranted() {
|
||||
startVideoTracking();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCameraPermissionBlocked(boolean requiresSettings) {
|
||||
if (videoSelected) {
|
||||
setTrackingLoading(false);
|
||||
showTrackingMessage(requiresSettings
|
||||
? R.string.camera_permission_settings_hint
|
||||
: R.string.camera_permission_retry_hint,
|
||||
R.color.liveness_fail);
|
||||
}
|
||||
}
|
||||
});
|
||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView())
|
||||
.setAppearanceLightNavigationBars(false);
|
||||
setContentView(R.layout.activity_face_detection);
|
||||
applyWindowInsets();
|
||||
|
||||
savedSettings = StillImageSessionSettings.load(this);
|
||||
trackingSettings = FaceTrackingSessionSettings.load(this);
|
||||
bindViews();
|
||||
cameraPermission.bindRecoveryButton(
|
||||
findViewById(R.id.btnCameraPermissionAction));
|
||||
((TextView) findViewById(R.id.currentModel)).setText(getString(
|
||||
R.string.current_model, FaceModelPrefs.get(this).sdkName()));
|
||||
findViewById(R.id.btnBack).setOnClickListener(v -> finish());
|
||||
choosePhotoButton.setOnClickListener(v -> photoPicker.launch("image/*"));
|
||||
findViewById(R.id.detectionImageCard).setOnClickListener(v -> {
|
||||
if (!sessionBusy && session != null) {
|
||||
photoPicker.launch("image/*");
|
||||
}
|
||||
});
|
||||
faceOverlay.setOnFaceSelectedListener(this::selectFace);
|
||||
landmarkSwitch.setOnCheckedChangeListener((button, checked) ->
|
||||
renderSelectedLandmarks());
|
||||
findViewById(R.id.sessionSettingsHeader).setOnClickListener(v ->
|
||||
setSettingsExpanded(sessionSettingsContent.getVisibility() != View.VISIBLE));
|
||||
applySessionButton.setOnClickListener(v -> rebuildSession(selectedSettings()));
|
||||
resetSessionButton.setOnClickListener(v -> {
|
||||
StillImageSessionSettings.Values defaults = StillImageSessionSettings.defaults();
|
||||
selectParameters(defaults);
|
||||
rebuildSession(defaults);
|
||||
});
|
||||
flipTrackingButton.setOnClickListener(v -> flipTrackingCamera());
|
||||
findViewById(R.id.trackingSettingsHeader).setOnClickListener(v ->
|
||||
setTrackingSettingsExpanded(
|
||||
trackingSettingsContent.getVisibility() != View.VISIBLE));
|
||||
resetTrackingButton.setOnClickListener(v -> {
|
||||
FaceTrackingSessionSettings.Values defaults =
|
||||
FaceTrackingSessionSettings.defaults();
|
||||
selectTrackingParameters(defaults);
|
||||
applyTrackingSettings(defaults);
|
||||
});
|
||||
configureTabs();
|
||||
selectParameters(savedSettings);
|
||||
selectTrackingParameters(trackingSettings);
|
||||
configureTrackingAutoApply();
|
||||
setSettingsExpanded(false);
|
||||
setTrackingSettingsExpanded(false);
|
||||
setControlsEnabled(false);
|
||||
setTrackingControlsEnabled(false);
|
||||
sessionStatus.setText(R.string.recognition_session_rebuilding);
|
||||
showTrackingSummary(trackingSettings);
|
||||
sdkExecutor.execute(this::initializeDetection);
|
||||
}
|
||||
|
||||
private void bindViews() {
|
||||
inputPxGroup = findViewById(R.id.inputPxGroup);
|
||||
maxFacesGroup = findViewById(R.id.maxFacesGroup);
|
||||
minFaceGroup = findViewById(R.id.minFaceGroup);
|
||||
applySessionButton = findViewById(R.id.btnApplySession);
|
||||
resetSessionButton = findViewById(R.id.btnResetSession);
|
||||
choosePhotoButton = findViewById(R.id.btnChooseDetectionPhoto);
|
||||
sessionStatus = findViewById(R.id.sessionStatus);
|
||||
sessionSettingsContent = findViewById(R.id.sessionSettingsContent);
|
||||
sessionSettingsToggleText = findViewById(R.id.sessionSettingsToggleText);
|
||||
landmarkSwitch = findViewById(R.id.switchDenseLandmarks);
|
||||
imageView = findViewById(R.id.detectionImage);
|
||||
imagePlaceholder = findViewById(R.id.detectionImagePlaceholder);
|
||||
imageStatus = findViewById(R.id.detectionImageStatus);
|
||||
faceOverlay = findViewById(R.id.detectionFaceOverlay);
|
||||
sourceLandmarkOverlay = findViewById(R.id.detectionLandmarkOverlay);
|
||||
magnifierCard = findViewById(R.id.landmarkMagnifierCard);
|
||||
magnifierImage = findViewById(R.id.landmarkMagnifierImage);
|
||||
magnifierLandmarkOverlay = findViewById(R.id.landmarkMagnifierOverlay);
|
||||
magnifierLandmarkBadge = findViewById(R.id.landmarkMagnifierBadge);
|
||||
landmarkDisplayHint = findViewById(R.id.landmarkDisplayHint);
|
||||
trackingPreview = findViewById(R.id.detectionTrackingPreview);
|
||||
trackingGlOverlay = findViewById(R.id.detectionTrackingGlOverlay);
|
||||
trackingStatus = findViewById(R.id.detectionTrackingStatus);
|
||||
trackingSessionStatus = findViewById(R.id.trackingSessionStatus);
|
||||
trackingSettingsToggleText = findViewById(R.id.trackingSettingsToggleText);
|
||||
trackingSettingsContent = findViewById(R.id.trackingSettingsContent);
|
||||
trackingModeGroup = findViewById(R.id.trackingModeGroup);
|
||||
trackingInputPxGroup = findViewById(R.id.trackingInputPxGroup);
|
||||
trackingMaxFacesGroup = findViewById(R.id.trackingMaxFacesGroup);
|
||||
trackingMinFaceGroup = findViewById(R.id.trackingMinFaceGroup);
|
||||
resetTrackingButton = findViewById(R.id.btnResetTrackingSession);
|
||||
trackingLoadingIndicator = findViewById(R.id.trackingLoadingIndicator);
|
||||
flipTrackingButton = findViewById(R.id.btnFlipTrackingCamera);
|
||||
}
|
||||
|
||||
private void configureTabs() {
|
||||
View imagePanel = findViewById(R.id.imageDetectionPanel);
|
||||
View trackingPanel = findViewById(R.id.videoTrackingPanel);
|
||||
((TabLayout) findViewById(R.id.detectionTabs))
|
||||
.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
|
||||
@Override
|
||||
public void onTabSelected(TabLayout.Tab tab) {
|
||||
boolean image = tab.getPosition() == 0;
|
||||
videoSelected = !image;
|
||||
imagePanel.setVisibility(image ? View.VISIBLE : View.GONE);
|
||||
trackingPanel.setVisibility(image ? View.GONE : View.VISIBLE);
|
||||
if (image) {
|
||||
stopVideoTracking();
|
||||
} else {
|
||||
startVideoTracking();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTabUnselected(TabLayout.Tab tab) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTabReselected(TabLayout.Tab tab) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeDetection() {
|
||||
StillImageSessionSettings.Values initial = savedSettings;
|
||||
boolean ready = FaceEngine.ensureLaunched(this);
|
||||
if (ready) {
|
||||
session = FaceEngine.createDetectionSession(
|
||||
initial.inputPx, initial.maxFaces, initial.minFacePx);
|
||||
ready = session != null;
|
||||
}
|
||||
boolean finalReady = ready;
|
||||
postUi(() -> {
|
||||
sessionBusy = false;
|
||||
engineReady = finalReady;
|
||||
setControlsEnabled(finalReady);
|
||||
setTrackingControlsEnabled(finalReady);
|
||||
if (finalReady) {
|
||||
showSessionSummary(initial);
|
||||
if (videoSelected) {
|
||||
startVideoTracking();
|
||||
}
|
||||
} else {
|
||||
setTrackingLoading(false);
|
||||
sessionStatus.setText(R.string.recognition_session_failed);
|
||||
sessionStatus.setTextColor(color(R.color.liveness_fail));
|
||||
setImageStatus(R.string.compare_engine_failed, R.color.liveness_fail);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void loadPhoto(Uri uri) {
|
||||
int requestVersion = ++imageVersion;
|
||||
StillImageSessionSettings.Values settings = savedSettings;
|
||||
sessionBusy = true;
|
||||
candidates = null;
|
||||
selectedIndex = -1;
|
||||
faceOverlay.clearFace();
|
||||
clearLandmarkPresentation();
|
||||
setControlsEnabled(false);
|
||||
setImageStatus(R.string.image_analyzing, R.color.home_text_secondary);
|
||||
sdkExecutor.execute(() -> {
|
||||
Bitmap bitmap = null;
|
||||
FaceImageProcessor.Result result = null;
|
||||
try {
|
||||
bitmap = ImageBitmapLoader.decode(this, uri, MAX_IMAGE_DIMENSION);
|
||||
if (destroyed || imageVersion != requestVersion) {
|
||||
recycle(bitmap);
|
||||
return;
|
||||
}
|
||||
// The 1.2.0 landmark workaround uses LIGHT_TRACK. Start from a clean tracker
|
||||
// for every unrelated still image so cached boxes from the previous photo
|
||||
// cannot affect this detection.
|
||||
replaceDetectionSession(settings);
|
||||
if (session != null) {
|
||||
result = FaceImageProcessor.detectWithLandmarks(session, bitmap);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Null result is rendered as a load/detection failure.
|
||||
}
|
||||
Bitmap deliveredBitmap = bitmap;
|
||||
FaceImageProcessor.Result deliveredResult = result;
|
||||
runOnUiThread(() -> applyPhotoResult(
|
||||
requestVersion, deliveredBitmap, deliveredResult));
|
||||
});
|
||||
}
|
||||
|
||||
/** Called only from {@link #sdkExecutor}. */
|
||||
private void replaceDetectionSession(StillImageSessionSettings.Values settings) {
|
||||
if (session != null) {
|
||||
FaceEngine.releaseSession(session);
|
||||
session = null;
|
||||
}
|
||||
session = FaceEngine.createDetectionSession(
|
||||
settings.inputPx, settings.maxFaces, settings.minFacePx);
|
||||
}
|
||||
|
||||
private void applyPhotoResult(int requestVersion, @Nullable Bitmap bitmap,
|
||||
@Nullable FaceImageProcessor.Result result) {
|
||||
if (destroyed || imageVersion != requestVersion) {
|
||||
recycle(bitmap);
|
||||
return;
|
||||
}
|
||||
Bitmap previous = imageBitmap;
|
||||
imageBitmap = bitmap;
|
||||
candidates = result == null ? null : result.candidates;
|
||||
selectedIndex = candidates != null && candidates.length > 0 ? 0 : -1;
|
||||
if (bitmap != null) {
|
||||
imageView.setImageBitmap(bitmap);
|
||||
imagePlaceholder.setVisibility(View.GONE);
|
||||
faceOverlay.showFaces(bitmap.getWidth(), bitmap.getHeight(),
|
||||
result == null ? null : result.faceRects(), selectedIndex);
|
||||
} else {
|
||||
imageView.setImageDrawable(null);
|
||||
imagePlaceholder.setVisibility(View.VISIBLE);
|
||||
faceOverlay.clearFace();
|
||||
}
|
||||
if (previous != bitmap) {
|
||||
recycle(previous);
|
||||
}
|
||||
finishDetectionUi(result);
|
||||
}
|
||||
|
||||
private void finishDetectionUi(@Nullable FaceImageProcessor.Result result) {
|
||||
sessionBusy = false;
|
||||
setControlsEnabled(session != null);
|
||||
if (result == null) {
|
||||
clearLandmarkPresentation();
|
||||
setImageStatus(R.string.image_load_failed, R.color.liveness_fail);
|
||||
} else if (result.status == FaceImageProcessor.Status.NO_FACE) {
|
||||
clearLandmarkPresentation();
|
||||
setImageStatus(R.string.image_no_face, R.color.liveness_fail);
|
||||
} else if (result.status != FaceImageProcessor.Status.READY
|
||||
|| candidates == null || candidates.length == 0) {
|
||||
clearLandmarkPresentation();
|
||||
setImageStatus(R.string.face_detection_failed, R.color.liveness_fail);
|
||||
} else {
|
||||
updateSelectionStatus();
|
||||
renderSelectedLandmarks();
|
||||
}
|
||||
}
|
||||
|
||||
private void selectFace(int index) {
|
||||
if (sessionBusy || candidates == null || index < 0 || index >= candidates.length) {
|
||||
return;
|
||||
}
|
||||
selectedIndex = index;
|
||||
faceOverlay.setSelectedIndex(index);
|
||||
updateSelectionStatus();
|
||||
renderSelectedLandmarks();
|
||||
}
|
||||
|
||||
private void updateSelectionStatus() {
|
||||
if (candidates == null || candidates.length == 0) {
|
||||
return;
|
||||
}
|
||||
FaceImageProcessor.Candidate selected = selectedCandidate();
|
||||
int landmarkCount = selected == null || selected.denseLandmarks == null
|
||||
? 0 : selected.denseLandmarks.length;
|
||||
imageStatus.setText(getString(R.string.detection_face_selected,
|
||||
selectedIndex + 1, candidates.length, landmarkCount));
|
||||
imageStatus.setTextColor(color(R.color.liveness_accent));
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private FaceImageProcessor.Candidate selectedCandidate() {
|
||||
return candidates != null && selectedIndex >= 0 && selectedIndex < candidates.length
|
||||
? candidates[selectedIndex] : null;
|
||||
}
|
||||
|
||||
private void renderSelectedLandmarks() {
|
||||
clearLandmarkPresentation();
|
||||
FaceImageProcessor.Candidate selected = selectedCandidate();
|
||||
Bitmap source = imageBitmap;
|
||||
if (selected == null || source == null || source.isRecycled()) {
|
||||
landmarkDisplayHint.setText(R.string.detection_landmarks_failed);
|
||||
return;
|
||||
}
|
||||
|
||||
boolean showLandmarks = landmarkSwitch.isChecked();
|
||||
boolean landmarksAvailable = selected.denseLandmarks != null
|
||||
&& selected.denseLandmarks.length > 0;
|
||||
float imageArea = (float) source.getWidth() * source.getHeight();
|
||||
float faceArea = Math.max(0f, selected.rect.width())
|
||||
* Math.max(0f, selected.rect.height());
|
||||
float ratio = imageArea <= 0f ? 0f : faceArea / imageArea;
|
||||
int percent = Math.round(ratio * 100f);
|
||||
boolean magnified = ratio < DIRECT_LANDMARK_FACE_AREA_RATIO
|
||||
&& showLandmarkMagnifier(
|
||||
selected, showLandmarks && landmarksAvailable);
|
||||
if (!showLandmarks) {
|
||||
landmarkDisplayHint.setText(R.string.detection_landmarks_hidden);
|
||||
return;
|
||||
}
|
||||
if (!landmarksAvailable) {
|
||||
landmarkDisplayHint.setText(R.string.detection_landmarks_failed);
|
||||
return;
|
||||
}
|
||||
if (magnified) {
|
||||
landmarkDisplayHint.setText(getString(
|
||||
R.string.detection_landmarks_in_magnifier, percent));
|
||||
} else {
|
||||
sourceLandmarkOverlay.showPoints(source.getWidth(), source.getHeight(),
|
||||
selected.denseLandmarks);
|
||||
landmarkDisplayHint.setText(getString(
|
||||
R.string.detection_landmarks_on_source, percent));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean showLandmarkMagnifier(FaceImageProcessor.Candidate selected,
|
||||
boolean showLandmarks) {
|
||||
Bitmap source = imageBitmap;
|
||||
if (source == null) {
|
||||
return false;
|
||||
}
|
||||
FaceCropUtils.SquareCrop crop = FaceCropUtils.createSquare(
|
||||
source, selected.rect, LANDMARK_MAGNIFIER_CROP_SCALE);
|
||||
if (crop == null) {
|
||||
return false;
|
||||
}
|
||||
magnifierBitmap = crop.bitmap;
|
||||
magnifierImage.setImageBitmap(crop.bitmap);
|
||||
if (showLandmarks) {
|
||||
magnifierLandmarkOverlay.showPoints(crop.size, crop.size,
|
||||
selected.denseLandmarks, crop.left, crop.top);
|
||||
magnifierLandmarkBadge.setVisibility(View.VISIBLE);
|
||||
}
|
||||
magnifierCard.setVisibility(View.VISIBLE);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void clearLandmarkPresentation() {
|
||||
sourceLandmarkOverlay.clearPoints();
|
||||
magnifierLandmarkOverlay.clearPoints();
|
||||
magnifierImage.setImageDrawable(null);
|
||||
magnifierLandmarkBadge.setVisibility(View.GONE);
|
||||
magnifierCard.setVisibility(View.GONE);
|
||||
recycle(magnifierBitmap);
|
||||
magnifierBitmap = null;
|
||||
}
|
||||
|
||||
private void rebuildSession(StillImageSessionSettings.Values requested) {
|
||||
if (sessionBusy) {
|
||||
return;
|
||||
}
|
||||
int version = ++imageVersion;
|
||||
Bitmap currentImage = imageBitmap;
|
||||
sessionBusy = true;
|
||||
candidates = null;
|
||||
selectedIndex = -1;
|
||||
faceOverlay.clearFace();
|
||||
clearLandmarkPresentation();
|
||||
setControlsEnabled(false);
|
||||
sessionStatus.setText(R.string.recognition_session_rebuilding);
|
||||
sessionStatus.setTextColor(color(R.color.home_text_secondary));
|
||||
if (currentImage != null) {
|
||||
setImageStatus(R.string.image_analyzing, R.color.home_text_secondary);
|
||||
}
|
||||
sdkExecutor.execute(() -> {
|
||||
replaceDetectionSession(requested);
|
||||
FaceImageProcessor.Result result = session != null && currentImage != null
|
||||
? FaceImageProcessor.detectWithLandmarks(session, currentImage) : null;
|
||||
boolean ready = session != null;
|
||||
postUi(() -> {
|
||||
if (imageVersion != version) {
|
||||
return;
|
||||
}
|
||||
sessionBusy = false;
|
||||
setControlsEnabled(ready);
|
||||
if (!ready) {
|
||||
sessionStatus.setText(R.string.recognition_session_failed);
|
||||
sessionStatus.setTextColor(color(R.color.liveness_fail));
|
||||
if (currentImage != null) {
|
||||
setImageStatus(R.string.compare_engine_failed, R.color.liveness_fail);
|
||||
}
|
||||
return;
|
||||
}
|
||||
savedSettings = requested;
|
||||
StillImageSessionSettings.save(this, requested);
|
||||
showSessionSummary(requested);
|
||||
if (currentImage != null) {
|
||||
candidates = result == null ? null : result.candidates;
|
||||
selectedIndex = candidates != null && candidates.length > 0 ? 0 : -1;
|
||||
faceOverlay.showFaces(currentImage.getWidth(), currentImage.getHeight(),
|
||||
result == null ? null : result.faceRects(), selectedIndex);
|
||||
finishDetectionUi(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void showSessionSummary(StillImageSessionSettings.Values settings) {
|
||||
sessionStatus.setText(getString(R.string.recognition_session_summary,
|
||||
settings.inputPx, settings.maxFaces, settings.minFacePx));
|
||||
sessionStatus.setTextColor(color(R.color.liveness_accent));
|
||||
}
|
||||
|
||||
private void selectParameters(StillImageSessionSettings.Values settings) {
|
||||
inputPxGroup.check(settings.inputPx == 320 ? R.id.inputPx320
|
||||
: settings.inputPx == 1280 ? R.id.inputPx1280 : R.id.inputPx640);
|
||||
maxFacesGroup.check(settings.maxFaces == 1 ? R.id.maxFaces1
|
||||
: settings.maxFaces == 3 ? R.id.maxFaces3
|
||||
: settings.maxFaces == 5 ? R.id.maxFaces5 : R.id.maxFaces10);
|
||||
minFaceGroup.check(settings.minFacePx == 48 ? R.id.minFace48
|
||||
: settings.minFacePx == 64 ? R.id.minFace64
|
||||
: settings.minFacePx == 128 ? R.id.minFace128 : R.id.minFace24);
|
||||
}
|
||||
|
||||
private StillImageSessionSettings.Values selectedSettings() {
|
||||
int inputId = inputPxGroup.getCheckedChipId();
|
||||
int inputPx = inputId == R.id.inputPx320 ? 320
|
||||
: inputId == R.id.inputPx1280 ? 1280 : 640;
|
||||
int maxId = maxFacesGroup.getCheckedChipId();
|
||||
int maxFaces = maxId == R.id.maxFaces1 ? 1 : maxId == R.id.maxFaces3 ? 3
|
||||
: maxId == R.id.maxFaces5 ? 5 : 10;
|
||||
int minId = minFaceGroup.getCheckedChipId();
|
||||
int minFacePx = minId == R.id.minFace48 ? 48 : minId == R.id.minFace64 ? 64
|
||||
: minId == R.id.minFace128 ? 128 : 24;
|
||||
return new StillImageSessionSettings.Values(inputPx, maxFaces, minFacePx);
|
||||
}
|
||||
|
||||
private void setSettingsExpanded(boolean expanded) {
|
||||
sessionSettingsContent.setVisibility(expanded ? View.VISIBLE : View.GONE);
|
||||
sessionSettingsToggleText.setText(expanded
|
||||
? R.string.recognition_settings_collapse
|
||||
: R.string.recognition_settings_expand);
|
||||
}
|
||||
|
||||
private void setTrackingSettingsExpanded(boolean expanded) {
|
||||
trackingSettingsContent.setVisibility(expanded ? View.VISIBLE : View.GONE);
|
||||
trackingSettingsToggleText.setText(expanded
|
||||
? R.string.recognition_settings_collapse
|
||||
: R.string.recognition_settings_expand);
|
||||
}
|
||||
|
||||
private void startVideoTracking() {
|
||||
if (!videoSelected || destroyed || trackingCameraController != null
|
||||
|| trackingStarting) {
|
||||
return;
|
||||
}
|
||||
setTrackingLoading(true);
|
||||
if (!engineReady) {
|
||||
showTrackingMessage(
|
||||
R.string.detection_tracking_initializing, R.color.white);
|
||||
return;
|
||||
}
|
||||
if (!cameraPermission.hasPermission()) {
|
||||
showTrackingMessage(
|
||||
R.string.msg_permission_required, R.color.liveness_fail);
|
||||
cameraPermission.requestAccess();
|
||||
return;
|
||||
}
|
||||
|
||||
trackingStarting = true;
|
||||
setTrackingControlsEnabled(false);
|
||||
showTrackingMessage(R.string.detection_tracking_initializing, R.color.white);
|
||||
int generation = ++trackingGeneration;
|
||||
FaceTrackingSessionSettings.Values settings = trackingSettings;
|
||||
FaceTrackingAnalyzer analyzer = new FaceTrackingAnalyzer(
|
||||
trackingGlOverlay, settings.mode, settings.inputPx,
|
||||
settings.maxFaces, settings.minFacePx,
|
||||
new FaceTrackingAnalyzer.Listener() {
|
||||
@Override
|
||||
public void onSessionReady() {
|
||||
postTrackingUi(generation, () -> {
|
||||
trackingStarting = false;
|
||||
setTrackingLoading(false);
|
||||
setTrackingControlsEnabled(engineReady);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStats(double fps, long latencyMs) {
|
||||
postTrackingUi(generation,
|
||||
() -> renderTrackingStats(fps, latencyMs));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSessionError() {
|
||||
postTrackingUi(generation, () -> {
|
||||
trackingStarting = false;
|
||||
setTrackingLoading(false);
|
||||
setTrackingControlsEnabled(engineReady);
|
||||
showTrackingMessage(
|
||||
R.string.detection_tracking_session_failed,
|
||||
R.color.liveness_fail);
|
||||
});
|
||||
}
|
||||
});
|
||||
trackingAnalyzer = analyzer;
|
||||
trackingCameraController = new CameraPreviewController(
|
||||
this, this, trackingPreview, trackingExecutor, analyzer,
|
||||
new CameraPreviewController.Listener() {
|
||||
@Override
|
||||
public void onCameraReady(boolean frontCamera) {
|
||||
if (!isCurrentTracking(generation)) {
|
||||
return;
|
||||
}
|
||||
trackingFrontCamera = frontCamera;
|
||||
analyzer.setMirrored(frontCamera);
|
||||
showTrackingMessage(
|
||||
R.string.detection_tracking_initializing, R.color.white);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLensChanged(boolean frontCamera) {
|
||||
if (!isCurrentTracking(generation)) {
|
||||
return;
|
||||
}
|
||||
trackingFrontCamera = frontCamera;
|
||||
analyzer.setMirrored(frontCamera);
|
||||
showTrackingMessage(
|
||||
R.string.detection_tracking_initializing, R.color.white);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCameraError(int messageRes) {
|
||||
if (!isCurrentTracking(generation)) {
|
||||
return;
|
||||
}
|
||||
trackingStarting = false;
|
||||
setTrackingLoading(false);
|
||||
setTrackingControlsEnabled(engineReady);
|
||||
showTrackingMessage(messageRes, R.color.liveness_fail);
|
||||
}
|
||||
});
|
||||
trackingCameraController.start(trackingFrontCamera);
|
||||
}
|
||||
|
||||
private void stopVideoTracking() {
|
||||
trackingGeneration++;
|
||||
trackingStarting = false;
|
||||
setTrackingLoading(false);
|
||||
if (trackingCameraController != null) {
|
||||
trackingCameraController.stop();
|
||||
trackingCameraController = null;
|
||||
}
|
||||
if (trackingAnalyzer != null) {
|
||||
FaceTrackingAnalyzer toRelease = trackingAnalyzer;
|
||||
trackingAnalyzer = null;
|
||||
trackingExecutor.execute(toRelease::release);
|
||||
}
|
||||
if (trackingGlOverlay != null) {
|
||||
trackingGlOverlay.clearTracking();
|
||||
}
|
||||
if (!destroyed) {
|
||||
setTrackingControlsEnabled(engineReady);
|
||||
}
|
||||
}
|
||||
|
||||
private void flipTrackingCamera() {
|
||||
if (trackingCameraController == null || !trackingCameraController.flipCamera()) {
|
||||
Toast.makeText(this, R.string.msg_camera_unavailable, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
private void applyTrackingSettings(FaceTrackingSessionSettings.Values requested) {
|
||||
if (!engineReady || trackingStarting) {
|
||||
return;
|
||||
}
|
||||
if (sameTrackingSettings(requested, trackingSettings)) {
|
||||
return;
|
||||
}
|
||||
setTrackingLoading(true);
|
||||
stopVideoTracking();
|
||||
trackingSettings = requested;
|
||||
FaceTrackingSessionSettings.save(this, requested);
|
||||
showTrackingSummary(requested);
|
||||
showTrackingMessage(
|
||||
R.string.detection_tracking_restarting, R.color.liveness_warn);
|
||||
if (videoSelected) {
|
||||
startVideoTracking();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isCurrentTracking(int generation) {
|
||||
return !destroyed && videoSelected && generation == trackingGeneration
|
||||
&& trackingAnalyzer != null;
|
||||
}
|
||||
|
||||
private void postTrackingUi(int generation, Runnable action) {
|
||||
runOnUiThread(() -> {
|
||||
if (isCurrentTracking(generation)) {
|
||||
action.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderTrackingStats(double fps, long latencyMs) {
|
||||
trackingStatus.setText(getString(
|
||||
R.string.detection_tracking_stats, fps, latencyMs));
|
||||
trackingStatus.setTextColor(color(R.color.liveness_accent));
|
||||
}
|
||||
|
||||
private void showTrackingMessage(int statusRes, @ColorRes int colorRes) {
|
||||
trackingStatus.setText(statusRes);
|
||||
trackingStatus.setTextColor(color(colorRes));
|
||||
}
|
||||
|
||||
private void showTrackingSummary(FaceTrackingSessionSettings.Values settings) {
|
||||
trackingSessionStatus.setText(getString(R.string.detection_tracking_summary,
|
||||
trackingModeLabel(settings.mode), settings.inputPx,
|
||||
settings.maxFaces, settings.minFacePx));
|
||||
trackingSessionStatus.setTextColor(color(R.color.liveness_accent));
|
||||
}
|
||||
|
||||
private String trackingModeLabel(int mode) {
|
||||
return getString(mode == InspireFace.DETECT_MODE_TRACK_BY_DETECTION
|
||||
? R.string.detection_tracking_mode_tbd
|
||||
: R.string.detection_tracking_mode_light);
|
||||
}
|
||||
|
||||
private void selectTrackingParameters(FaceTrackingSessionSettings.Values settings) {
|
||||
suppressTrackingSettingChanges = true;
|
||||
trackingModeGroup.check(settings.mode == InspireFace.DETECT_MODE_TRACK_BY_DETECTION
|
||||
? R.id.trackingModeTbd : R.id.trackingModeLight);
|
||||
trackingInputPxGroup.check(settings.inputPx == 320 ? R.id.trackingInputPx320
|
||||
: settings.inputPx == 1280
|
||||
? R.id.trackingInputPx1280 : R.id.trackingInputPx640);
|
||||
trackingMaxFacesGroup.check(settings.maxFaces == 1 ? R.id.trackingMaxFaces1
|
||||
: settings.maxFaces == 3 ? R.id.trackingMaxFaces3
|
||||
: settings.maxFaces == 5
|
||||
? R.id.trackingMaxFaces5 : R.id.trackingMaxFaces10);
|
||||
trackingMinFaceGroup.check(settings.minFacePx == 48 ? R.id.trackingMinFace48
|
||||
: settings.minFacePx == 64 ? R.id.trackingMinFace64
|
||||
: settings.minFacePx == 128
|
||||
? R.id.trackingMinFace128 : R.id.trackingMinFace24);
|
||||
suppressTrackingSettingChanges = false;
|
||||
}
|
||||
|
||||
private void configureTrackingAutoApply() {
|
||||
trackingModeGroup.setOnCheckedStateChangeListener(
|
||||
(group, checkedIds) -> onTrackingSettingChanged());
|
||||
trackingInputPxGroup.setOnCheckedStateChangeListener(
|
||||
(group, checkedIds) -> onTrackingSettingChanged());
|
||||
trackingMaxFacesGroup.setOnCheckedStateChangeListener(
|
||||
(group, checkedIds) -> onTrackingSettingChanged());
|
||||
trackingMinFaceGroup.setOnCheckedStateChangeListener(
|
||||
(group, checkedIds) -> onTrackingSettingChanged());
|
||||
}
|
||||
|
||||
private void onTrackingSettingChanged() {
|
||||
if (!suppressTrackingSettingChanges) {
|
||||
applyTrackingSettings(selectedTrackingSettings());
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean sameTrackingSettings(
|
||||
FaceTrackingSessionSettings.Values left,
|
||||
FaceTrackingSessionSettings.Values right) {
|
||||
return left.mode == right.mode && left.inputPx == right.inputPx
|
||||
&& left.maxFaces == right.maxFaces
|
||||
&& left.minFacePx == right.minFacePx;
|
||||
}
|
||||
|
||||
private FaceTrackingSessionSettings.Values selectedTrackingSettings() {
|
||||
int mode = trackingModeGroup.getCheckedChipId() == R.id.trackingModeTbd
|
||||
? InspireFace.DETECT_MODE_TRACK_BY_DETECTION
|
||||
: InspireFace.DETECT_MODE_LIGHT_TRACK;
|
||||
int inputId = trackingInputPxGroup.getCheckedChipId();
|
||||
int inputPx = inputId == R.id.trackingInputPx320 ? 320
|
||||
: inputId == R.id.trackingInputPx1280 ? 1280 : 640;
|
||||
int maxId = trackingMaxFacesGroup.getCheckedChipId();
|
||||
int maxFaces = maxId == R.id.trackingMaxFaces1 ? 1
|
||||
: maxId == R.id.trackingMaxFaces3 ? 3
|
||||
: maxId == R.id.trackingMaxFaces5 ? 5 : 10;
|
||||
int minId = trackingMinFaceGroup.getCheckedChipId();
|
||||
int minFacePx = minId == R.id.trackingMinFace48 ? 48
|
||||
: minId == R.id.trackingMinFace64 ? 64
|
||||
: minId == R.id.trackingMinFace128 ? 128 : 24;
|
||||
return new FaceTrackingSessionSettings.Values(
|
||||
mode, inputPx, maxFaces, minFacePx);
|
||||
}
|
||||
|
||||
private void setControlsEnabled(boolean enabled) {
|
||||
choosePhotoButton.setEnabled(enabled);
|
||||
applySessionButton.setEnabled(enabled);
|
||||
resetSessionButton.setEnabled(enabled);
|
||||
landmarkSwitch.setEnabled(enabled);
|
||||
setChipGroupEnabled(inputPxGroup, enabled);
|
||||
setChipGroupEnabled(maxFacesGroup, enabled);
|
||||
setChipGroupEnabled(minFaceGroup, enabled);
|
||||
}
|
||||
|
||||
private void setTrackingControlsEnabled(boolean enabled) {
|
||||
resetTrackingButton.setEnabled(enabled);
|
||||
setChipGroupEnabled(trackingModeGroup, enabled);
|
||||
setChipGroupEnabled(trackingInputPxGroup, enabled);
|
||||
setChipGroupEnabled(trackingMaxFacesGroup, enabled);
|
||||
setChipGroupEnabled(trackingMinFaceGroup, enabled);
|
||||
}
|
||||
|
||||
private void setTrackingLoading(boolean loading) {
|
||||
trackingLoadingIndicator.setVisibility(loading ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
private static void setChipGroupEnabled(ChipGroup group, boolean enabled) {
|
||||
for (int i = 0; i < group.getChildCount(); i++) {
|
||||
group.getChildAt(i).setEnabled(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
private void setImageStatus(int textRes, @ColorRes int colorRes) {
|
||||
imageStatus.setText(textRes);
|
||||
imageStatus.setTextColor(color(colorRes));
|
||||
}
|
||||
|
||||
private int color(@ColorRes int colorRes) {
|
||||
return ContextCompat.getColor(this, colorRes);
|
||||
}
|
||||
|
||||
private void applyWindowInsets() {
|
||||
View root = findViewById(R.id.detectionRoot);
|
||||
int left = root.getPaddingLeft();
|
||||
int top = root.getPaddingTop();
|
||||
int right = root.getPaddingRight();
|
||||
int bottom = root.getPaddingBottom();
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root, (v, insets) -> {
|
||||
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
|
||||
v.setPadding(left + bars.left, top + bars.top,
|
||||
right + bars.right, bottom + bars.bottom);
|
||||
return insets;
|
||||
});
|
||||
}
|
||||
|
||||
private void postUi(Runnable action) {
|
||||
runOnUiThread(() -> {
|
||||
if (!destroyed && !isFinishing()) {
|
||||
action.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void recycle(@Nullable Bitmap bitmap) {
|
||||
if (bitmap != null && !bitmap.isRecycled()) {
|
||||
bitmap.recycle();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
cameraPermission.onResume();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
trackingGlOverlay.onResume();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
trackingGlOverlay.onPause();
|
||||
super.onStop();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
cameraPermission.close();
|
||||
destroyed = true;
|
||||
engineReady = false;
|
||||
imageVersion++;
|
||||
stopVideoTracking();
|
||||
Bitmap imageToRecycle = imageBitmap;
|
||||
Bitmap magnifierToRecycle = magnifierBitmap;
|
||||
imageBitmap = null;
|
||||
magnifierBitmap = null;
|
||||
sdkExecutor.execute(() -> {
|
||||
recycle(imageToRecycle);
|
||||
recycle(magnifierToRecycle);
|
||||
if (session != null) {
|
||||
FaceEngine.releaseSession(session);
|
||||
session = null;
|
||||
}
|
||||
});
|
||||
sdkExecutor.shutdown();
|
||||
trackingExecutor.shutdown();
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
package com.example.inspireface_example;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.text.Editable;
|
||||
import android.text.TextWatcher;
|
||||
import android.util.LruCache;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.BaseAdapter;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.ListView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import com.example.inspireface_example.face.FaceImageProcessor;
|
||||
import com.example.inspireface_example.face.FaceRecord;
|
||||
import com.example.inspireface_example.face.FaceRepository;
|
||||
import com.example.inspireface_example.face.ImageBitmapLoader;
|
||||
import com.example.inspireface_example.view.FaceCaptureActivity;
|
||||
import com.example.inspireface_example.view.FaceEngine;
|
||||
import com.example.inspireface_example.widget.FaceImageOverlayView;
|
||||
import com.google.android.material.button.MaterialButton;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.insightface.sdk.inspireface.base.FaceFeature;
|
||||
import com.insightface.sdk.inspireface.base.Session;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/** CRUD demo for a model-isolated persistent FeatureHub and its face crops. */
|
||||
public class FaceManagementActivity extends AppCompatActivity {
|
||||
|
||||
private static final int MAX_IMAGE_DIMENSION = 2048;
|
||||
|
||||
private final ExecutorService sdkExecutor = Executors.newSingleThreadExecutor();
|
||||
private final FaceRecordAdapter adapter = new FaceRecordAdapter();
|
||||
|
||||
private FaceModelPrefs.Model model;
|
||||
private FaceRepository repository;
|
||||
private Session session;
|
||||
private MaterialButton addButton;
|
||||
private EditText searchInput;
|
||||
private TextView faceCount;
|
||||
private View loadingIndicator;
|
||||
private View emptyState;
|
||||
private volatile boolean destroyed;
|
||||
|
||||
// Editor dialog state
|
||||
private AlertDialog editorDialog;
|
||||
private FaceRecord editingRecord;
|
||||
private ImageView editorImage;
|
||||
private FaceImageOverlayView editorOverlay;
|
||||
private View editorPlaceholder;
|
||||
private TextView editorStatus;
|
||||
private EditText editorName;
|
||||
private MaterialButton captureFaceButton;
|
||||
private MaterialButton chooseImageButton;
|
||||
private Button saveButton;
|
||||
private Bitmap editorBitmap;
|
||||
private FaceImageProcessor.Candidate[] editorCandidates;
|
||||
private int editorSelectedIndex = -1;
|
||||
private volatile int editorVersion;
|
||||
private boolean editorSaving;
|
||||
|
||||
private final ActivityResultLauncher<String> editorImagePicker =
|
||||
registerForActivityResult(new ActivityResultContracts.GetContent(), uri -> {
|
||||
if (uri != null && editorDialog != null) {
|
||||
loadEditorImage(uri);
|
||||
}
|
||||
});
|
||||
|
||||
private final ActivityResultLauncher<Intent> editorCamera =
|
||||
registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> {
|
||||
Intent data = result.getData();
|
||||
String path = data == null ? null
|
||||
: data.getStringExtra(FaceCaptureActivity.EXTRA_CAPTURE_PATH);
|
||||
if (result.getResultCode() == RESULT_OK && path != null) {
|
||||
if (editorDialog != null) {
|
||||
loadEditorCapture(path);
|
||||
} else {
|
||||
new File(path).delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView())
|
||||
.setAppearanceLightNavigationBars(false);
|
||||
setContentView(R.layout.activity_face_management);
|
||||
applyWindowInsets();
|
||||
|
||||
model = FaceModelPrefs.get(this);
|
||||
repository = new FaceRepository(this, model);
|
||||
addButton = findViewById(R.id.btnAddFace);
|
||||
searchInput = findViewById(R.id.searchInput);
|
||||
faceCount = findViewById(R.id.faceCount);
|
||||
loadingIndicator = findViewById(R.id.loadingIndicator);
|
||||
emptyState = findViewById(R.id.emptyState);
|
||||
ListView faceList = findViewById(R.id.faceList);
|
||||
faceList.setAdapter(adapter);
|
||||
faceList.setEmptyView(emptyState);
|
||||
emptyState.setVisibility(View.GONE);
|
||||
|
||||
((TextView) findViewById(R.id.currentModel)).setText(
|
||||
getString(R.string.current_model, model.sdkName()));
|
||||
((TextView) findViewById(R.id.storageScope)).setText(
|
||||
getString(R.string.face_storage_scope, model.sdkName()));
|
||||
findViewById(R.id.btnBack).setOnClickListener(v -> finish());
|
||||
addButton.setOnClickListener(v -> openEditor(null));
|
||||
searchInput.addTextChangedListener(new SimpleTextWatcher() {
|
||||
@Override
|
||||
public void afterTextChanged(Editable editable) {
|
||||
refreshRecords();
|
||||
}
|
||||
});
|
||||
|
||||
sdkExecutor.execute(this::initializeLibrary);
|
||||
}
|
||||
|
||||
private void initializeLibrary() {
|
||||
boolean ready = FaceEngine.ensureLaunched(this);
|
||||
if (ready) {
|
||||
session = FaceEngine.createRecognitionSession();
|
||||
ready = session != null;
|
||||
}
|
||||
if (ready) {
|
||||
// Keep an active session for the whole lifetime of FeatureHub. FaceEngine then
|
||||
// cannot terminate or switch the loaded model while this repository is open.
|
||||
ready = repository.open();
|
||||
}
|
||||
if (!ready) {
|
||||
repository.close();
|
||||
if (session != null) {
|
||||
FaceEngine.releaseSession(session);
|
||||
session = null;
|
||||
}
|
||||
}
|
||||
boolean finalReady = ready;
|
||||
postUi(() -> {
|
||||
loadingIndicator.setVisibility(View.GONE);
|
||||
addButton.setEnabled(finalReady);
|
||||
if (finalReady) {
|
||||
refreshRecords();
|
||||
} else {
|
||||
emptyState.setVisibility(View.VISIBLE);
|
||||
Toast.makeText(this, R.string.face_library_failed, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void refreshRecords() {
|
||||
if (repository == null) {
|
||||
return;
|
||||
}
|
||||
String keyword = searchInput == null ? null : searchInput.getText().toString();
|
||||
List<FaceRecord> records = repository.query(keyword);
|
||||
adapter.setRecords(records);
|
||||
faceCount.setText(getString(R.string.face_list_count,
|
||||
keyword == null || keyword.trim().isEmpty()
|
||||
? records.size() : repository.query(null).size()));
|
||||
}
|
||||
|
||||
private void openEditor(@Nullable FaceRecord record) {
|
||||
if (session == null || editorDialog != null) {
|
||||
return;
|
||||
}
|
||||
editingRecord = record;
|
||||
editorCandidates = null;
|
||||
editorSelectedIndex = -1;
|
||||
editorSaving = false;
|
||||
editorVersion++;
|
||||
|
||||
View content = getLayoutInflater().inflate(R.layout.dialog_face_editor, null, false);
|
||||
editorImage = content.findViewById(R.id.editorImage);
|
||||
editorOverlay = content.findViewById(R.id.editorFaceOverlay);
|
||||
editorPlaceholder = content.findViewById(R.id.editorPlaceholder);
|
||||
editorStatus = content.findViewById(R.id.editorImageStatus);
|
||||
editorName = content.findViewById(R.id.editorName);
|
||||
captureFaceButton = content.findViewById(R.id.btnCaptureEditorFace);
|
||||
chooseImageButton = content.findViewById(R.id.btnChooseEditorImage);
|
||||
editorOverlay.setOnFaceSelectedListener(this::selectEditorFace);
|
||||
|
||||
if (record != null) {
|
||||
editorName.setText(record.name);
|
||||
editorBitmap = BitmapFactory.decodeFile(record.cropPath);
|
||||
if (editorBitmap != null) {
|
||||
editorImage.setImageBitmap(editorBitmap);
|
||||
editorPlaceholder.setVisibility(View.GONE);
|
||||
setEditorStatus(getString(R.string.stored_face_crop), true);
|
||||
}
|
||||
} else {
|
||||
editorBitmap = null;
|
||||
}
|
||||
|
||||
editorDialog = new MaterialAlertDialogBuilder(this)
|
||||
.setTitle(record == null ? R.string.add_face_title : R.string.edit_face_title)
|
||||
.setView(content)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setPositiveButton(R.string.save, null)
|
||||
.create();
|
||||
editorDialog.setOnShowListener(ignored -> {
|
||||
saveButton = editorDialog.getButton(AlertDialog.BUTTON_POSITIVE);
|
||||
saveButton.setEnabled(record != null);
|
||||
saveButton.setOnClickListener(v -> saveEditor());
|
||||
captureFaceButton.setOnClickListener(v -> editorCamera.launch(
|
||||
new Intent(this, FaceCaptureActivity.class)));
|
||||
chooseImageButton.setOnClickListener(v -> editorImagePicker.launch("image/*"));
|
||||
});
|
||||
editorDialog.setOnDismissListener(ignored -> clearEditorState());
|
||||
editorDialog.show();
|
||||
}
|
||||
|
||||
private void loadEditorImage(Uri uri) {
|
||||
loadEditorBitmap(() -> ImageBitmapLoader.decode(
|
||||
this, uri, MAX_IMAGE_DIMENSION), null);
|
||||
}
|
||||
|
||||
private void loadEditorCapture(String path) {
|
||||
File temporaryCapture = new File(path);
|
||||
loadEditorBitmap(() -> {
|
||||
Bitmap bitmap = BitmapFactory.decodeFile(temporaryCapture.getAbsolutePath());
|
||||
if (bitmap == null) {
|
||||
throw new IOException("Unable to decode camera capture");
|
||||
}
|
||||
return bitmap;
|
||||
}, temporaryCapture);
|
||||
}
|
||||
|
||||
private void loadEditorBitmap(EditorBitmapSource source,
|
||||
@Nullable File deleteAfterDecode) {
|
||||
int requestVersion = ++editorVersion;
|
||||
editorSelectedIndex = -1;
|
||||
editorOverlay.clearFace();
|
||||
saveButton.setEnabled(false);
|
||||
setEditorStatus(getString(R.string.image_analyzing), false);
|
||||
sdkExecutor.execute(() -> {
|
||||
Bitmap bitmap = null;
|
||||
FaceImageProcessor.Result result = null;
|
||||
try {
|
||||
bitmap = source.decode();
|
||||
if (editorVersion != requestVersion || destroyed) {
|
||||
bitmap.recycle();
|
||||
return;
|
||||
}
|
||||
result = FaceImageProcessor.detect(session, bitmap, true);
|
||||
} catch (Exception ignored) {
|
||||
// The UI maps a null result to a load failure below.
|
||||
} finally {
|
||||
if (deleteAfterDecode != null) {
|
||||
deleteAfterDecode.delete();
|
||||
}
|
||||
}
|
||||
Bitmap deliveredBitmap = bitmap;
|
||||
FaceImageProcessor.Result deliveredResult = result;
|
||||
// Always deliver detector ownership to the UI callback. applyEditorImage also
|
||||
// handles a dismissed/destroyed editor and recycles an obsolete result.
|
||||
runOnUiThread(() -> applyEditorImage(
|
||||
requestVersion, deliveredBitmap, deliveredResult));
|
||||
});
|
||||
}
|
||||
|
||||
private void applyEditorImage(int requestVersion, @Nullable Bitmap bitmap,
|
||||
@Nullable FaceImageProcessor.Result result) {
|
||||
if (editorDialog == null || editorVersion != requestVersion) {
|
||||
recycleEditorResult(bitmap, result);
|
||||
return;
|
||||
}
|
||||
Bitmap previousBitmap = editorBitmap;
|
||||
FaceImageProcessor.Candidate[] previousCandidates = editorCandidates;
|
||||
editorBitmap = bitmap;
|
||||
editorCandidates = result == null ? null : result.candidates;
|
||||
editorSelectedIndex = editorCandidates != null && editorCandidates.length > 0 ? 0 : -1;
|
||||
if (bitmap != null) {
|
||||
editorImage.setImageBitmap(bitmap);
|
||||
editorPlaceholder.setVisibility(View.GONE);
|
||||
editorOverlay.showFaces(bitmap.getWidth(), bitmap.getHeight(),
|
||||
result == null ? null : result.faceRects(), editorSelectedIndex);
|
||||
} else {
|
||||
editorImage.setImageDrawable(null);
|
||||
editorPlaceholder.setVisibility(View.VISIBLE);
|
||||
editorOverlay.clearFace();
|
||||
}
|
||||
recycleEditorAssets(previousBitmap, previousCandidates);
|
||||
if (result == null) {
|
||||
setEditorStatus(getString(R.string.image_load_failed), false);
|
||||
} else if (result.status == FaceImageProcessor.Status.NO_FACE) {
|
||||
setEditorStatus(getString(R.string.image_no_face), false);
|
||||
} else if (result.status != FaceImageProcessor.Status.READY) {
|
||||
setEditorStatus(getString(R.string.face_extract_failed), false);
|
||||
} else {
|
||||
updateEditorSelectionState();
|
||||
}
|
||||
}
|
||||
|
||||
private void selectEditorFace(int index) {
|
||||
if (editorCandidates == null || index < 0 || index >= editorCandidates.length) {
|
||||
return;
|
||||
}
|
||||
editorSelectedIndex = index;
|
||||
editorOverlay.setSelectedIndex(index);
|
||||
updateEditorSelectionState();
|
||||
}
|
||||
|
||||
private void updateEditorSelectionState() {
|
||||
FaceImageProcessor.Candidate candidate = selectedEditorCandidate();
|
||||
boolean valid = candidate != null && candidate.feature != null && candidate.crop != null;
|
||||
if (editorCandidates != null && editorCandidates.length > 1) {
|
||||
setEditorStatus(getString(R.string.image_face_selected,
|
||||
editorSelectedIndex + 1, editorCandidates.length), valid);
|
||||
} else {
|
||||
setEditorStatus(getString(valid ? R.string.image_face_ready
|
||||
: R.string.face_extract_failed), valid);
|
||||
}
|
||||
saveButton.setEnabled(valid);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private FaceImageProcessor.Candidate selectedEditorCandidate() {
|
||||
return editorCandidates != null && editorSelectedIndex >= 0
|
||||
&& editorSelectedIndex < editorCandidates.length
|
||||
? editorCandidates[editorSelectedIndex] : null;
|
||||
}
|
||||
|
||||
private void saveEditor() {
|
||||
FaceImageProcessor.Candidate selected = selectedEditorCandidate();
|
||||
if (editingRecord == null && selected == null) {
|
||||
return;
|
||||
}
|
||||
String name = editorName.getText() == null ? "" : editorName.getText().toString();
|
||||
FaceFeature feature = selected == null ? null : selected.feature;
|
||||
Bitmap crop = selected == null ? null : selected.crop;
|
||||
long editingId = editingRecord == null ? -1L : editingRecord.id;
|
||||
editorSaving = true;
|
||||
editorDialog.setCancelable(false);
|
||||
saveButton.setEnabled(false);
|
||||
editorDialog.getButton(AlertDialog.BUTTON_NEGATIVE).setEnabled(false);
|
||||
captureFaceButton.setEnabled(false);
|
||||
chooseImageButton.setEnabled(false);
|
||||
setEditorStatus(getString(R.string.saving_face), true);
|
||||
|
||||
sdkExecutor.execute(() -> {
|
||||
boolean success;
|
||||
if (editingId < 0) {
|
||||
success = repository.insert(name, feature, crop).success;
|
||||
} else {
|
||||
success = repository.update(editingId, name, feature, crop);
|
||||
}
|
||||
postUi(() -> {
|
||||
editorSaving = false;
|
||||
if (success) {
|
||||
Toast.makeText(this, R.string.face_saved, Toast.LENGTH_SHORT).show();
|
||||
adapter.clearCropCache();
|
||||
editorDialog.dismiss();
|
||||
refreshRecords();
|
||||
} else {
|
||||
editorDialog.setCancelable(true);
|
||||
editorDialog.getButton(AlertDialog.BUTTON_NEGATIVE).setEnabled(true);
|
||||
captureFaceButton.setEnabled(true);
|
||||
chooseImageButton.setEnabled(true);
|
||||
saveButton.setEnabled(editingRecord != null || selectedEditorCandidate() != null);
|
||||
setEditorStatus(getString(R.string.face_save_failed), false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void confirmDelete(FaceRecord record) {
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.delete_face_title)
|
||||
.setMessage(getString(R.string.delete_face_message, record.name, model.sdkName()))
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setPositiveButton(R.string.delete, (dialog, which) -> deleteRecord(record))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void deleteRecord(FaceRecord record) {
|
||||
addButton.setEnabled(false);
|
||||
sdkExecutor.execute(() -> {
|
||||
boolean deleted = repository.delete(record.id);
|
||||
postUi(() -> {
|
||||
addButton.setEnabled(true);
|
||||
Toast.makeText(this, deleted ? R.string.face_deleted
|
||||
: R.string.face_delete_failed, Toast.LENGTH_SHORT).show();
|
||||
if (deleted) {
|
||||
adapter.removeCrop(record.cropPath);
|
||||
refreshRecords();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void setEditorStatus(CharSequence message, boolean positive) {
|
||||
editorStatus.setText(message);
|
||||
editorStatus.setTextColor(ContextCompat.getColor(this,
|
||||
positive ? R.color.liveness_accent : R.color.home_text_secondary));
|
||||
}
|
||||
|
||||
private void clearEditorState() {
|
||||
editorVersion++;
|
||||
if (!editorSaving) {
|
||||
recycleEditorAssets(editorBitmap, editorCandidates);
|
||||
}
|
||||
editorDialog = null;
|
||||
editingRecord = null;
|
||||
editorImage = null;
|
||||
editorOverlay = null;
|
||||
editorPlaceholder = null;
|
||||
editorStatus = null;
|
||||
editorName = null;
|
||||
captureFaceButton = null;
|
||||
chooseImageButton = null;
|
||||
saveButton = null;
|
||||
editorBitmap = null;
|
||||
editorCandidates = null;
|
||||
editorSelectedIndex = -1;
|
||||
}
|
||||
|
||||
private static void recycleEditorResult(@Nullable Bitmap bitmap,
|
||||
@Nullable FaceImageProcessor.Result result) {
|
||||
recycleEditorAssets(bitmap, result == null ? null : result.candidates);
|
||||
}
|
||||
|
||||
private static void recycleEditorAssets(@Nullable Bitmap bitmap,
|
||||
@Nullable FaceImageProcessor.Candidate[] candidates) {
|
||||
FaceImageProcessor.recycleCrops(candidates, -1);
|
||||
if (bitmap != null && !bitmap.isRecycled()) {
|
||||
bitmap.recycle();
|
||||
}
|
||||
}
|
||||
|
||||
private void applyWindowInsets() {
|
||||
View root = findViewById(R.id.managementRoot);
|
||||
int left = root.getPaddingLeft();
|
||||
int top = root.getPaddingTop();
|
||||
int right = root.getPaddingRight();
|
||||
int bottom = root.getPaddingBottom();
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root, (v, insets) -> {
|
||||
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
|
||||
v.setPadding(left + bars.left, top + bars.top,
|
||||
right + bars.right, bottom + bars.bottom);
|
||||
return insets;
|
||||
});
|
||||
}
|
||||
|
||||
private void postUi(Runnable action) {
|
||||
runOnUiThread(() -> {
|
||||
if (!destroyed && !isFinishing()) {
|
||||
action.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
destroyed = true;
|
||||
editorVersion++;
|
||||
Bitmap deferredEditorBitmap = editorBitmap;
|
||||
FaceImageProcessor.Candidate[] deferredEditorCandidates = editorCandidates;
|
||||
if (editorDialog != null && !editorSaving) {
|
||||
editorDialog.dismiss();
|
||||
}
|
||||
// The save task may still own the selected crop for JPEG compression. Queue a
|
||||
// second, idempotent cleanup behind all SDK tasks; this also covers dialog teardown.
|
||||
Bitmap bitmapToRecycle = deferredEditorBitmap;
|
||||
FaceImageProcessor.Candidate[] candidatesToRecycle = deferredEditorCandidates;
|
||||
sdkExecutor.execute(() -> {
|
||||
recycleEditorAssets(bitmapToRecycle, candidatesToRecycle);
|
||||
repository.close();
|
||||
if (session != null) {
|
||||
FaceEngine.releaseSession(session);
|
||||
session = null;
|
||||
}
|
||||
});
|
||||
sdkExecutor.shutdown();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private final class FaceRecordAdapter extends BaseAdapter {
|
||||
private final List<FaceRecord> records = new ArrayList<>();
|
||||
private final LruCache<String, Bitmap> cropCache = new LruCache<String, Bitmap>(8) {
|
||||
@Override
|
||||
protected int sizeOf(String key, Bitmap value) {
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
void setRecords(List<FaceRecord> newRecords) {
|
||||
records.clear();
|
||||
records.addAll(newRecords);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
void removeCrop(String path) {
|
||||
cropCache.remove(path);
|
||||
}
|
||||
|
||||
void clearCropCache() {
|
||||
cropCache.evictAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return records.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public FaceRecord getItem(int position) {
|
||||
return records.get(position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int position) {
|
||||
return getItem(position).id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasStableIds() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public View getView(int position, View convertView, ViewGroup parent) {
|
||||
ViewHolder holder;
|
||||
if (convertView == null) {
|
||||
convertView = LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.item_face_record, parent, false);
|
||||
holder = new ViewHolder(convertView);
|
||||
convertView.setTag(holder);
|
||||
} else {
|
||||
holder = (ViewHolder) convertView.getTag();
|
||||
}
|
||||
FaceRecord record = getItem(position);
|
||||
holder.name.setText(record.name);
|
||||
holder.id.setText(getString(R.string.face_id_format, record.id));
|
||||
Bitmap crop = cropCache.get(record.cropPath);
|
||||
if (crop == null || crop.isRecycled()) {
|
||||
crop = BitmapFactory.decodeFile(record.cropPath);
|
||||
if (crop != null) {
|
||||
cropCache.put(record.cropPath, crop);
|
||||
}
|
||||
}
|
||||
holder.crop.setImageBitmap(crop);
|
||||
holder.edit.setOnClickListener(v -> openEditor(record));
|
||||
holder.delete.setOnClickListener(v -> confirmDelete(record));
|
||||
return convertView;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ViewHolder {
|
||||
final ImageView crop;
|
||||
final TextView name;
|
||||
final TextView id;
|
||||
final View edit;
|
||||
final View delete;
|
||||
|
||||
ViewHolder(View root) {
|
||||
crop = root.findViewById(R.id.faceCrop);
|
||||
name = root.findViewById(R.id.faceName);
|
||||
id = root.findViewById(R.id.faceId);
|
||||
edit = root.findViewById(R.id.btnEditFace);
|
||||
delete = root.findViewById(R.id.btnDeleteFace);
|
||||
}
|
||||
}
|
||||
|
||||
private abstract static class SimpleTextWatcher implements TextWatcher {
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||
}
|
||||
}
|
||||
|
||||
private interface EditorBitmapSource {
|
||||
Bitmap decode() throws Exception;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.example.inspireface_example;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
/** Process-wide model selection shared by the home screen and all feature screens. */
|
||||
public final class FaceModelPrefs {
|
||||
|
||||
public enum Model {
|
||||
PIKACHU("Pikachu"),
|
||||
MEGATRON("Megatron");
|
||||
|
||||
private final String sdkName;
|
||||
|
||||
Model(String sdkName) {
|
||||
this.sdkName = sdkName;
|
||||
}
|
||||
|
||||
public String sdkName() {
|
||||
return sdkName;
|
||||
}
|
||||
|
||||
static Model fromStored(String value) {
|
||||
for (Model model : values()) {
|
||||
if (model.sdkName.equals(value)) {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
return MEGATRON;
|
||||
}
|
||||
}
|
||||
|
||||
private static final String PREFS = "settings";
|
||||
private static final String KEY_MODEL = "face_model";
|
||||
|
||||
private FaceModelPrefs() {
|
||||
}
|
||||
|
||||
/** Megatron remains the default to preserve the project's previous behavior. */
|
||||
public static Model get(Context context) {
|
||||
String value = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(KEY_MODEL, Model.MEGATRON.sdkName());
|
||||
return Model.fromStored(value);
|
||||
}
|
||||
|
||||
public static void set(Context context, Model model) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit().putString(KEY_MODEL, model.sdkName()).apply();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,918 @@
|
||||
package com.example.inspireface_example;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.graphics.RectF;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.ColorRes;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.camera.view.PreviewView;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import com.example.inspireface_example.face.FaceImageProcessor;
|
||||
import com.example.inspireface_example.face.FaceRecord;
|
||||
import com.example.inspireface_example.face.FaceRepository;
|
||||
import com.example.inspireface_example.face.ImageBitmapLoader;
|
||||
import com.example.inspireface_example.permission.CameraPermissionCoordinator;
|
||||
import com.example.inspireface_example.view.CameraPreviewController;
|
||||
import com.example.inspireface_example.view.FaceEngine;
|
||||
import com.example.inspireface_example.view.FaceOverlayView;
|
||||
import com.example.inspireface_example.view.RecognitionFaceAnalyzer;
|
||||
import com.example.inspireface_example.widget.FaceImageOverlayView;
|
||||
import com.google.android.material.button.MaterialButton;
|
||||
import com.google.android.material.chip.ChipGroup;
|
||||
import com.google.android.material.tabs.TabLayout;
|
||||
import com.insightface.sdk.inspireface.base.FaceFeature;
|
||||
import com.insightface.sdk.inspireface.base.Session;
|
||||
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/** Model-scoped 1:N recognition from selectable still-image faces or camera face 0. */
|
||||
public final class FaceRecognitionActivity extends AppCompatActivity {
|
||||
|
||||
private static final int MAX_IMAGE_DIMENSION = 2048;
|
||||
private static final float SELECTED_CROP_HIDE_AREA_RATIO = 0.15f;
|
||||
private static final float SELECTED_CROP_SCALE = 1.5f;
|
||||
|
||||
private final ExecutorService sdkExecutor = Executors.newSingleThreadExecutor();
|
||||
|
||||
private FaceModelPrefs.Model model;
|
||||
private FaceRepository repository;
|
||||
private Session session;
|
||||
private volatile boolean destroyed;
|
||||
private volatile int imageVersion;
|
||||
private boolean libraryEmpty;
|
||||
private boolean sessionBusy = true;
|
||||
private boolean recognitionReady;
|
||||
private StillImageSessionSettings.Values savedSettings;
|
||||
|
||||
private ChipGroup inputPxGroup;
|
||||
private ChipGroup maxFacesGroup;
|
||||
private ChipGroup minFaceGroup;
|
||||
private MaterialButton applySessionButton;
|
||||
private MaterialButton resetSessionButton;
|
||||
private MaterialButton choosePhotoButton;
|
||||
private TextView sessionStatus;
|
||||
private View emptyLibraryTip;
|
||||
private TextView emptyLibraryTipText;
|
||||
private ImageView imageView;
|
||||
private FaceImageOverlayView faceOverlay;
|
||||
private View selectedFaceCropCard;
|
||||
private ImageView selectedFaceCropView;
|
||||
private View imagePlaceholder;
|
||||
private TextView imageStatus;
|
||||
private View resultCard;
|
||||
private ImageView resultCropView;
|
||||
private TextView resultStatus;
|
||||
private TextView resultName;
|
||||
private TextView resultDetails;
|
||||
private View sessionSettingsContent;
|
||||
private TextView sessionSettingsToggleText;
|
||||
|
||||
private PreviewView videoPreview;
|
||||
private FaceOverlayView videoOverlay;
|
||||
private TextView videoStatus;
|
||||
private TextView videoName;
|
||||
private TextView videoDetails;
|
||||
private View flipVideoButton;
|
||||
private CameraPreviewController videoCameraController;
|
||||
private RecognitionFaceAnalyzer videoAnalyzer;
|
||||
private boolean videoSelected;
|
||||
private boolean videoStarting;
|
||||
private CameraPermissionCoordinator cameraPermission;
|
||||
private int videoGeneration;
|
||||
|
||||
private Bitmap imageBitmap;
|
||||
private Bitmap resultCropBitmap;
|
||||
private Bitmap selectedFaceCropBitmap;
|
||||
private FaceImageProcessor.Candidate[] candidates;
|
||||
private int selectedIndex = -1;
|
||||
|
||||
private final ActivityResultLauncher<String> photoPicker =
|
||||
registerForActivityResult(new ActivityResultContracts.GetContent(), uri -> {
|
||||
if (uri != null && !sessionBusy && session != null) {
|
||||
loadPhoto(uri);
|
||||
}
|
||||
});
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
cameraPermission = new CameraPermissionCoordinator(this,
|
||||
new CameraPermissionCoordinator.Listener() {
|
||||
@Override
|
||||
public void onCameraPermissionGranted() {
|
||||
startVideoRecognition();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCameraPermissionBlocked(boolean requiresSettings) {
|
||||
if (videoSelected) {
|
||||
showVideoError(R.string.msg_permission_required,
|
||||
requiresSettings
|
||||
? R.string.camera_permission_settings_hint
|
||||
: R.string.camera_permission_retry_hint);
|
||||
}
|
||||
}
|
||||
});
|
||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView())
|
||||
.setAppearanceLightNavigationBars(false);
|
||||
setContentView(R.layout.activity_face_recognition);
|
||||
applyWindowInsets();
|
||||
|
||||
model = FaceModelPrefs.get(this);
|
||||
repository = new FaceRepository(this, model);
|
||||
savedSettings = StillImageSessionSettings.load(this);
|
||||
bindViews();
|
||||
cameraPermission.bindRecoveryButton(
|
||||
findViewById(R.id.btnCameraPermissionAction));
|
||||
((TextView) findViewById(R.id.currentModel)).setText(
|
||||
getString(R.string.current_model, model.sdkName()));
|
||||
findViewById(R.id.btnBack).setOnClickListener(v -> finish());
|
||||
choosePhotoButton.setOnClickListener(v -> photoPicker.launch("image/*"));
|
||||
findViewById(R.id.recognitionImageCard)
|
||||
.setOnClickListener(v -> {
|
||||
if (!sessionBusy && session != null) {
|
||||
photoPicker.launch("image/*");
|
||||
}
|
||||
});
|
||||
faceOverlay.setOnFaceSelectedListener(this::selectFace);
|
||||
findViewById(R.id.sessionSettingsHeader).setOnClickListener(
|
||||
v -> setSettingsExpanded(
|
||||
sessionSettingsContent.getVisibility() != View.VISIBLE));
|
||||
applySessionButton.setOnClickListener(v -> rebuildSession(
|
||||
selectedSettings(), true));
|
||||
resetSessionButton.setOnClickListener(v -> {
|
||||
StillImageSessionSettings.Values defaults = StillImageSessionSettings.defaults();
|
||||
selectParameters(defaults);
|
||||
rebuildSession(defaults, true);
|
||||
});
|
||||
flipVideoButton.setOnClickListener(v -> flipVideoCamera());
|
||||
configureTabs();
|
||||
selectParameters(savedSettings);
|
||||
setSettingsExpanded(false);
|
||||
setControlsEnabled(false);
|
||||
sessionStatus.setText(R.string.recognition_session_rebuilding);
|
||||
sdkExecutor.execute(this::initializeRecognition);
|
||||
}
|
||||
|
||||
private void bindViews() {
|
||||
inputPxGroup = findViewById(R.id.inputPxGroup);
|
||||
maxFacesGroup = findViewById(R.id.maxFacesGroup);
|
||||
minFaceGroup = findViewById(R.id.minFaceGroup);
|
||||
applySessionButton = findViewById(R.id.btnApplySession);
|
||||
resetSessionButton = findViewById(R.id.btnResetSession);
|
||||
choosePhotoButton = findViewById(R.id.btnChooseRecognitionPhoto);
|
||||
sessionStatus = findViewById(R.id.sessionStatus);
|
||||
emptyLibraryTip = findViewById(R.id.emptyLibraryTip);
|
||||
emptyLibraryTipText = findViewById(R.id.emptyLibraryTipText);
|
||||
imageView = findViewById(R.id.recognitionImage);
|
||||
faceOverlay = findViewById(R.id.recognitionFaceOverlay);
|
||||
selectedFaceCropCard = findViewById(R.id.recognitionSelectedFaceCropCard);
|
||||
selectedFaceCropView = findViewById(R.id.recognitionSelectedFaceCrop);
|
||||
imagePlaceholder = findViewById(R.id.recognitionImagePlaceholder);
|
||||
imageStatus = findViewById(R.id.recognitionImageStatus);
|
||||
resultCard = findViewById(R.id.recognitionResultCard);
|
||||
resultCropView = findViewById(R.id.recognitionResultCrop);
|
||||
resultStatus = findViewById(R.id.recognitionResultStatus);
|
||||
resultName = findViewById(R.id.recognitionResultName);
|
||||
resultDetails = findViewById(R.id.recognitionResultDetails);
|
||||
sessionSettingsContent = findViewById(R.id.sessionSettingsContent);
|
||||
sessionSettingsToggleText = findViewById(R.id.sessionSettingsToggleText);
|
||||
videoPreview = findViewById(R.id.recognitionVideoPreview);
|
||||
videoOverlay = findViewById(R.id.recognitionVideoOverlay);
|
||||
videoStatus = findViewById(R.id.videoRecognitionStatus);
|
||||
videoName = findViewById(R.id.videoRecognitionName);
|
||||
videoDetails = findViewById(R.id.videoRecognitionDetails);
|
||||
flipVideoButton = findViewById(R.id.btnFlipRecognitionCamera);
|
||||
}
|
||||
|
||||
private void configureTabs() {
|
||||
View photoPanel = findViewById(R.id.photoRecognitionPanel);
|
||||
View videoPanel = findViewById(R.id.videoRecognitionPanel);
|
||||
((TabLayout) findViewById(R.id.recognitionTabs))
|
||||
.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
|
||||
@Override
|
||||
public void onTabSelected(TabLayout.Tab tab) {
|
||||
boolean photo = tab.getPosition() == 0;
|
||||
videoSelected = !photo;
|
||||
photoPanel.setVisibility(photo ? View.VISIBLE : View.GONE);
|
||||
videoPanel.setVisibility(photo ? View.GONE : View.VISIBLE);
|
||||
if (photo) {
|
||||
stopVideoRecognition();
|
||||
} else {
|
||||
startVideoRecognition();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTabUnselected(TabLayout.Tab tab) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTabReselected(TabLayout.Tab tab) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeRecognition() {
|
||||
StillImageSessionSettings.Values initialSettings = savedSettings;
|
||||
boolean ready = FaceEngine.ensureLaunched(this);
|
||||
if (ready) {
|
||||
session = FaceEngine.createRecognitionSession(
|
||||
initialSettings.inputPx,
|
||||
initialSettings.maxFaces,
|
||||
initialSettings.minFacePx);
|
||||
ready = session != null;
|
||||
}
|
||||
if (ready) {
|
||||
ready = repository.open();
|
||||
}
|
||||
if (!ready) {
|
||||
repository.close();
|
||||
if (session != null) {
|
||||
FaceEngine.releaseSession(session);
|
||||
session = null;
|
||||
}
|
||||
}
|
||||
boolean finalReady = ready;
|
||||
boolean empty = ready && repository.query(null).isEmpty();
|
||||
postUi(() -> {
|
||||
sessionBusy = false;
|
||||
recognitionReady = finalReady;
|
||||
libraryEmpty = empty;
|
||||
updateEmptyLibraryTip();
|
||||
setControlsEnabled(finalReady);
|
||||
if (finalReady) {
|
||||
showSessionSummary(initialSettings);
|
||||
if (videoSelected) {
|
||||
startVideoRecognition();
|
||||
}
|
||||
} else {
|
||||
sessionStatus.setText(R.string.recognition_session_failed);
|
||||
sessionStatus.setTextColor(color(R.color.liveness_fail));
|
||||
setImageStatus(R.string.compare_engine_failed, R.color.liveness_fail);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateEmptyLibraryTip() {
|
||||
emptyLibraryTip.setVisibility(libraryEmpty ? View.VISIBLE : View.GONE);
|
||||
if (libraryEmpty) {
|
||||
emptyLibraryTipText.setText(
|
||||
getString(R.string.recognition_empty_library, model.sdkName()));
|
||||
}
|
||||
}
|
||||
|
||||
private void loadPhoto(Uri uri) {
|
||||
int requestVersion = ++imageVersion;
|
||||
sessionBusy = true;
|
||||
candidates = null;
|
||||
selectedIndex = -1;
|
||||
faceOverlay.clearFace();
|
||||
clearSelectedFaceCrop();
|
||||
hideResult();
|
||||
setControlsEnabled(false);
|
||||
setImageStatus(R.string.image_analyzing, R.color.home_text_secondary);
|
||||
sdkExecutor.execute(() -> {
|
||||
Bitmap bitmap = null;
|
||||
FaceImageProcessor.Result result = null;
|
||||
try {
|
||||
bitmap = ImageBitmapLoader.decode(this, uri, MAX_IMAGE_DIMENSION);
|
||||
if (destroyed || imageVersion != requestVersion) {
|
||||
bitmap.recycle();
|
||||
return;
|
||||
}
|
||||
result = FaceImageProcessor.detect(session, bitmap, false);
|
||||
} catch (Exception ignored) {
|
||||
// A null result is rendered as a load failure.
|
||||
}
|
||||
Bitmap deliveredBitmap = bitmap;
|
||||
FaceImageProcessor.Result deliveredResult = result;
|
||||
runOnUiThread(() -> applyPhotoResult(
|
||||
requestVersion, deliveredBitmap, deliveredResult));
|
||||
});
|
||||
}
|
||||
|
||||
private void applyPhotoResult(int requestVersion, @Nullable Bitmap bitmap,
|
||||
@Nullable FaceImageProcessor.Result result) {
|
||||
if (destroyed || imageVersion != requestVersion) {
|
||||
recycle(bitmap);
|
||||
return;
|
||||
}
|
||||
Bitmap previous = imageBitmap;
|
||||
imageBitmap = bitmap;
|
||||
candidates = result == null ? null : result.candidates;
|
||||
selectedIndex = candidates != null && candidates.length > 0 ? 0 : -1;
|
||||
if (bitmap != null) {
|
||||
imageView.setImageBitmap(bitmap);
|
||||
imagePlaceholder.setVisibility(View.GONE);
|
||||
faceOverlay.showFaces(bitmap.getWidth(), bitmap.getHeight(),
|
||||
result == null ? null : result.faceRects(), selectedIndex);
|
||||
updateSelectedFaceCrop();
|
||||
} else {
|
||||
imageView.setImageDrawable(null);
|
||||
imagePlaceholder.setVisibility(View.VISIBLE);
|
||||
faceOverlay.clearFace();
|
||||
clearSelectedFaceCrop();
|
||||
}
|
||||
if (previous != bitmap) {
|
||||
recycle(previous);
|
||||
}
|
||||
finishDetectionUi(result);
|
||||
}
|
||||
|
||||
private void finishDetectionUi(@Nullable FaceImageProcessor.Result result) {
|
||||
sessionBusy = false;
|
||||
setControlsEnabled(session != null);
|
||||
if (result == null) {
|
||||
setImageStatus(R.string.image_load_failed, R.color.liveness_fail);
|
||||
} else if (result.status == FaceImageProcessor.Status.NO_FACE) {
|
||||
setImageStatus(R.string.image_no_face, R.color.liveness_fail);
|
||||
} else if (result.status != FaceImageProcessor.Status.READY
|
||||
|| candidates == null || candidates.length == 0) {
|
||||
setImageStatus(R.string.face_extract_failed, R.color.liveness_fail);
|
||||
} else {
|
||||
updateSelectedFaceStatus();
|
||||
recognizeSelectedFace();
|
||||
}
|
||||
}
|
||||
|
||||
private void selectFace(int index) {
|
||||
if (sessionBusy || candidates == null || index < 0 || index >= candidates.length) {
|
||||
return;
|
||||
}
|
||||
selectedIndex = index;
|
||||
faceOverlay.setSelectedIndex(index);
|
||||
updateSelectedFaceCrop();
|
||||
updateSelectedFaceStatus();
|
||||
recognizeSelectedFace();
|
||||
}
|
||||
|
||||
private void updateSelectedFaceStatus() {
|
||||
FaceImageProcessor.Candidate selected = selectedCandidate();
|
||||
if (selected == null || selected.feature == null) {
|
||||
setImageStatus(R.string.face_extract_failed, R.color.liveness_fail);
|
||||
} else if (candidates.length > 1) {
|
||||
imageStatus.setText(getString(R.string.image_face_selected,
|
||||
selectedIndex + 1, candidates.length));
|
||||
imageStatus.setTextColor(color(R.color.liveness_accent));
|
||||
} else {
|
||||
setImageStatus(R.string.image_face_ready, R.color.liveness_accent);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private FaceImageProcessor.Candidate selectedCandidate() {
|
||||
return candidates != null && selectedIndex >= 0 && selectedIndex < candidates.length
|
||||
? candidates[selectedIndex] : null;
|
||||
}
|
||||
|
||||
private void updateSelectedFaceCrop() {
|
||||
clearSelectedFaceCrop();
|
||||
FaceImageProcessor.Candidate selected = selectedCandidate();
|
||||
Bitmap source = imageBitmap;
|
||||
if (selected == null || source == null || source.isRecycled()) {
|
||||
return;
|
||||
}
|
||||
RectF face = selected.rect;
|
||||
float imageArea = (float) source.getWidth() * source.getHeight();
|
||||
float faceArea = Math.max(0f, face.width()) * Math.max(0f, face.height());
|
||||
if (imageArea <= 0f || faceArea / imageArea >= SELECTED_CROP_HIDE_AREA_RATIO) {
|
||||
return;
|
||||
}
|
||||
|
||||
int cropSize = Math.max(2, Math.round(
|
||||
Math.max(face.width(), face.height()) * SELECTED_CROP_SCALE));
|
||||
cropSize = Math.min(cropSize, Math.min(source.getWidth(), source.getHeight()));
|
||||
int left = Math.round(face.centerX() - cropSize / 2f);
|
||||
int top = Math.round(face.centerY() - cropSize / 2f);
|
||||
left = Math.max(0, Math.min(left, source.getWidth() - cropSize));
|
||||
top = Math.max(0, Math.min(top, source.getHeight() - cropSize));
|
||||
try {
|
||||
Bitmap crop = Bitmap.createBitmap(source, left, top, cropSize, cropSize);
|
||||
if (crop == source) {
|
||||
return;
|
||||
}
|
||||
selectedFaceCropBitmap = crop;
|
||||
selectedFaceCropView.setImageBitmap(crop);
|
||||
selectedFaceCropCard.setVisibility(View.VISIBLE);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// A malformed SDK rectangle simply leaves the optional preview hidden.
|
||||
}
|
||||
}
|
||||
|
||||
private void clearSelectedFaceCrop() {
|
||||
if (selectedFaceCropView != null) {
|
||||
selectedFaceCropView.setImageDrawable(null);
|
||||
}
|
||||
if (selectedFaceCropCard != null) {
|
||||
selectedFaceCropCard.setVisibility(View.GONE);
|
||||
}
|
||||
recycle(selectedFaceCropBitmap);
|
||||
selectedFaceCropBitmap = null;
|
||||
}
|
||||
|
||||
private void recognizeSelectedFace() {
|
||||
FaceImageProcessor.Candidate selected = selectedCandidate();
|
||||
if (selected == null || selected.feature == null) {
|
||||
hideResult();
|
||||
return;
|
||||
}
|
||||
if (libraryEmpty) {
|
||||
showEmptyLibraryResult();
|
||||
return;
|
||||
}
|
||||
int version = imageVersion;
|
||||
int faceIndex = selectedIndex;
|
||||
FaceFeature feature = selected.feature;
|
||||
showSearching();
|
||||
sdkExecutor.execute(() -> {
|
||||
FaceRepository.SearchResult search = repository.search(feature);
|
||||
Bitmap crop = search.matched && search.record != null
|
||||
? BitmapFactory.decodeFile(search.record.cropPath) : null;
|
||||
runOnUiThread(() -> applySearchResult(
|
||||
version, faceIndex, feature, search, crop));
|
||||
});
|
||||
}
|
||||
|
||||
private void applySearchResult(int version, int faceIndex, FaceFeature feature,
|
||||
FaceRepository.SearchResult search,
|
||||
@Nullable Bitmap crop) {
|
||||
FaceImageProcessor.Candidate selected = selectedCandidate();
|
||||
if (destroyed || imageVersion != version || selectedIndex != faceIndex
|
||||
|| selected == null || selected.feature != feature) {
|
||||
recycle(crop);
|
||||
return;
|
||||
}
|
||||
if (search.matched && search.record != null) {
|
||||
showMatch(search.record, search.confidence, search.threshold, crop);
|
||||
} else {
|
||||
recycle(crop);
|
||||
showNoMatch(search);
|
||||
}
|
||||
}
|
||||
|
||||
private void showSearching() {
|
||||
clearResultCrop();
|
||||
resultCard.setVisibility(View.VISIBLE);
|
||||
resultStatus.setText(R.string.recognition_searching);
|
||||
resultStatus.setTextColor(color(R.color.home_text_secondary));
|
||||
resultName.setText(R.string.recognition_result_name_unknown);
|
||||
resultDetails.setText(null);
|
||||
}
|
||||
|
||||
private void showMatch(FaceRecord record, float confidence,
|
||||
float threshold, @Nullable Bitmap crop) {
|
||||
clearResultCrop();
|
||||
resultCropBitmap = crop;
|
||||
resultCropView.setImageBitmap(crop);
|
||||
resultCropView.setVisibility(crop == null ? View.GONE : View.VISIBLE);
|
||||
resultCard.setVisibility(View.VISIBLE);
|
||||
resultStatus.setText(R.string.recognition_match_found);
|
||||
resultStatus.setTextColor(color(R.color.liveness_accent));
|
||||
resultName.setText(record.name);
|
||||
resultDetails.setText(getString(R.string.recognition_result_details,
|
||||
record.id, confidence, threshold));
|
||||
}
|
||||
|
||||
private void showNoMatch(FaceRepository.SearchResult search) {
|
||||
clearResultCrop();
|
||||
resultCard.setVisibility(View.VISIBLE);
|
||||
resultStatus.setText(R.string.recognition_no_match);
|
||||
resultStatus.setTextColor(color(R.color.liveness_fail));
|
||||
resultName.setText(R.string.recognition_result_name_unknown);
|
||||
if (!Float.isNaN(search.confidence)) {
|
||||
resultDetails.setText(getString(R.string.recognition_no_match_details,
|
||||
search.confidence, search.threshold));
|
||||
} else {
|
||||
resultDetails.setText(R.string.recognition_no_confidence);
|
||||
}
|
||||
}
|
||||
|
||||
private void showEmptyLibraryResult() {
|
||||
clearResultCrop();
|
||||
resultCard.setVisibility(View.VISIBLE);
|
||||
resultStatus.setText(R.string.recognition_library_empty_result);
|
||||
resultStatus.setTextColor(color(R.color.liveness_warn));
|
||||
resultName.setText(model.sdkName());
|
||||
resultDetails.setText(getString(
|
||||
R.string.recognition_empty_library, model.sdkName()));
|
||||
}
|
||||
|
||||
private void hideResult() {
|
||||
clearResultCrop();
|
||||
resultCard.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private void clearResultCrop() {
|
||||
resultCropView.setImageDrawable(null);
|
||||
resultCropView.setVisibility(View.GONE);
|
||||
recycle(resultCropBitmap);
|
||||
resultCropBitmap = null;
|
||||
}
|
||||
|
||||
private void rebuildSession(StillImageSessionSettings.Values requested,
|
||||
boolean persistOnSuccess) {
|
||||
if (sessionBusy) {
|
||||
return;
|
||||
}
|
||||
int version = ++imageVersion;
|
||||
Bitmap currentImage = imageBitmap;
|
||||
sessionBusy = true;
|
||||
candidates = null;
|
||||
selectedIndex = -1;
|
||||
faceOverlay.clearFace();
|
||||
clearSelectedFaceCrop();
|
||||
hideResult();
|
||||
setControlsEnabled(false);
|
||||
sessionStatus.setText(R.string.recognition_session_rebuilding);
|
||||
sessionStatus.setTextColor(color(R.color.home_text_secondary));
|
||||
if (currentImage != null) {
|
||||
setImageStatus(R.string.image_analyzing, R.color.home_text_secondary);
|
||||
}
|
||||
sdkExecutor.execute(() -> {
|
||||
if (session != null) {
|
||||
FaceEngine.releaseSession(session);
|
||||
session = null;
|
||||
}
|
||||
session = FaceEngine.createRecognitionSession(
|
||||
requested.inputPx, requested.maxFaces, requested.minFacePx);
|
||||
FaceImageProcessor.Result result = session != null && currentImage != null
|
||||
? FaceImageProcessor.detect(session, currentImage, false) : null;
|
||||
boolean ready = session != null;
|
||||
postUi(() -> {
|
||||
if (imageVersion != version) {
|
||||
return;
|
||||
}
|
||||
sessionBusy = false;
|
||||
setControlsEnabled(ready);
|
||||
if (!ready) {
|
||||
sessionStatus.setText(R.string.recognition_session_failed);
|
||||
sessionStatus.setTextColor(color(R.color.liveness_fail));
|
||||
if (currentImage != null) {
|
||||
setImageStatus(R.string.compare_engine_failed, R.color.liveness_fail);
|
||||
}
|
||||
return;
|
||||
}
|
||||
savedSettings = requested;
|
||||
if (persistOnSuccess) {
|
||||
StillImageSessionSettings.save(this, requested);
|
||||
}
|
||||
showSessionSummary(requested);
|
||||
if (currentImage != null) {
|
||||
candidates = result == null ? null : result.candidates;
|
||||
selectedIndex = candidates != null && candidates.length > 0 ? 0 : -1;
|
||||
faceOverlay.showFaces(currentImage.getWidth(), currentImage.getHeight(),
|
||||
result == null ? null : result.faceRects(), selectedIndex);
|
||||
updateSelectedFaceCrop();
|
||||
finishDetectionUi(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void showSessionSummary(StillImageSessionSettings.Values settings) {
|
||||
sessionStatus.setText(getString(R.string.recognition_session_summary,
|
||||
settings.inputPx, settings.maxFaces, settings.minFacePx));
|
||||
sessionStatus.setTextColor(color(R.color.liveness_accent));
|
||||
}
|
||||
|
||||
private void selectParameters(StillImageSessionSettings.Values settings) {
|
||||
inputPxGroup.check(settings.inputPx == 320 ? R.id.inputPx320
|
||||
: settings.inputPx == 1280 ? R.id.inputPx1280 : R.id.inputPx640);
|
||||
if (settings.maxFaces == 1) {
|
||||
maxFacesGroup.check(R.id.maxFaces1);
|
||||
} else if (settings.maxFaces == 3) {
|
||||
maxFacesGroup.check(R.id.maxFaces3);
|
||||
} else if (settings.maxFaces == 5) {
|
||||
maxFacesGroup.check(R.id.maxFaces5);
|
||||
} else {
|
||||
maxFacesGroup.check(R.id.maxFaces10);
|
||||
}
|
||||
if (settings.minFacePx == 48) {
|
||||
minFaceGroup.check(R.id.minFace48);
|
||||
} else if (settings.minFacePx == 64) {
|
||||
minFaceGroup.check(R.id.minFace64);
|
||||
} else if (settings.minFacePx == 128) {
|
||||
minFaceGroup.check(R.id.minFace128);
|
||||
} else {
|
||||
minFaceGroup.check(R.id.minFace24);
|
||||
}
|
||||
}
|
||||
|
||||
private StillImageSessionSettings.Values selectedSettings() {
|
||||
return new StillImageSessionSettings.Values(
|
||||
selectedInputPx(), selectedMaxFaces(), selectedMinFacePx());
|
||||
}
|
||||
|
||||
private void setSettingsExpanded(boolean expanded) {
|
||||
sessionSettingsContent.setVisibility(expanded ? View.VISIBLE : View.GONE);
|
||||
sessionSettingsToggleText.setText(expanded
|
||||
? R.string.recognition_settings_collapse
|
||||
: R.string.recognition_settings_expand);
|
||||
}
|
||||
|
||||
private void startVideoRecognition() {
|
||||
if (!videoSelected || destroyed || videoCameraController != null || videoStarting) {
|
||||
return;
|
||||
}
|
||||
if (!recognitionReady) {
|
||||
showVideoError(R.string.recognition_video_initializing,
|
||||
R.string.recognition_video_first_face_hint);
|
||||
return;
|
||||
}
|
||||
if (!cameraPermission.hasPermission()) {
|
||||
showVideoError(R.string.msg_permission_required,
|
||||
R.string.capture_permission_hint);
|
||||
cameraPermission.requestAccess();
|
||||
return;
|
||||
}
|
||||
|
||||
videoStarting = true;
|
||||
showVideoError(R.string.recognition_video_initializing,
|
||||
R.string.recognition_video_first_face_hint);
|
||||
int generation = ++videoGeneration;
|
||||
RecognitionFaceAnalyzer analyzer = new RecognitionFaceAnalyzer(
|
||||
this, videoOverlay, repository, libraryEmpty,
|
||||
new RecognitionFaceAnalyzer.Listener() {
|
||||
@Override
|
||||
public void onState(RecognitionFaceAnalyzer.State state,
|
||||
@Nullable FaceRecord record,
|
||||
float confidence, float threshold) {
|
||||
postVideoUi(generation,
|
||||
() -> renderVideoState(state, record, confidence, threshold));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSessionError() {
|
||||
postVideoUi(generation, () -> {
|
||||
videoStarting = false;
|
||||
showVideoError(R.string.compare_engine_failed,
|
||||
R.string.recognition_video_first_face_hint);
|
||||
});
|
||||
}
|
||||
});
|
||||
videoAnalyzer = analyzer;
|
||||
videoCameraController = new CameraPreviewController(
|
||||
this, this, videoPreview, sdkExecutor, analyzer,
|
||||
new CameraPreviewController.Listener() {
|
||||
@Override
|
||||
public void onCameraReady(boolean frontCamera) {
|
||||
if (!isCurrentVideo(generation)) {
|
||||
return;
|
||||
}
|
||||
videoStarting = false;
|
||||
analyzer.setMirrored(frontCamera);
|
||||
renderVideoState(RecognitionFaceAnalyzer.State.NO_FACE,
|
||||
null, Float.NaN, Float.NaN);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLensChanged(boolean frontCamera) {
|
||||
if (!isCurrentVideo(generation)) {
|
||||
return;
|
||||
}
|
||||
analyzer.setMirrored(frontCamera);
|
||||
analyzer.resetTracking();
|
||||
renderVideoState(RecognitionFaceAnalyzer.State.NO_FACE,
|
||||
null, Float.NaN, Float.NaN);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCameraError(int messageRes) {
|
||||
if (!isCurrentVideo(generation)) {
|
||||
return;
|
||||
}
|
||||
videoStarting = false;
|
||||
showVideoError(messageRes,
|
||||
R.string.recognition_video_first_face_hint);
|
||||
}
|
||||
});
|
||||
videoCameraController.start();
|
||||
}
|
||||
|
||||
private void stopVideoRecognition() {
|
||||
videoGeneration++;
|
||||
videoStarting = false;
|
||||
if (videoCameraController != null) {
|
||||
videoCameraController.stop();
|
||||
videoCameraController = null;
|
||||
}
|
||||
if (videoAnalyzer != null) {
|
||||
RecognitionFaceAnalyzer toRelease = videoAnalyzer;
|
||||
videoAnalyzer = null;
|
||||
sdkExecutor.execute(toRelease::release);
|
||||
}
|
||||
if (videoOverlay != null) {
|
||||
videoOverlay.submit(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void flipVideoCamera() {
|
||||
if (videoCameraController == null || !videoCameraController.flipCamera()) {
|
||||
Toast.makeText(this, R.string.msg_camera_unavailable, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isCurrentVideo(int generation) {
|
||||
return !destroyed && videoSelected && generation == videoGeneration
|
||||
&& videoAnalyzer != null;
|
||||
}
|
||||
|
||||
private void postVideoUi(int generation, Runnable action) {
|
||||
runOnUiThread(() -> {
|
||||
if (isCurrentVideo(generation)) {
|
||||
action.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderVideoState(RecognitionFaceAnalyzer.State state,
|
||||
@Nullable FaceRecord record,
|
||||
float confidence, float threshold) {
|
||||
videoName.setVisibility(View.GONE);
|
||||
switch (state) {
|
||||
case MOVE_CLOSER:
|
||||
showVideoError(R.string.capture_move_closer,
|
||||
R.string.recognition_video_first_face_hint);
|
||||
videoStatus.setTextColor(color(R.color.liveness_warn));
|
||||
break;
|
||||
case HOLD_STILL:
|
||||
showVideoError(R.string.recognition_video_hold_still,
|
||||
R.string.recognition_video_first_face_hint);
|
||||
videoStatus.setTextColor(color(R.color.white));
|
||||
break;
|
||||
case SEARCHING:
|
||||
showVideoError(R.string.recognition_searching,
|
||||
R.string.recognition_video_first_face_hint);
|
||||
videoStatus.setTextColor(color(R.color.liveness_warn));
|
||||
break;
|
||||
case MATCHED:
|
||||
videoStatus.setText(R.string.recognition_match_found);
|
||||
videoStatus.setTextColor(color(R.color.liveness_accent));
|
||||
videoName.setVisibility(View.VISIBLE);
|
||||
videoName.setText(record == null
|
||||
? getString(R.string.recognition_result_name_unknown) : record.name);
|
||||
if (record != null) {
|
||||
videoDetails.setText(getString(R.string.recognition_result_details,
|
||||
record.id, confidence, threshold));
|
||||
} else {
|
||||
videoDetails.setText(R.string.recognition_no_confidence);
|
||||
}
|
||||
break;
|
||||
case NO_MATCH:
|
||||
videoStatus.setText(R.string.recognition_no_match);
|
||||
videoStatus.setTextColor(color(R.color.liveness_fail));
|
||||
videoName.setVisibility(View.VISIBLE);
|
||||
videoName.setText(R.string.recognition_result_name_unknown);
|
||||
videoDetails.setText(Float.isNaN(confidence)
|
||||
? getString(R.string.recognition_no_confidence)
|
||||
: getString(R.string.recognition_no_match_details,
|
||||
confidence, threshold));
|
||||
break;
|
||||
case EMPTY_LIBRARY:
|
||||
videoStatus.setText(R.string.recognition_library_empty_result);
|
||||
videoStatus.setTextColor(color(R.color.liveness_warn));
|
||||
videoName.setVisibility(View.VISIBLE);
|
||||
videoName.setText(model.sdkName());
|
||||
videoDetails.setText(getString(
|
||||
R.string.recognition_empty_library, model.sdkName()));
|
||||
break;
|
||||
case NO_FACE:
|
||||
default:
|
||||
showVideoError(R.string.recognition_video_no_face,
|
||||
R.string.recognition_video_first_face_hint);
|
||||
videoStatus.setTextColor(color(R.color.white));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void showVideoError(int titleRes, int detailsRes) {
|
||||
videoStatus.setText(titleRes);
|
||||
videoStatus.setTextColor(color(R.color.white));
|
||||
videoName.setVisibility(View.GONE);
|
||||
videoDetails.setText(detailsRes);
|
||||
}
|
||||
|
||||
private int selectedInputPx() {
|
||||
int id = inputPxGroup.getCheckedChipId();
|
||||
return id == R.id.inputPx320 ? 320 : id == R.id.inputPx1280 ? 1280 : 640;
|
||||
}
|
||||
|
||||
private int selectedMaxFaces() {
|
||||
int id = maxFacesGroup.getCheckedChipId();
|
||||
if (id == R.id.maxFaces1) return 1;
|
||||
if (id == R.id.maxFaces3) return 3;
|
||||
if (id == R.id.maxFaces5) return 5;
|
||||
return 10;
|
||||
}
|
||||
|
||||
private int selectedMinFacePx() {
|
||||
int id = minFaceGroup.getCheckedChipId();
|
||||
if (id == R.id.minFace48) return 48;
|
||||
if (id == R.id.minFace64) return 64;
|
||||
if (id == R.id.minFace128) return 128;
|
||||
return 24;
|
||||
}
|
||||
|
||||
private void setControlsEnabled(boolean enabled) {
|
||||
choosePhotoButton.setEnabled(enabled);
|
||||
applySessionButton.setEnabled(enabled);
|
||||
resetSessionButton.setEnabled(enabled);
|
||||
setChipGroupEnabled(inputPxGroup, enabled);
|
||||
setChipGroupEnabled(maxFacesGroup, enabled);
|
||||
setChipGroupEnabled(minFaceGroup, enabled);
|
||||
}
|
||||
|
||||
private static void setChipGroupEnabled(ChipGroup group, boolean enabled) {
|
||||
for (int i = 0; i < group.getChildCount(); i++) {
|
||||
group.getChildAt(i).setEnabled(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
private void setImageStatus(int textRes, @ColorRes int colorRes) {
|
||||
imageStatus.setText(textRes);
|
||||
imageStatus.setTextColor(color(colorRes));
|
||||
}
|
||||
|
||||
private int color(@ColorRes int colorRes) {
|
||||
return ContextCompat.getColor(this, colorRes);
|
||||
}
|
||||
|
||||
private void applyWindowInsets() {
|
||||
View root = findViewById(R.id.recognitionRoot);
|
||||
int left = root.getPaddingLeft();
|
||||
int top = root.getPaddingTop();
|
||||
int right = root.getPaddingRight();
|
||||
int bottom = root.getPaddingBottom();
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root, (v, insets) -> {
|
||||
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
|
||||
v.setPadding(left + bars.left, top + bars.top,
|
||||
right + bars.right, bottom + bars.bottom);
|
||||
return insets;
|
||||
});
|
||||
}
|
||||
|
||||
private void postUi(Runnable action) {
|
||||
runOnUiThread(() -> {
|
||||
if (!destroyed && !isFinishing()) {
|
||||
action.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void recycle(@Nullable Bitmap bitmap) {
|
||||
if (bitmap != null && !bitmap.isRecycled()) {
|
||||
bitmap.recycle();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
cameraPermission.onResume();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
cameraPermission.close();
|
||||
destroyed = true;
|
||||
recognitionReady = false;
|
||||
imageVersion++;
|
||||
stopVideoRecognition();
|
||||
Bitmap imageToRecycle = imageBitmap;
|
||||
Bitmap cropToRecycle = resultCropBitmap;
|
||||
Bitmap selectedCropToRecycle = selectedFaceCropBitmap;
|
||||
imageBitmap = null;
|
||||
resultCropBitmap = null;
|
||||
selectedFaceCropBitmap = null;
|
||||
sdkExecutor.execute(() -> {
|
||||
recycle(imageToRecycle);
|
||||
recycle(cropToRecycle);
|
||||
recycle(selectedCropToRecycle);
|
||||
repository.close();
|
||||
if (session != null) {
|
||||
FaceEngine.releaseSession(session);
|
||||
session = null;
|
||||
}
|
||||
});
|
||||
sdkExecutor.shutdown();
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.example.inspireface_example;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import com.insightface.sdk.inspireface.InspireFace;
|
||||
|
||||
/** Locally cached configuration for the continuous face-tracking camera Session. */
|
||||
final class FaceTrackingSessionSettings {
|
||||
|
||||
static final int DEFAULT_MODE = InspireFace.DETECT_MODE_LIGHT_TRACK;
|
||||
static final int DEFAULT_INPUT_PX = DetectorDefaults.INPUT_PX;
|
||||
static final int DEFAULT_MAX_FACES = 10;
|
||||
static final int DEFAULT_MIN_FACE_PX = 24;
|
||||
|
||||
static final class Values {
|
||||
final int mode;
|
||||
final int inputPx;
|
||||
final int maxFaces;
|
||||
final int minFacePx;
|
||||
|
||||
Values(int mode, int inputPx, int maxFaces, int minFacePx) {
|
||||
this.mode = validMode(mode);
|
||||
this.inputPx = validInputPx(inputPx);
|
||||
this.maxFaces = validMaxFaces(maxFaces);
|
||||
this.minFacePx = validMinFacePx(minFacePx);
|
||||
}
|
||||
}
|
||||
|
||||
private static final String PREFS = "face_tracking_session";
|
||||
private static final String KEY_MODE = "detect_mode";
|
||||
private static final String KEY_INPUT_PX = "input_px";
|
||||
private static final String KEY_MAX_FACES = "max_faces";
|
||||
private static final String KEY_MIN_FACE_PX = "min_face_px";
|
||||
|
||||
private FaceTrackingSessionSettings() {
|
||||
}
|
||||
|
||||
static Values defaults() {
|
||||
return new Values(DEFAULT_MODE, DEFAULT_INPUT_PX,
|
||||
DEFAULT_MAX_FACES, DEFAULT_MIN_FACE_PX);
|
||||
}
|
||||
|
||||
static Values load(Context context) {
|
||||
SharedPreferences preferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
return new Values(
|
||||
preferences.getInt(KEY_MODE, DEFAULT_MODE),
|
||||
preferences.getInt(KEY_INPUT_PX, DEFAULT_INPUT_PX),
|
||||
preferences.getInt(KEY_MAX_FACES, DEFAULT_MAX_FACES),
|
||||
preferences.getInt(KEY_MIN_FACE_PX, DEFAULT_MIN_FACE_PX));
|
||||
}
|
||||
|
||||
static void save(Context context, Values values) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putInt(KEY_MODE, values.mode)
|
||||
.putInt(KEY_INPUT_PX, values.inputPx)
|
||||
.putInt(KEY_MAX_FACES, values.maxFaces)
|
||||
.putInt(KEY_MIN_FACE_PX, values.minFacePx)
|
||||
.apply();
|
||||
}
|
||||
|
||||
private static int validMode(int value) {
|
||||
return value == InspireFace.DETECT_MODE_TRACK_BY_DETECTION
|
||||
? value : DEFAULT_MODE;
|
||||
}
|
||||
|
||||
private static int validInputPx(int value) {
|
||||
return value == 320 || value == 1280 ? value : DEFAULT_INPUT_PX;
|
||||
}
|
||||
|
||||
private static int validMaxFaces(int value) {
|
||||
return value == 1 || value == 3 || value == 5 ? value : DEFAULT_MAX_FACES;
|
||||
}
|
||||
|
||||
private static int validMinFacePx(int value) {
|
||||
return value == 48 || value == 64 || value == 128
|
||||
? value : DEFAULT_MIN_FACE_PX;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.example.inspireface_example;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import com.example.inspireface_example.view.ActionLivenessActivity;
|
||||
import com.example.inspireface_example.view.LivenessActivity;
|
||||
import com.example.inspireface_example.view.PoseActivity;
|
||||
import com.google.android.material.button.MaterialButtonToggleGroup;
|
||||
|
||||
/** Launcher page: model selection plus a square-grid menu for the available demos. */
|
||||
public class HomeActivity extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView())
|
||||
.setAppearanceLightNavigationBars(false);
|
||||
setContentView(R.layout.activity_home);
|
||||
applyWindowInsets();
|
||||
|
||||
MaterialButtonToggleGroup modelToggle = findViewById(R.id.modelToggle);
|
||||
FaceModelPrefs.Model selected = FaceModelPrefs.get(this);
|
||||
modelToggle.check(selected == FaceModelPrefs.Model.PIKACHU
|
||||
? R.id.btnModelPikachu : R.id.btnModelMegatron);
|
||||
modelToggle.addOnButtonCheckedListener((group, checkedId, isChecked) -> {
|
||||
if (!isChecked) {
|
||||
return;
|
||||
}
|
||||
FaceModelPrefs.set(this, checkedId == R.id.btnModelPikachu
|
||||
? FaceModelPrefs.Model.PIKACHU : FaceModelPrefs.Model.MEGATRON);
|
||||
});
|
||||
|
||||
findViewById(R.id.cardSilent).setOnClickListener(
|
||||
v -> openFeature(LivenessActivity.class));
|
||||
findViewById(R.id.cardAction).setOnClickListener(
|
||||
v -> openFeature(ActionLivenessActivity.class));
|
||||
findViewById(R.id.cardPose).setOnClickListener(
|
||||
v -> openFeature(PoseActivity.class));
|
||||
findViewById(R.id.cardCompare).setOnClickListener(
|
||||
v -> openFeature(FaceCompareActivity.class));
|
||||
findViewById(R.id.cardManagement).setOnClickListener(
|
||||
v -> openFeature(FaceManagementActivity.class));
|
||||
findViewById(R.id.cardRecognition).setOnClickListener(
|
||||
v -> openFeature(FaceRecognitionActivity.class));
|
||||
findViewById(R.id.cardDetection).setOnClickListener(
|
||||
v -> openFeature(FaceDetectionActivity.class));
|
||||
findViewById(R.id.cardAttribute).setOnClickListener(
|
||||
v -> openFeature(FaceAttributeActivity.class));
|
||||
findViewById(R.id.langSwitch).setOnClickListener(v -> LocalePrefs.toggle(this));
|
||||
}
|
||||
|
||||
private void openFeature(Class<?> activityClass) {
|
||||
startActivity(new Intent(this, activityClass));
|
||||
}
|
||||
|
||||
private void applyWindowInsets() {
|
||||
View root = findViewById(R.id.homeRoot);
|
||||
int horizontal = root.getPaddingLeft();
|
||||
int top = root.getPaddingTop();
|
||||
int bottom = root.getPaddingBottom();
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root, (v, insets) -> {
|
||||
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
|
||||
v.setPadding(horizontal, top + bars.top, horizontal, bottom + bars.bottom);
|
||||
return insets;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.example.inspireface_example;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.core.os.LocaleListCompat;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Per-app language selection. The demo defaults to English regardless of the system
|
||||
* language; the in-app language controls switch to Chinese and back.
|
||||
* The choice is stored in SharedPreferences and re-applied on every app start.
|
||||
*/
|
||||
public final class LocalePrefs {
|
||||
|
||||
private static final String PREFS = "settings";
|
||||
private static final String KEY_LOCALE = "app_locale";
|
||||
private static final String ENGLISH = "en";
|
||||
private static final String CHINESE = "zh";
|
||||
|
||||
private LocalePrefs() {
|
||||
}
|
||||
|
||||
/** Applies the stored language (English by default). Call from Application.onCreate. */
|
||||
public static void applyStored(Context context) {
|
||||
String selected = stored(context);
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
String platformSelection = supportedLanguage(
|
||||
AppCompatDelegate.getApplicationLocales());
|
||||
if (platformSelection != null) {
|
||||
// Respect a language selected from Android 13+'s per-app language screen.
|
||||
selected = platformSelection;
|
||||
}
|
||||
}
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit().putString(KEY_LOCALE, selected).apply();
|
||||
AppCompatDelegate.setApplicationLocales(
|
||||
LocaleListCompat.forLanguageTags(selected));
|
||||
}
|
||||
|
||||
/** Switches between English and Chinese; running activities recreate automatically. */
|
||||
public static void toggle(Context context) {
|
||||
String active = supportedLanguage(AppCompatDelegate.getApplicationLocales());
|
||||
String next = CHINESE.equals(active != null ? active : stored(context))
|
||||
? ENGLISH : CHINESE;
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit().putString(KEY_LOCALE, next).apply();
|
||||
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(next));
|
||||
}
|
||||
|
||||
private static String stored(Context context) {
|
||||
String value = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(KEY_LOCALE, ENGLISH);
|
||||
return CHINESE.equals(value) ? CHINESE : ENGLISH;
|
||||
}
|
||||
|
||||
private static String supportedLanguage(LocaleListCompat locales) {
|
||||
if (locales == null || locales.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
Locale locale = locales.get(0);
|
||||
if (locale == null) {
|
||||
return null;
|
||||
}
|
||||
if (CHINESE.equals(locale.getLanguage())) {
|
||||
return CHINESE;
|
||||
}
|
||||
return ENGLISH.equals(locale.getLanguage()) ? ENGLISH : null;
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
package com.example.inspireface_example;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.AssetManager;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.activity.EdgeToEdge;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import com.insightface.sdk.inspireface.InspireFace;
|
||||
import com.insightface.sdk.inspireface.base.*;
|
||||
import com.insightface.sdk.inspireface.utils.SDKUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private final String TAG = "InspireFace";
|
||||
|
||||
void test() {
|
||||
InspireFaceVersion version = InspireFace.QueryInspireFaceVersion();
|
||||
Log.i(TAG, "InspireFace Version: " + version.major + "." + version.minor + "." + version.patch);
|
||||
String dbPath = "/storage/emulated/0/Android/data/com.example.inspireface_example/files/f.db";
|
||||
FeatureHubConfiguration configuration = InspireFace.CreateFeatureHubConfiguration()
|
||||
.setEnablePersistence(false)
|
||||
.setPersistenceDbPath(dbPath)
|
||||
.setSearchThreshold(0.42f)
|
||||
.setSearchMode(InspireFace.SEARCH_MODE_EXHAUSTIVE)
|
||||
.setPrimaryKeyMode(InspireFace.PK_AUTO_INCREMENT);
|
||||
|
||||
boolean enableStatus = InspireFace.FeatureHubDataEnable(configuration);
|
||||
Log.d(TAG, "Enable feature hub data status: " + enableStatus);
|
||||
InspireFace.FeatureHubFaceSearchThresholdSetting(0.42f);
|
||||
|
||||
boolean launchStatus = InspireFace.GlobalLaunch(this, InspireFace.PIKACHU);
|
||||
Log.d(TAG, "Launch status: " + launchStatus);
|
||||
if (!launchStatus) {
|
||||
Log.e(TAG, "Failed to launch InspireFace");
|
||||
return;
|
||||
}
|
||||
CustomParameter parameter = InspireFace.CreateCustomParameter()
|
||||
.enableRecognition(true)
|
||||
.enableFaceQuality(true)
|
||||
.enableFaceAttribute(true)
|
||||
.enableInteractionLiveness(true)
|
||||
.enableLiveness(true)
|
||||
.enableMaskDetect(true);
|
||||
Session session = InspireFace.CreateSession(parameter, InspireFace.DETECT_MODE_ALWAYS_DETECT, 10, -1, -1);
|
||||
Log.i(TAG, "session handle: " + session.handle);
|
||||
InspireFace.SetTrackPreviewSize(session, 320);
|
||||
InspireFace.SetFaceDetectThreshold(session, 0.5f);
|
||||
InspireFace.SetFilterMinimumFacePixelSize(session, 0);
|
||||
|
||||
Bitmap img = SDKUtils.getImageFromAssetsFile(this, "inspireface/kun.jpg");
|
||||
ImageStream stream = InspireFace.CreateImageStreamFromBitmap(img, InspireFace.CAMERA_ROTATION_0);
|
||||
Log.i(TAG, "stream handle: " + stream.handle);
|
||||
InspireFace.WriteImageStreamToFile(stream, "/storage/emulated/0/Android/data/com.example.inspireface_example/files/out.jpg");
|
||||
|
||||
MultipleFaceData multipleFaceData = InspireFace.ExecuteFaceTrack(session, stream);
|
||||
Log.i(TAG, "Face num: " + multipleFaceData.detectedNum);
|
||||
|
||||
if (multipleFaceData.detectedNum > 0) {
|
||||
Point2f[] lmk = InspireFace.GetFaceDenseLandmarkFromFaceToken(multipleFaceData.tokens[0]);
|
||||
for (Point2f p : lmk) {
|
||||
Log.i(TAG, p.x + ", " + p.y);
|
||||
}
|
||||
FaceFeature feature = InspireFace.ExtractFaceFeature(session, stream, multipleFaceData.tokens[0]);
|
||||
Log.i(TAG, "Feature size: " + feature.data.length);
|
||||
String strFt = "";
|
||||
for (int i = 0; i < feature.data.length; i++) {
|
||||
strFt = strFt + feature.data[i] + ", ";
|
||||
}
|
||||
Log.i(TAG, strFt);
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
FaceFeatureIdentity identity = FaceFeatureIdentity.create(-1, feature);
|
||||
boolean succ = InspireFace.FeatureHubInsertFeature(identity);
|
||||
if (succ) {
|
||||
Log.i(TAG, "Allocation ID: " + identity.id);
|
||||
}
|
||||
}
|
||||
|
||||
FaceFeatureIdentity searched = InspireFace.FeatureHubFaceSearch(feature);
|
||||
Log.i(TAG, "Searched id: " + searched.id + ", Confidence: " + searched.searchConfidence);
|
||||
|
||||
SearchTopKResults topKResults = InspireFace.FeatureHubFaceSearchTopK(feature, 10);
|
||||
for (int i = 0; i < topKResults.num; i++) {
|
||||
Log.i(TAG, "TopK id: " + topKResults.ids[i] + ", Confidence: " + topKResults.confidence[i]);
|
||||
}
|
||||
|
||||
FaceFeature newFeature = new FaceFeature();
|
||||
Log.i(TAG, "Feature length: " + InspireFace.GetFeatureLength());
|
||||
newFeature.data = new float[InspireFace.GetFeatureLength()];
|
||||
FaceFeatureIdentity identity = FaceFeatureIdentity.create(8, newFeature);
|
||||
boolean updateSucc = InspireFace.FeatureHubFaceUpdate(identity);
|
||||
if (updateSucc) {
|
||||
Log.i(TAG, "Update feature success: " + 8);
|
||||
}
|
||||
boolean removeSucc = InspireFace.FeatureHubFaceRemove(4);
|
||||
if (removeSucc) {
|
||||
Log.i(TAG, "Remove feature success: " + 4);
|
||||
}
|
||||
SearchTopKResults topkAgn = InspireFace.FeatureHubFaceSearchTopK(feature, 10);
|
||||
for (int i = 0; i < topkAgn.num; i++) {
|
||||
Log.i(TAG, "Agn TopK id: " + topkAgn.ids[i] + ", Confidence: " + topKResults.confidence[i]);
|
||||
}
|
||||
|
||||
FaceFeatureIdentity queryIdentity = InspireFace.FeatureHubGetFaceIdentity(4);
|
||||
if (queryIdentity != null) {
|
||||
Log.e(TAG, "query id: " + queryIdentity.id);
|
||||
}
|
||||
queryIdentity = InspireFace.FeatureHubGetFaceIdentity(2);
|
||||
if (queryIdentity != null) {
|
||||
strFt = "";
|
||||
for (int i = 0; i < queryIdentity.feature.data.length; i++) {
|
||||
strFt = strFt + queryIdentity.feature.data[i] + ", ";
|
||||
}
|
||||
Log.i(TAG, "query id: " + queryIdentity.id);
|
||||
Log.i(TAG, strFt);
|
||||
|
||||
float comp = InspireFace.FaceComparison(queryIdentity.feature, feature);
|
||||
Log.i(TAG, "Comparison: " + comp);
|
||||
}
|
||||
CustomParameter pipelineNeedParam = InspireFace.CreateCustomParameter()
|
||||
.enableFaceQuality(true)
|
||||
.enableLiveness(true)
|
||||
.enableMaskDetect(true)
|
||||
.enableFaceAttribute(true)
|
||||
.enableInteractionLiveness(true);
|
||||
boolean succPipe = InspireFace.MultipleFacePipelineProcess(session, stream, multipleFaceData, pipelineNeedParam);
|
||||
if (succPipe) {
|
||||
Log.i(TAG, "Exec pipeline success");
|
||||
RGBLivenessConfidence rgbLivenessConfidence = InspireFace.GetRGBLivenessConfidence(session);
|
||||
Log.i(TAG, "rgbLivenessConfidence: " + rgbLivenessConfidence.confidence[0]);
|
||||
FaceQualityConfidence faceQualityConfidence = InspireFace.GetFaceQualityConfidence(session);
|
||||
Log.i(TAG, "faceQualityConfidence: " + faceQualityConfidence.confidence[0]);
|
||||
FaceMaskConfidence faceMaskConfidence = InspireFace.GetFaceMaskConfidence(session);
|
||||
Log.i(TAG, "faceMaskConfidence: " + faceMaskConfidence.confidence[0]);
|
||||
FaceInteractionState faceInteractionState = InspireFace.GetFaceInteractionStateResult(session);
|
||||
Log.i(TAG, "Left eye status confidence: " + faceInteractionState.leftEyeStatusConfidence[0]);
|
||||
Log.i(TAG, "Right eye status confidence: " + faceInteractionState.rightEyeStatusConfidence[0]);
|
||||
FaceInteractionsActions faceInteractionsActions = InspireFace.GetFaceInteractionActionsResult(session);
|
||||
Log.i(TAG, "Normal: " + faceInteractionsActions.normal[0]);
|
||||
Log.i(TAG, "Shake: " + faceInteractionsActions.shake[0]);
|
||||
Log.i(TAG, "Jaw open: " + faceInteractionsActions.jawOpen[0]);
|
||||
Log.i(TAG, "Head raise: " + faceInteractionsActions.headRaise[0]);
|
||||
Log.i(TAG, "Blink: " + faceInteractionsActions.blink[0]);
|
||||
FaceAttributeResult faceAttributeResult = InspireFace.GetFaceAttributeResult(session);
|
||||
Log.i(TAG, "Race: " + faceAttributeResult.race[0]);
|
||||
Log.i(TAG, "Gender: " + faceAttributeResult.gender[0]);
|
||||
Log.i(TAG, "Age bracket: " + faceAttributeResult.ageBracket[0]);
|
||||
} else {
|
||||
|
||||
Log.e(TAG, "Exec pipeline fail");
|
||||
}
|
||||
}
|
||||
|
||||
int count = InspireFace.FeatureHubGetFaceCount();
|
||||
Log.i(TAG, "Face count: " + count);
|
||||
|
||||
Bitmap crop = InspireFace.GetFaceAlignmentImage(session, stream, multipleFaceData.tokens[0]);
|
||||
try {
|
||||
SDKUtils.saveBitmap("/storage/emulated/0/Android/data/com.example.inspireface_example/files/", "crop", crop);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
InspireFace.ReleaseImageStream(stream);
|
||||
InspireFace.ReleaseSession(session);
|
||||
|
||||
|
||||
InspireFace.FeatureHubDataDisable();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
EdgeToEdge.enable(this);
|
||||
setContentView(R.layout.activity_main);
|
||||
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
|
||||
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
|
||||
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
|
||||
return insets;
|
||||
});
|
||||
|
||||
//
|
||||
|
||||
test();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.example.inspireface_example;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
/** Cached still-image detection settings shared by photo-based SDK demos. */
|
||||
final class StillImageSessionSettings {
|
||||
|
||||
static final int DEFAULT_INPUT_PX = DetectorDefaults.INPUT_PX;
|
||||
static final int DEFAULT_MAX_FACES = 10;
|
||||
static final int DEFAULT_MIN_FACE_PX = 24;
|
||||
|
||||
static final class Values {
|
||||
final int inputPx;
|
||||
final int maxFaces;
|
||||
final int minFacePx;
|
||||
|
||||
Values(int inputPx, int maxFaces, int minFacePx) {
|
||||
this.inputPx = validInputPx(inputPx);
|
||||
this.maxFaces = validMaxFaces(maxFaces);
|
||||
this.minFacePx = validMinFacePx(minFacePx);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the original preference name so existing recognition settings migrate intact.
|
||||
private static final String PREFS = "face_recognition_session";
|
||||
private static final String KEY_INPUT_PX = "input_px";
|
||||
private static final String KEY_MAX_FACES = "max_faces";
|
||||
private static final String KEY_MIN_FACE_PX = "min_face_px";
|
||||
|
||||
private StillImageSessionSettings() {
|
||||
}
|
||||
|
||||
static Values defaults() {
|
||||
return new Values(DEFAULT_INPUT_PX, DEFAULT_MAX_FACES, DEFAULT_MIN_FACE_PX);
|
||||
}
|
||||
|
||||
static Values load(Context context) {
|
||||
SharedPreferences preferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
return new Values(
|
||||
preferences.getInt(KEY_INPUT_PX, DEFAULT_INPUT_PX),
|
||||
preferences.getInt(KEY_MAX_FACES, DEFAULT_MAX_FACES),
|
||||
preferences.getInt(KEY_MIN_FACE_PX, DEFAULT_MIN_FACE_PX));
|
||||
}
|
||||
|
||||
static void save(Context context, Values values) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putInt(KEY_INPUT_PX, values.inputPx)
|
||||
.putInt(KEY_MAX_FACES, values.maxFaces)
|
||||
.putInt(KEY_MIN_FACE_PX, values.minFacePx)
|
||||
.apply();
|
||||
}
|
||||
|
||||
private static int validInputPx(int value) {
|
||||
return value == 320 || value == 1280 ? value : DEFAULT_INPUT_PX;
|
||||
}
|
||||
|
||||
private static int validMaxFaces(int value) {
|
||||
return value == 1 || value == 3 || value == 5 ? value : DEFAULT_MAX_FACES;
|
||||
}
|
||||
|
||||
private static int validMinFacePx(int value) {
|
||||
return value == 48 || value == 64 || value == 128
|
||||
? value : DEFAULT_MIN_FACE_PX;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.example.inspireface_example.face;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.RectF;
|
||||
|
||||
import com.insightface.sdk.inspireface.InspireFace;
|
||||
import com.insightface.sdk.inspireface.base.CustomParameter;
|
||||
import com.insightface.sdk.inspireface.base.FaceAttributeResult;
|
||||
import com.insightface.sdk.inspireface.base.FaceInteractionState;
|
||||
import com.insightface.sdk.inspireface.base.FaceInteractionsActions;
|
||||
import com.insightface.sdk.inspireface.base.FaceMaskConfidence;
|
||||
import com.insightface.sdk.inspireface.base.FaceQualityConfidence;
|
||||
import com.insightface.sdk.inspireface.base.FaceRect;
|
||||
import com.insightface.sdk.inspireface.base.ImageStream;
|
||||
import com.insightface.sdk.inspireface.base.MultipleFaceData;
|
||||
import com.insightface.sdk.inspireface.base.Session;
|
||||
|
||||
/** Runs still-image face detection and the SDK attribute pipeline in one stream lifetime. */
|
||||
public final class FaceAttributeProcessor {
|
||||
|
||||
private static final int INTERACTION_PIPELINE_CALLS = 10;
|
||||
|
||||
public enum Status { READY, NO_FACE, PROCESS_FAILED }
|
||||
|
||||
public static final class Attribute {
|
||||
public final float maskConfidence;
|
||||
public final int ageBracket;
|
||||
public final float qualityScore;
|
||||
public final int jawOpen;
|
||||
public final int race;
|
||||
public final int gender;
|
||||
public final float leftEyeConfidence;
|
||||
public final float rightEyeConfidence;
|
||||
|
||||
Attribute(float maskConfidence, int ageBracket, float qualityScore,
|
||||
int jawOpen, int race, int gender,
|
||||
float leftEyeConfidence, float rightEyeConfidence) {
|
||||
this.maskConfidence = maskConfidence;
|
||||
this.ageBracket = ageBracket;
|
||||
this.qualityScore = qualityScore;
|
||||
this.jawOpen = jawOpen;
|
||||
this.race = race;
|
||||
this.gender = gender;
|
||||
this.leftEyeConfidence = leftEyeConfidence;
|
||||
this.rightEyeConfidence = rightEyeConfidence;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Result {
|
||||
public final Status status;
|
||||
public final FaceImageProcessor.Candidate[] candidates;
|
||||
public final Attribute[] attributes;
|
||||
|
||||
Result(Status status, FaceImageProcessor.Candidate[] candidates,
|
||||
Attribute[] attributes) {
|
||||
this.status = status;
|
||||
this.candidates = candidates;
|
||||
this.attributes = attributes;
|
||||
}
|
||||
|
||||
public RectF[] faceRects() {
|
||||
RectF[] rects = new RectF[candidates.length];
|
||||
for (int i = 0; i < candidates.length; i++) {
|
||||
rects[i] = new RectF(candidates[i].rect);
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
}
|
||||
|
||||
private FaceAttributeProcessor() {
|
||||
}
|
||||
|
||||
public static Result analyze(Session session, Bitmap bitmap) {
|
||||
ImageStream stream = InspireFace.CreateImageStreamFromBitmap(
|
||||
bitmap, InspireFace.CAMERA_ROTATION_0);
|
||||
if (stream == null) {
|
||||
return empty(Status.PROCESS_FAILED);
|
||||
}
|
||||
try {
|
||||
MultipleFaceData faces = InspireFace.ExecuteFaceTrack(session, stream);
|
||||
if (faces == null) {
|
||||
return empty(Status.PROCESS_FAILED);
|
||||
}
|
||||
if (faces.detectedNum <= 0) {
|
||||
return empty(Status.NO_FACE);
|
||||
}
|
||||
|
||||
FaceImageProcessor.Candidate[] candidates = candidates(faces);
|
||||
CustomParameter pipeline = InspireFace.CreateCustomParameter()
|
||||
.enableMaskDetect(true)
|
||||
.enableFaceQuality(true)
|
||||
.enableFaceAttribute(true)
|
||||
.enableInteractionLiveness(true);
|
||||
if (!InspireFace.MultipleFacePipelineProcess(
|
||||
session, stream, faces, pipeline)) {
|
||||
return new Result(Status.PROCESS_FAILED, candidates, new Attribute[0]);
|
||||
}
|
||||
|
||||
FaceMaskConfidence masks = InspireFace.GetFaceMaskConfidence(session);
|
||||
FaceQualityConfidence qualities = InspireFace.GetFaceQualityConfidence(session);
|
||||
FaceAttributeResult faceAttributes = InspireFace.GetFaceAttributeResult(session);
|
||||
|
||||
// The interaction module has a short fresh-track warm-up. Reusing the same
|
||||
// still frame lets it produce deterministic eye and jaw state without changing
|
||||
// the already cached mask, quality and demographic results above.
|
||||
CustomParameter interaction = InspireFace.CreateCustomParameter()
|
||||
.enableInteractionLiveness(true);
|
||||
for (int i = 1; i < INTERACTION_PIPELINE_CALLS; i++) {
|
||||
InspireFace.MultipleFacePipelineProcess(
|
||||
session, stream, faces, interaction);
|
||||
}
|
||||
FaceInteractionState eyeStates =
|
||||
InspireFace.GetFaceInteractionStateResult(session);
|
||||
FaceInteractionsActions actions =
|
||||
InspireFace.GetFaceInteractionActionsResult(session);
|
||||
|
||||
Attribute[] attributes = new Attribute[candidates.length];
|
||||
for (int i = 0; i < attributes.length; i++) {
|
||||
attributes[i] = new Attribute(
|
||||
floatAt(masks == null ? null : masks.confidence, i),
|
||||
intAt(faceAttributes == null ? null : faceAttributes.ageBracket, i),
|
||||
floatAt(qualities == null ? null : qualities.confidence, i),
|
||||
intAt(actions == null ? null : actions.jawOpen, i),
|
||||
intAt(faceAttributes == null ? null : faceAttributes.race, i),
|
||||
intAt(faceAttributes == null ? null : faceAttributes.gender, i),
|
||||
floatAt(eyeStates == null ? null
|
||||
: eyeStates.leftEyeStatusConfidence, i),
|
||||
floatAt(eyeStates == null ? null
|
||||
: eyeStates.rightEyeStatusConfidence, i));
|
||||
}
|
||||
return new Result(Status.READY, candidates, attributes);
|
||||
} finally {
|
||||
InspireFace.ReleaseImageStream(stream);
|
||||
}
|
||||
}
|
||||
|
||||
private static FaceImageProcessor.Candidate[] candidates(MultipleFaceData faces) {
|
||||
FaceImageProcessor.Candidate[] candidates =
|
||||
new FaceImageProcessor.Candidate[faces.detectedNum];
|
||||
for (int i = 0; i < faces.detectedNum; i++) {
|
||||
FaceRect face = faces.rects[i];
|
||||
RectF rect = new RectF(face.x, face.y,
|
||||
face.x + face.width, face.y + face.height);
|
||||
candidates[i] = new FaceImageProcessor.Candidate(
|
||||
rect, null, null, null);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private static Result empty(Status status) {
|
||||
return new Result(status, new FaceImageProcessor.Candidate[0], new Attribute[0]);
|
||||
}
|
||||
|
||||
private static float floatAt(float[] values, int index) {
|
||||
return values != null && index >= 0 && index < values.length
|
||||
? values[index] : Float.NaN;
|
||||
}
|
||||
|
||||
private static int intAt(int[] values, int index) {
|
||||
return values != null && index >= 0 && index < values.length ? values[index] : -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.example.inspireface_example.face;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.RectF;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
/** Shared expanded square crop used by selectable-face magnifiers. */
|
||||
public final class FaceCropUtils {
|
||||
|
||||
public static final class SquareCrop {
|
||||
public final Bitmap bitmap;
|
||||
public final int left;
|
||||
public final int top;
|
||||
public final int size;
|
||||
|
||||
SquareCrop(Bitmap bitmap, int left, int top, int size) {
|
||||
this.bitmap = bitmap;
|
||||
this.left = left;
|
||||
this.top = top;
|
||||
this.size = size;
|
||||
}
|
||||
}
|
||||
|
||||
private FaceCropUtils() {
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static SquareCrop createSquare(Bitmap source, RectF face, float scale) {
|
||||
if (source == null || source.isRecycled() || face == null
|
||||
|| source.getWidth() < 2 || source.getHeight() < 2) {
|
||||
return null;
|
||||
}
|
||||
int cropSize = Math.max(2, Math.round(
|
||||
Math.max(face.width(), face.height()) * scale));
|
||||
cropSize = Math.min(cropSize, Math.min(source.getWidth(), source.getHeight()));
|
||||
int left = Math.round(face.centerX() - cropSize / 2f);
|
||||
int top = Math.round(face.centerY() - cropSize / 2f);
|
||||
left = Math.max(0, Math.min(left, source.getWidth() - cropSize));
|
||||
top = Math.max(0, Math.min(top, source.getHeight() - cropSize));
|
||||
try {
|
||||
Bitmap crop = Bitmap.createBitmap(source, left, top, cropSize, cropSize);
|
||||
return crop == source ? null : new SquareCrop(crop, left, top, cropSize);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.example.inspireface_example.face;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.RectF;
|
||||
|
||||
import com.insightface.sdk.inspireface.InspireFace;
|
||||
import com.insightface.sdk.inspireface.base.FaceFeature;
|
||||
import com.insightface.sdk.inspireface.base.FaceRect;
|
||||
import com.insightface.sdk.inspireface.base.ImageStream;
|
||||
import com.insightface.sdk.inspireface.base.MultipleFaceData;
|
||||
import com.insightface.sdk.inspireface.base.Point2f;
|
||||
import com.insightface.sdk.inspireface.base.Session;
|
||||
|
||||
/** Detects all faces once and materializes optional features, crops, or dense landmarks. */
|
||||
public final class FaceImageProcessor {
|
||||
|
||||
public enum Status { READY, NO_FACE, PROCESS_FAILED }
|
||||
|
||||
public static final class Candidate {
|
||||
public final RectF rect;
|
||||
public final FaceFeature feature;
|
||||
public final Bitmap crop;
|
||||
public final Point2f[] denseLandmarks;
|
||||
|
||||
Candidate(RectF rect, FaceFeature feature, Bitmap crop,
|
||||
Point2f[] denseLandmarks) {
|
||||
this.rect = rect;
|
||||
this.feature = feature;
|
||||
this.crop = crop;
|
||||
this.denseLandmarks = denseLandmarks;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Result {
|
||||
public final Status status;
|
||||
public final Candidate[] candidates;
|
||||
|
||||
Result(Status status, Candidate[] candidates) {
|
||||
this.status = status;
|
||||
this.candidates = candidates;
|
||||
}
|
||||
|
||||
public RectF[] faceRects() {
|
||||
RectF[] rects = new RectF[candidates.length];
|
||||
for (int i = 0; i < candidates.length; i++) {
|
||||
rects[i] = new RectF(candidates[i].rect);
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private FaceImageProcessor() {
|
||||
}
|
||||
|
||||
/** All calls must remain on the thread that owns {@code session}. */
|
||||
public static Result detect(Session session, Bitmap bitmap, boolean createCrops) {
|
||||
return detect(session, bitmap, createCrops, true, false);
|
||||
}
|
||||
|
||||
/** Detection-only variant that materializes the SDK's native 106-point landmarks. */
|
||||
public static Result detectWithLandmarks(Session session, Bitmap bitmap) {
|
||||
return detect(session, bitmap, false, false, true);
|
||||
}
|
||||
|
||||
private static Result detect(Session session, Bitmap bitmap, boolean createCrops,
|
||||
boolean extractFeatures, boolean extractLandmarks) {
|
||||
ImageStream stream = InspireFace.CreateImageStreamFromBitmap(
|
||||
bitmap, InspireFace.CAMERA_ROTATION_0);
|
||||
if (stream == null) {
|
||||
return new Result(Status.PROCESS_FAILED, new Candidate[0]);
|
||||
}
|
||||
try {
|
||||
MultipleFaceData faces = InspireFace.ExecuteFaceTrack(session, stream);
|
||||
if (faces == null) {
|
||||
return new Result(Status.PROCESS_FAILED, new Candidate[0]);
|
||||
}
|
||||
if (faces.detectedNum == 0) {
|
||||
return new Result(Status.NO_FACE, new Candidate[0]);
|
||||
}
|
||||
Candidate[] candidates = new Candidate[faces.detectedNum];
|
||||
for (int i = 0; i < faces.detectedNum; i++) {
|
||||
FaceRect face = faces.rects[i];
|
||||
RectF rect = new RectF(face.x, face.y,
|
||||
face.x + face.width, face.y + face.height);
|
||||
FaceFeature feature = extractFeatures
|
||||
? InspireFace.ExtractFaceFeature(session, stream, faces.tokens[i])
|
||||
: null;
|
||||
Point2f[] denseLandmarks = extractLandmarks
|
||||
? InspireFace.GetFaceDenseLandmarkFromFaceToken(faces.tokens[i])
|
||||
: null;
|
||||
Bitmap crop = null;
|
||||
if (createCrops) {
|
||||
crop = InspireFace.GetFaceAlignmentImage(session, stream, faces.tokens[i]);
|
||||
if (crop == null) {
|
||||
crop = cropFace(bitmap, rect);
|
||||
} else if (crop == bitmap) {
|
||||
// Keep crop ownership independent from the source image. The editor
|
||||
// recycles source and crop bitmaps on separate lifecycle paths.
|
||||
crop = independentCopy(bitmap);
|
||||
}
|
||||
}
|
||||
candidates[i] = new Candidate(rect, feature, crop, denseLandmarks);
|
||||
}
|
||||
return new Result(Status.READY, candidates);
|
||||
} finally {
|
||||
InspireFace.ReleaseImageStream(stream);
|
||||
}
|
||||
}
|
||||
|
||||
public static void recycleCrops(Candidate[] candidates, int exceptIndex) {
|
||||
if (candidates == null) {
|
||||
return;
|
||||
}
|
||||
Bitmap keptCrop = exceptIndex >= 0 && exceptIndex < candidates.length
|
||||
? candidates[exceptIndex].crop : null;
|
||||
for (int i = 0; i < candidates.length; i++) {
|
||||
Bitmap crop = candidates[i].crop;
|
||||
if (i != exceptIndex && crop != null && crop != keptCrop && !crop.isRecycled()) {
|
||||
crop.recycle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Bitmap cropFace(Bitmap bitmap, RectF face) {
|
||||
float paddingX = face.width() * 0.18f;
|
||||
float paddingY = face.height() * 0.18f;
|
||||
int left = Math.max(0, Math.round(face.left - paddingX));
|
||||
int top = Math.max(0, Math.round(face.top - paddingY));
|
||||
int right = Math.min(bitmap.getWidth(), Math.round(face.right + paddingX));
|
||||
int bottom = Math.min(bitmap.getHeight(), Math.round(face.bottom + paddingY));
|
||||
if (right <= left || bottom <= top) {
|
||||
return null;
|
||||
}
|
||||
Bitmap crop = Bitmap.createBitmap(bitmap, left, top, right - left, bottom - top);
|
||||
return crop == bitmap ? independentCopy(bitmap) : crop;
|
||||
}
|
||||
|
||||
private static Bitmap independentCopy(Bitmap source) {
|
||||
return source.copy(Bitmap.Config.ARGB_8888, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.example.inspireface_example.face;
|
||||
|
||||
/** Local metadata paired with one FeatureHub identity. */
|
||||
public final class FaceRecord {
|
||||
public final long id;
|
||||
public final String name;
|
||||
public final String cropPath;
|
||||
public final long updatedAt;
|
||||
|
||||
public FaceRecord(long id, String name, String cropPath, long updatedAt) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.cropPath = cropPath;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package com.example.inspireface_example.face;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.Bitmap;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.example.inspireface_example.FaceModelPrefs;
|
||||
import com.insightface.sdk.inspireface.InspireFace;
|
||||
import com.insightface.sdk.inspireface.base.FaceFeature;
|
||||
import com.insightface.sdk.inspireface.base.FaceFeatureIdentity;
|
||||
import com.insightface.sdk.inspireface.base.FeatureHubConfiguration;
|
||||
import com.insightface.sdk.inspireface.base.SearchTopKResults;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Model-scoped FeatureHub storage. Each model gets its own native DB, crop directory,
|
||||
* metadata preferences and ID sequence; no feature or image is shared across models.
|
||||
*/
|
||||
public final class FaceRepository {
|
||||
|
||||
public static final class SearchResult {
|
||||
public final boolean matched;
|
||||
public final FaceRecord record;
|
||||
public final float confidence;
|
||||
public final float threshold;
|
||||
|
||||
SearchResult(boolean matched, @Nullable FaceRecord record,
|
||||
float confidence, float threshold) {
|
||||
this.matched = matched;
|
||||
this.record = record;
|
||||
this.confidence = confidence;
|
||||
this.threshold = threshold;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class InsertResult {
|
||||
public final boolean success;
|
||||
public final FaceRecord record;
|
||||
|
||||
InsertResult(boolean success, @Nullable FaceRecord record) {
|
||||
this.success = success;
|
||||
this.record = record;
|
||||
}
|
||||
}
|
||||
|
||||
private static final String KEY_PREFIX = "record.";
|
||||
private static final String KEY_NEXT_ID = "next_id";
|
||||
private static final Object HUB_LOCK = new Object();
|
||||
private static String activeDatabasePath;
|
||||
private static int hubReferences;
|
||||
|
||||
private final SharedPreferences metadata;
|
||||
private final File modelDirectory;
|
||||
private final File cropDirectory;
|
||||
private final File databaseFile;
|
||||
private boolean hubAcquired;
|
||||
|
||||
public FaceRepository(Context context, FaceModelPrefs.Model model) {
|
||||
Context app = context.getApplicationContext();
|
||||
modelDirectory = new File(new File(app.getFilesDir(), "face_hub"), model.sdkName());
|
||||
cropDirectory = new File(modelDirectory, "crops");
|
||||
databaseFile = new File(modelDirectory, "features.db");
|
||||
metadata = app.getSharedPreferences(
|
||||
"face_records_" + model.sdkName(), Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
/** Must be called after GlobalLaunch and from the repository's SDK executor. */
|
||||
public boolean open() {
|
||||
synchronized (HUB_LOCK) {
|
||||
if (hubAcquired) {
|
||||
return true;
|
||||
}
|
||||
String requestedPath = databaseFile.getAbsolutePath();
|
||||
if (hubReferences > 0) {
|
||||
if (!requestedPath.equals(activeDatabasePath)) {
|
||||
return false;
|
||||
}
|
||||
hubReferences++;
|
||||
hubAcquired = true;
|
||||
return true;
|
||||
}
|
||||
if (!cropDirectory.exists() && !cropDirectory.mkdirs()) {
|
||||
return false;
|
||||
}
|
||||
FeatureHubConfiguration configuration = InspireFace.CreateFeatureHubConfiguration()
|
||||
.setPrimaryKeyMode(InspireFace.PK_MANUAL_INPUT)
|
||||
.setEnablePersistence(true)
|
||||
.setPersistenceDbPath(requestedPath)
|
||||
.setSearchThreshold(InspireFace.GetRecommendedCosineThreshold())
|
||||
.setSearchMode(InspireFace.SEARCH_MODE_EXHAUSTIVE);
|
||||
if (!InspireFace.FeatureHubDataEnable(configuration)) {
|
||||
return false;
|
||||
}
|
||||
activeDatabasePath = requestedPath;
|
||||
hubReferences = 1;
|
||||
hubAcquired = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void close() {
|
||||
synchronized (HUB_LOCK) {
|
||||
if (!hubAcquired) {
|
||||
return;
|
||||
}
|
||||
hubAcquired = false;
|
||||
hubReferences = Math.max(0, hubReferences - 1);
|
||||
if (hubReferences == 0) {
|
||||
InspireFace.FeatureHubDataDisable();
|
||||
activeDatabasePath = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<FaceRecord> query(@Nullable String keyword) {
|
||||
String normalized = keyword == null ? ""
|
||||
: keyword.trim().toLowerCase(Locale.ROOT);
|
||||
List<FaceRecord> records = new ArrayList<>();
|
||||
for (Map.Entry<String, ?> entry : metadata.getAll().entrySet()) {
|
||||
if (!entry.getKey().startsWith(KEY_PREFIX) || !(entry.getValue() instanceof String)) {
|
||||
continue;
|
||||
}
|
||||
FaceRecord record = decode((String) entry.getValue());
|
||||
if (record != null && (normalized.isEmpty()
|
||||
|| record.name.toLowerCase(Locale.ROOT).contains(normalized)
|
||||
|| String.valueOf(record.id).contains(normalized))) {
|
||||
records.add(record);
|
||||
}
|
||||
}
|
||||
Collections.sort(records,
|
||||
(left, right) -> Long.compare(right.updatedAt, left.updatedAt));
|
||||
return records;
|
||||
}
|
||||
|
||||
/** Searches the active model's native FeatureHub and joins the best ID to metadata. */
|
||||
public SearchResult search(FaceFeature feature) {
|
||||
float threshold = InspireFace.GetRecommendedCosineThreshold();
|
||||
if (!hubAcquired || feature == null) {
|
||||
return new SearchResult(false, null, Float.NaN, threshold);
|
||||
}
|
||||
SearchTopKResults results = InspireFace.FeatureHubFaceSearchTopK(feature, 1);
|
||||
if (results == null || results.num <= 0 || results.ids == null
|
||||
|| results.confidence == null || results.ids.length == 0
|
||||
|| results.confidence.length == 0) {
|
||||
return new SearchResult(false, null, Float.NaN, threshold);
|
||||
}
|
||||
float confidence = results.confidence[0];
|
||||
FaceRecord record = get(results.ids[0]);
|
||||
return new SearchResult(record != null && confidence >= threshold,
|
||||
record, confidence, threshold);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public FaceRecord get(long id) {
|
||||
return decode(metadata.getString(key(id), null));
|
||||
}
|
||||
|
||||
public InsertResult insert(String name, FaceFeature feature, Bitmap crop) {
|
||||
if (!hubAcquired || feature == null || crop == null) {
|
||||
return new InsertResult(false, null);
|
||||
}
|
||||
long id = Math.max(1L, metadata.getLong(KEY_NEXT_ID, 1L));
|
||||
while (metadata.contains(key(id))) {
|
||||
id++;
|
||||
}
|
||||
File cropFile = cropFile(id);
|
||||
File stagedCrop = stageCrop(cropFile, crop);
|
||||
if (stagedCrop == null) {
|
||||
return new InsertResult(false, null);
|
||||
}
|
||||
File cropBackup = backupFile(cropFile);
|
||||
boolean hadPreviousCrop = cropFile.exists();
|
||||
FaceFeatureIdentity identity = FaceFeatureIdentity.create(id, feature);
|
||||
if (!InspireFace.FeatureHubInsertFeature(identity)) {
|
||||
stagedCrop.delete();
|
||||
return new InsertResult(false, null);
|
||||
}
|
||||
if (!commitStagedCrop(cropFile, stagedCrop, cropBackup)) {
|
||||
InspireFace.FeatureHubFaceRemove(id);
|
||||
return new InsertResult(false, null);
|
||||
}
|
||||
FaceRecord record = new FaceRecord(
|
||||
id, normalizedName(name, id), cropFile.getAbsolutePath(),
|
||||
System.currentTimeMillis());
|
||||
boolean saved = metadata.edit()
|
||||
.putString(key(id), encode(record))
|
||||
.putLong(KEY_NEXT_ID, id + 1)
|
||||
.commit();
|
||||
if (!saved) {
|
||||
InspireFace.FeatureHubFaceRemove(id);
|
||||
restoreCrop(cropFile, cropBackup, hadPreviousCrop);
|
||||
return new InsertResult(false, null);
|
||||
}
|
||||
cropBackup.delete();
|
||||
return new InsertResult(true, record);
|
||||
}
|
||||
|
||||
/** Passing null feature/crop performs a metadata-only rename. */
|
||||
public boolean update(long id, String name,
|
||||
@Nullable FaceFeature feature, @Nullable Bitmap crop) {
|
||||
FaceRecord old = get(id);
|
||||
if (!hubAcquired || old == null) {
|
||||
return false;
|
||||
}
|
||||
File cropFile = new File(old.cropPath);
|
||||
File stagedCrop = crop == null ? null : stageCrop(cropFile, crop);
|
||||
if (crop != null && stagedCrop == null) {
|
||||
return false;
|
||||
}
|
||||
File cropBackup = backupFile(cropFile);
|
||||
boolean hadPreviousCrop = cropFile.exists();
|
||||
|
||||
FaceFeatureIdentity previousIdentity = null;
|
||||
if (feature != null) {
|
||||
previousIdentity = InspireFace.FeatureHubGetFaceIdentity(id);
|
||||
if (previousIdentity == null || !InspireFace.FeatureHubFaceUpdate(
|
||||
FaceFeatureIdentity.create(id, feature))) {
|
||||
if (stagedCrop != null) {
|
||||
stagedCrop.delete();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (stagedCrop != null
|
||||
&& !commitStagedCrop(cropFile, stagedCrop, cropBackup)) {
|
||||
rollbackFeature(previousIdentity);
|
||||
return false;
|
||||
}
|
||||
FaceRecord updated = new FaceRecord(id, normalizedName(name, id),
|
||||
cropFile.getAbsolutePath(), System.currentTimeMillis());
|
||||
boolean saved = metadata.edit().putString(key(id), encode(updated)).commit();
|
||||
if (!saved) {
|
||||
if (stagedCrop != null) {
|
||||
restoreCrop(cropFile, cropBackup, hadPreviousCrop);
|
||||
}
|
||||
rollbackFeature(previousIdentity);
|
||||
return false;
|
||||
}
|
||||
cropBackup.delete();
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean delete(long id) {
|
||||
FaceRecord record = get(id);
|
||||
if (!hubAcquired || record == null) {
|
||||
return false;
|
||||
}
|
||||
File crop = new File(record.cropPath);
|
||||
File pendingDelete = new File(crop.getParentFile(), crop.getName() + ".delete");
|
||||
if (!recoverPendingDelete(crop, pendingDelete)) {
|
||||
return false;
|
||||
}
|
||||
boolean hadCrop = crop.exists();
|
||||
if (hadCrop && !crop.renameTo(pendingDelete)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
FaceFeatureIdentity previousIdentity = InspireFace.FeatureHubGetFaceIdentity(id);
|
||||
if (previousIdentity != null && !InspireFace.FeatureHubFaceRemove(id)
|
||||
&& InspireFace.FeatureHubGetFaceIdentity(id) != null) {
|
||||
restorePendingDelete(crop, pendingDelete, hadCrop);
|
||||
return false;
|
||||
}
|
||||
boolean metadataRemoved = metadata.edit().remove(key(id)).commit();
|
||||
if (!metadataRemoved) {
|
||||
if (previousIdentity != null
|
||||
&& InspireFace.FeatureHubGetFaceIdentity(id) == null) {
|
||||
InspireFace.FeatureHubInsertFeature(previousIdentity);
|
||||
}
|
||||
restorePendingDelete(crop, pendingDelete, hadCrop);
|
||||
return false;
|
||||
}
|
||||
// The identity is already logically deleted. A leftover tombstone is harmless and
|
||||
// will be cleaned the next time this path is touched.
|
||||
pendingDelete.delete();
|
||||
return true;
|
||||
}
|
||||
|
||||
public File databaseFile() {
|
||||
return databaseFile;
|
||||
}
|
||||
|
||||
public File cropDirectory() {
|
||||
return cropDirectory;
|
||||
}
|
||||
|
||||
private File cropFile(long id) {
|
||||
return new File(cropDirectory, id + ".jpg");
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static File stageCrop(File destination, Bitmap crop) {
|
||||
File parent = destination.getParentFile();
|
||||
if (parent == null || (!parent.exists() && !parent.mkdirs())) {
|
||||
return null;
|
||||
}
|
||||
File backup = backupFile(destination);
|
||||
if (!recoverCropReplacement(destination, backup)) {
|
||||
return null;
|
||||
}
|
||||
File temp = new File(parent, destination.getName() + ".tmp");
|
||||
if (temp.exists() && !temp.delete()) {
|
||||
return null;
|
||||
}
|
||||
try (FileOutputStream output = new FileOutputStream(temp)) {
|
||||
if (!crop.compress(Bitmap.CompressFormat.JPEG, 92, output)) {
|
||||
temp.delete();
|
||||
return null;
|
||||
}
|
||||
output.flush();
|
||||
output.getFD().sync();
|
||||
} catch (IOException e) {
|
||||
temp.delete();
|
||||
return null;
|
||||
}
|
||||
return temp;
|
||||
}
|
||||
|
||||
private static boolean commitStagedCrop(File destination, File staged, File backup) {
|
||||
if (backup.exists() && !backup.delete()) {
|
||||
return false;
|
||||
}
|
||||
boolean hadDestination = destination.exists();
|
||||
if (hadDestination && !destination.renameTo(backup)) {
|
||||
staged.delete();
|
||||
return false;
|
||||
}
|
||||
if (!staged.renameTo(destination)) {
|
||||
if (hadDestination) {
|
||||
backup.renameTo(destination);
|
||||
}
|
||||
staged.delete();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void restoreCrop(File destination, File backup, boolean hadPreviousCrop) {
|
||||
if (destination.exists()) {
|
||||
destination.delete();
|
||||
}
|
||||
if (hadPreviousCrop && backup.exists()) {
|
||||
backup.renameTo(destination);
|
||||
} else {
|
||||
backup.delete();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean recoverCropReplacement(File destination, File backup) {
|
||||
if (!backup.exists()) {
|
||||
return true;
|
||||
}
|
||||
if (destination.exists()) {
|
||||
return backup.delete();
|
||||
}
|
||||
return backup.renameTo(destination);
|
||||
}
|
||||
|
||||
private static boolean recoverPendingDelete(File crop, File pendingDelete) {
|
||||
if (!pendingDelete.exists()) {
|
||||
return true;
|
||||
}
|
||||
if (crop.exists()) {
|
||||
return pendingDelete.delete();
|
||||
}
|
||||
return pendingDelete.renameTo(crop);
|
||||
}
|
||||
|
||||
private static void restorePendingDelete(File crop, File pendingDelete, boolean hadCrop) {
|
||||
if (hadCrop && pendingDelete.exists()) {
|
||||
pendingDelete.renameTo(crop);
|
||||
} else if (!hadCrop) {
|
||||
pendingDelete.delete();
|
||||
}
|
||||
}
|
||||
|
||||
private static void rollbackFeature(@Nullable FaceFeatureIdentity previous) {
|
||||
if (previous != null) {
|
||||
InspireFace.FeatureHubFaceUpdate(previous);
|
||||
}
|
||||
}
|
||||
|
||||
private static File backupFile(File destination) {
|
||||
return new File(destination.getParentFile(), destination.getName() + ".bak");
|
||||
}
|
||||
|
||||
private static String normalizedName(String name, long id) {
|
||||
String trimmed = name == null ? "" : name.trim();
|
||||
return trimmed.isEmpty() ? "Face " + id : trimmed;
|
||||
}
|
||||
|
||||
private static String key(long id) {
|
||||
return KEY_PREFIX + id;
|
||||
}
|
||||
|
||||
private static String encode(FaceRecord record) {
|
||||
try {
|
||||
return new JSONObject()
|
||||
.put("id", record.id)
|
||||
.put("name", record.name)
|
||||
.put("crop", record.cropPath)
|
||||
.put("updated", record.updatedAt)
|
||||
.toString();
|
||||
} catch (JSONException impossible) {
|
||||
throw new IllegalStateException(impossible);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static FaceRecord decode(@Nullable String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
JSONObject json = new JSONObject(value);
|
||||
return new FaceRecord(json.getLong("id"), json.getString("name"),
|
||||
json.getString("crop"), json.getLong("updated"));
|
||||
} catch (JSONException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.example.inspireface_example.face;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.graphics.Matrix;
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.exifinterface.media.ExifInterface;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/** Decodes a content Uri with bounded memory and applies its EXIF orientation. */
|
||||
public final class ImageBitmapLoader {
|
||||
|
||||
private ImageBitmapLoader() {
|
||||
}
|
||||
|
||||
public static Bitmap decode(Context context, Uri uri, int maxDimension) throws IOException {
|
||||
int orientation = readOrientation(context, uri);
|
||||
BitmapFactory.Options bounds = new BitmapFactory.Options();
|
||||
bounds.inJustDecodeBounds = true;
|
||||
try (InputStream input = openInput(context, uri)) {
|
||||
BitmapFactory.decodeStream(input, null, bounds);
|
||||
}
|
||||
|
||||
BitmapFactory.Options options = new BitmapFactory.Options();
|
||||
options.inPreferredConfig = Bitmap.Config.ARGB_8888;
|
||||
options.inSampleSize = calculateSampleSize(
|
||||
bounds.outWidth, bounds.outHeight, maxDimension);
|
||||
Bitmap decoded;
|
||||
try (InputStream input = openInput(context, uri)) {
|
||||
decoded = BitmapFactory.decodeStream(input, null, options);
|
||||
}
|
||||
if (decoded == null) {
|
||||
throw new IOException("Unable to decode selected image");
|
||||
}
|
||||
return applyExifOrientation(decoded, orientation);
|
||||
}
|
||||
|
||||
private static int readOrientation(Context context, Uri uri) {
|
||||
try (InputStream input = openInput(context, uri)) {
|
||||
return new ExifInterface(input).getAttributeInt(
|
||||
ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
|
||||
} catch (IOException | RuntimeException ignored) {
|
||||
return ExifInterface.ORIENTATION_NORMAL;
|
||||
}
|
||||
}
|
||||
|
||||
private static InputStream openInput(Context context, Uri uri) throws IOException {
|
||||
InputStream input = context.getContentResolver().openInputStream(uri);
|
||||
if (input == null) {
|
||||
throw new IOException("Unable to open selected image");
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
private static int calculateSampleSize(int width, int height, int maxDimension) {
|
||||
int longest = Math.max(width, height);
|
||||
int sample = 1;
|
||||
while (longest > 0 && longest / sample > maxDimension) {
|
||||
sample *= 2;
|
||||
}
|
||||
return sample;
|
||||
}
|
||||
|
||||
private static Bitmap applyExifOrientation(Bitmap bitmap, int orientation) {
|
||||
Matrix matrix = new Matrix();
|
||||
switch (orientation) {
|
||||
case ExifInterface.ORIENTATION_FLIP_HORIZONTAL:
|
||||
matrix.setScale(-1f, 1f);
|
||||
break;
|
||||
case ExifInterface.ORIENTATION_ROTATE_180:
|
||||
matrix.setRotate(180f);
|
||||
break;
|
||||
case ExifInterface.ORIENTATION_FLIP_VERTICAL:
|
||||
matrix.setRotate(180f);
|
||||
matrix.postScale(-1f, 1f);
|
||||
break;
|
||||
case ExifInterface.ORIENTATION_TRANSPOSE:
|
||||
matrix.setRotate(90f);
|
||||
matrix.postScale(-1f, 1f);
|
||||
break;
|
||||
case ExifInterface.ORIENTATION_ROTATE_90:
|
||||
matrix.setRotate(90f);
|
||||
break;
|
||||
case ExifInterface.ORIENTATION_TRANSVERSE:
|
||||
matrix.setRotate(-90f);
|
||||
matrix.postScale(-1f, 1f);
|
||||
break;
|
||||
case ExifInterface.ORIENTATION_ROTATE_270:
|
||||
matrix.setRotate(-90f);
|
||||
break;
|
||||
case ExifInterface.ORIENTATION_NORMAL:
|
||||
default:
|
||||
return bitmap;
|
||||
}
|
||||
Bitmap oriented = Bitmap.createBitmap(
|
||||
bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);
|
||||
if (oriented != bitmap) {
|
||||
bitmap.recycle();
|
||||
}
|
||||
return oriented;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package com.example.inspireface_example.permission;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.Uri;
|
||||
import android.provider.Settings;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.example.inspireface_example.R;
|
||||
import com.google.android.material.button.MaterialButton;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
/**
|
||||
* Shared camera-permission UX for every live feature.
|
||||
*
|
||||
* <p>It owns the one-time on-device privacy notice, Android permission request,
|
||||
* rationale, permanent-denial settings recovery, and the return-from-settings check.</p>
|
||||
*/
|
||||
public final class CameraPermissionCoordinator {
|
||||
|
||||
public interface Listener {
|
||||
void onCameraPermissionGranted();
|
||||
|
||||
void onCameraPermissionBlocked(boolean requiresSettings);
|
||||
}
|
||||
|
||||
public static final String PREFERENCES_NAME = "camera_permission";
|
||||
private static final String KEY_NOTICE_ACCEPTED = "notice_accepted";
|
||||
private static final String KEY_REQUESTED_BEFORE = "requested_before";
|
||||
|
||||
private final AppCompatActivity activity;
|
||||
private final Listener listener;
|
||||
private final SharedPreferences preferences;
|
||||
private final ActivityResultLauncher<String> permissionLauncher;
|
||||
|
||||
private MaterialButton recoveryButton;
|
||||
private AlertDialog activeDialog;
|
||||
private boolean permissionRequestInFlight;
|
||||
private boolean awaitingSettings;
|
||||
private boolean closed;
|
||||
|
||||
public CameraPermissionCoordinator(AppCompatActivity activity, Listener listener) {
|
||||
this.activity = activity;
|
||||
this.listener = listener;
|
||||
preferences = activity.getSharedPreferences(
|
||||
PREFERENCES_NAME, Context.MODE_PRIVATE);
|
||||
permissionLauncher = activity.registerForActivityResult(
|
||||
new ActivityResultContracts.RequestPermission(), this::onPermissionResult);
|
||||
}
|
||||
|
||||
/** Binds the persistent Try again / Open settings action shown by each camera page. */
|
||||
public void bindRecoveryButton(MaterialButton button) {
|
||||
recoveryButton = button;
|
||||
recoveryButton.setOnClickListener(v -> performRecoveryAction());
|
||||
recoveryButton.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
/** Starts or resumes the permission flow without issuing duplicate requests/dialogs. */
|
||||
public void requestAccess() {
|
||||
if (closed || activity.isFinishing() || activity.isDestroyed()
|
||||
|| permissionRequestInFlight || isDialogShowing()) {
|
||||
return;
|
||||
}
|
||||
CameraPermissionPolicy.Action action = CameraPermissionPolicy.nextAction(
|
||||
preferences.getBoolean(KEY_NOTICE_ACCEPTED, false),
|
||||
hasPermission(),
|
||||
preferences.getBoolean(KEY_REQUESTED_BEFORE, false),
|
||||
shouldShowRationale());
|
||||
switch (action) {
|
||||
case SHOW_NOTICE:
|
||||
showPrivacyNotice();
|
||||
break;
|
||||
case GRANTED:
|
||||
notifyGranted();
|
||||
break;
|
||||
case REQUEST_SYSTEM_PERMISSION:
|
||||
launchSystemPermission();
|
||||
break;
|
||||
case SHOW_RATIONALE:
|
||||
showRationale();
|
||||
break;
|
||||
case OPEN_SETTINGS:
|
||||
notifyBlocked(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Call from Activity.onResume so a Settings grant takes effect without reopening the page. */
|
||||
public void onResume() {
|
||||
if (closed || !awaitingSettings) {
|
||||
return;
|
||||
}
|
||||
awaitingSettings = false;
|
||||
if (hasPermission()) {
|
||||
notifyGranted();
|
||||
} else {
|
||||
notifyBlocked(true);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasPermission() {
|
||||
return ContextCompat.checkSelfPermission(activity, Manifest.permission.CAMERA)
|
||||
== PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
public void close() {
|
||||
closed = true;
|
||||
if (activeDialog != null) {
|
||||
activeDialog.dismiss();
|
||||
activeDialog = null;
|
||||
}
|
||||
recoveryButton = null;
|
||||
}
|
||||
|
||||
private void showPrivacyNotice() {
|
||||
activeDialog = new MaterialAlertDialogBuilder(activity)
|
||||
.setTitle(R.string.camera_privacy_notice_title)
|
||||
.setMessage(R.string.camera_privacy_notice_message)
|
||||
.setNegativeButton(R.string.camera_permission_not_now, (dialog, which) -> {
|
||||
activeDialog = null;
|
||||
notifyBlocked(false);
|
||||
})
|
||||
.setPositiveButton(R.string.camera_permission_continue, (dialog, which) -> {
|
||||
activeDialog = null;
|
||||
preferences.edit().putBoolean(KEY_NOTICE_ACCEPTED, true).apply();
|
||||
requestAccess();
|
||||
})
|
||||
.setOnCancelListener(dialog -> {
|
||||
activeDialog = null;
|
||||
notifyBlocked(false);
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
private void showRationale() {
|
||||
activeDialog = new MaterialAlertDialogBuilder(activity)
|
||||
.setTitle(R.string.camera_permission_rationale_title)
|
||||
.setMessage(R.string.camera_permission_rationale_message)
|
||||
.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
|
||||
activeDialog = null;
|
||||
notifyBlocked(false);
|
||||
})
|
||||
.setPositiveButton(R.string.camera_permission_try_again, (dialog, which) -> {
|
||||
activeDialog = null;
|
||||
launchSystemPermission();
|
||||
})
|
||||
.setOnCancelListener(dialog -> {
|
||||
activeDialog = null;
|
||||
notifyBlocked(false);
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
private void launchSystemPermission() {
|
||||
preferences.edit().putBoolean(KEY_REQUESTED_BEFORE, true).apply();
|
||||
permissionRequestInFlight = true;
|
||||
permissionLauncher.launch(Manifest.permission.CAMERA);
|
||||
}
|
||||
|
||||
private void onPermissionResult(boolean granted) {
|
||||
permissionRequestInFlight = false;
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
if (granted) {
|
||||
notifyGranted();
|
||||
} else {
|
||||
notifyBlocked(!shouldShowRationale());
|
||||
}
|
||||
}
|
||||
|
||||
private void performRecoveryAction() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
if (hasPermission()) {
|
||||
notifyGranted();
|
||||
return;
|
||||
}
|
||||
boolean requestedBefore = preferences.getBoolean(KEY_REQUESTED_BEFORE, false);
|
||||
if (requestedBefore && !shouldShowRationale()) {
|
||||
openApplicationSettings();
|
||||
} else {
|
||||
requestAccess();
|
||||
}
|
||||
}
|
||||
|
||||
private void openApplicationSettings() {
|
||||
awaitingSettings = true;
|
||||
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", activity.getPackageName(), null));
|
||||
activity.startActivity(intent);
|
||||
}
|
||||
|
||||
private void notifyGranted() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
if (recoveryButton != null) {
|
||||
recoveryButton.setVisibility(View.GONE);
|
||||
}
|
||||
listener.onCameraPermissionGranted();
|
||||
}
|
||||
|
||||
private void notifyBlocked(boolean requiresSettings) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
if (recoveryButton != null) {
|
||||
recoveryButton.setText(requiresSettings
|
||||
? R.string.camera_permission_open_settings
|
||||
: R.string.camera_permission_try_again);
|
||||
recoveryButton.setVisibility(View.VISIBLE);
|
||||
}
|
||||
listener.onCameraPermissionBlocked(requiresSettings);
|
||||
}
|
||||
|
||||
private boolean shouldShowRationale() {
|
||||
return ActivityCompat.shouldShowRequestPermissionRationale(
|
||||
activity, Manifest.permission.CAMERA);
|
||||
}
|
||||
|
||||
private boolean isDialogShowing() {
|
||||
return activeDialog != null && activeDialog.isShowing();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.example.inspireface_example.permission;
|
||||
|
||||
/** Pure decision table for the camera permission flow; kept Android-free for unit tests. */
|
||||
public final class CameraPermissionPolicy {
|
||||
|
||||
public enum Action {
|
||||
SHOW_NOTICE,
|
||||
GRANTED,
|
||||
REQUEST_SYSTEM_PERMISSION,
|
||||
SHOW_RATIONALE,
|
||||
OPEN_SETTINGS
|
||||
}
|
||||
|
||||
private CameraPermissionPolicy() {
|
||||
}
|
||||
|
||||
public static Action nextAction(boolean noticeAccepted,
|
||||
boolean permissionGranted,
|
||||
boolean requestedBefore,
|
||||
boolean shouldShowRationale) {
|
||||
if (!noticeAccepted) {
|
||||
return Action.SHOW_NOTICE;
|
||||
}
|
||||
if (permissionGranted) {
|
||||
return Action.GRANTED;
|
||||
}
|
||||
if (!requestedBefore) {
|
||||
return Action.REQUEST_SYSTEM_PERMISSION;
|
||||
}
|
||||
return shouldShowRationale ? Action.SHOW_RATIONALE : Action.OPEN_SETTINGS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.inspireface_example.view;
|
||||
|
||||
import com.example.inspireface_example.R;
|
||||
|
||||
/** Dedicated cooperative action-liveness screen. */
|
||||
public final class ActionLivenessActivity extends LivenessActivity {
|
||||
|
||||
@Override
|
||||
protected LivenessController.Mode initialMode() {
|
||||
return LivenessController.Mode.ACTION;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int pageTitleRes() {
|
||||
return R.string.mode_action;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.example.inspireface_example.view;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Size;
|
||||
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.camera.core.CameraInfoUnavailableException;
|
||||
import androidx.camera.core.CameraSelector;
|
||||
import androidx.camera.core.ImageAnalysis;
|
||||
import androidx.camera.core.Preview;
|
||||
import androidx.camera.core.resolutionselector.AspectRatioStrategy;
|
||||
import androidx.camera.core.resolutionselector.ResolutionSelector;
|
||||
import androidx.camera.core.resolutionselector.ResolutionStrategy;
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider;
|
||||
import androidx.camera.view.PreviewView;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
|
||||
import com.example.inspireface_example.R;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* Reusable CameraX preview + analysis component shared by live demos and enrollment.
|
||||
* It owns lens fallback/switching and keeps both use cases on the same 4:3 crop.
|
||||
*/
|
||||
public final class CameraPreviewController {
|
||||
|
||||
public interface Listener {
|
||||
/** Initial lens is ready; false means the device fell back to the rear camera. */
|
||||
void onCameraReady(boolean frontCamera);
|
||||
|
||||
/** Called only after a user-requested lens switch succeeds. */
|
||||
void onLensChanged(boolean frontCamera);
|
||||
|
||||
void onCameraError(@StringRes int messageRes);
|
||||
}
|
||||
|
||||
private final Context context;
|
||||
private final LifecycleOwner lifecycleOwner;
|
||||
private final PreviewView previewView;
|
||||
private final Executor analysisExecutor;
|
||||
private final ImageAnalysis.Analyzer analyzer;
|
||||
private final Listener listener;
|
||||
|
||||
private ProcessCameraProvider cameraProvider;
|
||||
private Preview preview;
|
||||
private ImageAnalysis analysis;
|
||||
private boolean useFrontCamera = true;
|
||||
private boolean stopped;
|
||||
|
||||
public CameraPreviewController(Context context, LifecycleOwner lifecycleOwner,
|
||||
PreviewView previewView, Executor analysisExecutor,
|
||||
ImageAnalysis.Analyzer analyzer, Listener listener) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.lifecycleOwner = lifecycleOwner;
|
||||
this.previewView = previewView;
|
||||
this.analysisExecutor = analysisExecutor;
|
||||
this.analyzer = analyzer;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
/** Asynchronously acquires CameraX and binds a front camera, with rear fallback. */
|
||||
public void start() {
|
||||
start(true);
|
||||
}
|
||||
|
||||
/** Asynchronously acquires CameraX and binds the requested lens, with opposite fallback. */
|
||||
public void start(boolean preferFrontCamera) {
|
||||
stopped = false;
|
||||
useFrontCamera = preferFrontCamera;
|
||||
ListenableFuture<ProcessCameraProvider> future =
|
||||
ProcessCameraProvider.getInstance(context);
|
||||
future.addListener(() -> {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
cameraProvider = future.get();
|
||||
} catch (ExecutionException e) {
|
||||
listener.onCameraError(R.string.msg_engine_failed);
|
||||
return;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
listener.onCameraError(R.string.msg_engine_failed);
|
||||
return;
|
||||
}
|
||||
CameraSelector requested = useFrontCamera
|
||||
? CameraSelector.DEFAULT_FRONT_CAMERA
|
||||
: CameraSelector.DEFAULT_BACK_CAMERA;
|
||||
if (!hasCamera(requested)) {
|
||||
CameraSelector fallback = useFrontCamera
|
||||
? CameraSelector.DEFAULT_BACK_CAMERA
|
||||
: CameraSelector.DEFAULT_FRONT_CAMERA;
|
||||
if (hasCamera(fallback)) {
|
||||
useFrontCamera = !useFrontCamera;
|
||||
} else {
|
||||
listener.onCameraError(R.string.msg_no_front_camera);
|
||||
return;
|
||||
}
|
||||
}
|
||||
createUseCases();
|
||||
if (bindCurrentLens()) {
|
||||
listener.onCameraReady(useFrontCamera);
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(context));
|
||||
}
|
||||
|
||||
/** Returns false if CameraX is not ready or the opposite lens does not exist. */
|
||||
public boolean flipCamera() {
|
||||
if (cameraProvider == null || stopped) {
|
||||
return false;
|
||||
}
|
||||
boolean targetFront = !useFrontCamera;
|
||||
if (!hasCamera(targetFront ? CameraSelector.DEFAULT_FRONT_CAMERA
|
||||
: CameraSelector.DEFAULT_BACK_CAMERA)) {
|
||||
return false;
|
||||
}
|
||||
useFrontCamera = targetFront;
|
||||
if (!bindCurrentLens()) {
|
||||
useFrontCamera = !targetFront;
|
||||
bindCurrentLens();
|
||||
return false;
|
||||
}
|
||||
listener.onLensChanged(useFrontCamera);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Stops only this component's use cases; it does not disturb another Activity. */
|
||||
public void stop() {
|
||||
stopped = true;
|
||||
if (analysis != null) {
|
||||
analysis.clearAnalyzer();
|
||||
}
|
||||
if (cameraProvider != null && preview != null && analysis != null) {
|
||||
cameraProvider.unbind(preview, analysis);
|
||||
}
|
||||
}
|
||||
|
||||
private void createUseCases() {
|
||||
ResolutionSelector analysisResolution = new ResolutionSelector.Builder()
|
||||
.setAspectRatioStrategy(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY)
|
||||
.setResolutionStrategy(new ResolutionStrategy(new Size(640, 480),
|
||||
ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER))
|
||||
.build();
|
||||
analysis = new ImageAnalysis.Builder()
|
||||
.setResolutionSelector(analysisResolution)
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build();
|
||||
analysis.setAnalyzer(analysisExecutor, analyzer);
|
||||
|
||||
preview = new Preview.Builder()
|
||||
.setResolutionSelector(new ResolutionSelector.Builder()
|
||||
.setAspectRatioStrategy(
|
||||
AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY)
|
||||
.build())
|
||||
.build();
|
||||
preview.setSurfaceProvider(previewView.getSurfaceProvider());
|
||||
}
|
||||
|
||||
private boolean bindCurrentLens() {
|
||||
if (cameraProvider == null || preview == null || analysis == null || stopped) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
cameraProvider.unbind(preview, analysis);
|
||||
cameraProvider.bindToLifecycle(lifecycleOwner,
|
||||
useFrontCamera ? CameraSelector.DEFAULT_FRONT_CAMERA
|
||||
: CameraSelector.DEFAULT_BACK_CAMERA,
|
||||
preview, analysis);
|
||||
return true;
|
||||
} catch (RuntimeException e) {
|
||||
listener.onCameraError(R.string.msg_camera_unavailable);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasCamera(CameraSelector selector) {
|
||||
try {
|
||||
return cameraProvider != null && cameraProvider.hasCamera(selector);
|
||||
} catch (CameraInfoUnavailableException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.example.inspireface_example.view;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.ImageFormat;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.YuvImage;
|
||||
import android.os.SystemClock;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.example.inspireface_example.R;
|
||||
import com.insightface.sdk.inspireface.base.FaceRect;
|
||||
import com.insightface.sdk.inspireface.base.ImageStream;
|
||||
import com.insightface.sdk.inspireface.base.MultipleFaceData;
|
||||
import com.insightface.sdk.inspireface.base.Session;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
/** Tracks SDK face 0 and captures it after one warm-up second plus two stable seconds. */
|
||||
final class EnrollmentFaceAnalyzer extends UprightFaceCameraAnalyzer {
|
||||
|
||||
enum Stage { NO_FACE, MOVE_CLOSER, HOLD_STILL, CAPTURING, COMPLETE }
|
||||
|
||||
interface Listener {
|
||||
/** Called on the analysis thread. Progress is meaningful only for CAPTURING. */
|
||||
void onState(Stage stage, float progress);
|
||||
|
||||
/** Called once with an app-private temporary JPEG. */
|
||||
void onCaptured(String path);
|
||||
|
||||
void onCaptureError();
|
||||
|
||||
void onSessionError();
|
||||
}
|
||||
|
||||
private static final long STATE_REPORT_INTERVAL_MS = 80L;
|
||||
private static final float MIN_FACE_WIDTH_RATIO = 0.18f;
|
||||
|
||||
private final FaceOverlayView overlay;
|
||||
private final Listener listener;
|
||||
private final File captureDirectory;
|
||||
private final FaceStabilityGate stabilityGate = new FaceStabilityGate();
|
||||
private final int accentColor;
|
||||
private final int redColor;
|
||||
private final int yellowColor;
|
||||
private final int greenColor;
|
||||
private long lastStateReport;
|
||||
private Stage lastStage;
|
||||
private int lastProgressBucket = -1;
|
||||
private volatile boolean mirrored = true;
|
||||
private volatile boolean resetRequested;
|
||||
private boolean captured;
|
||||
|
||||
EnrollmentFaceAnalyzer(Context context, FaceOverlayView overlay,
|
||||
File captureDirectory, Listener listener) {
|
||||
this.overlay = overlay;
|
||||
this.captureDirectory = captureDirectory;
|
||||
this.listener = listener;
|
||||
accentColor = ContextCompat.getColor(context, R.color.liveness_accent);
|
||||
redColor = ContextCompat.getColor(context, R.color.liveness_fail);
|
||||
yellowColor = ContextCompat.getColor(context, R.color.liveness_warn);
|
||||
greenColor = accentColor;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldSkipFrame() {
|
||||
return captured;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void beforeFrame() {
|
||||
if (resetRequested) {
|
||||
resetRequested = false;
|
||||
clearStability();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Session createSession() {
|
||||
return FaceEngine.createTrackingSession();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onFaces(Session session, ImageStream stream,
|
||||
@Nullable MultipleFaceData faces, byte[] upright,
|
||||
int uprightWidth, int uprightHeight, long frameStart) {
|
||||
if (faces == null || faces.detectedNum == 0) {
|
||||
clearStability();
|
||||
overlay.submit(null);
|
||||
reportState(Stage.NO_FACE, 0f);
|
||||
return;
|
||||
}
|
||||
FaceRect first = faces.rects[0];
|
||||
RectF face = new RectF(first.x, first.y,
|
||||
first.x + first.width, first.y + first.height);
|
||||
int trackId = faces.trackIds != null && faces.trackIds.length > 0
|
||||
? faces.trackIds[0] : 0;
|
||||
if (face.width() < uprightWidth * MIN_FACE_WIDTH_RATIO) {
|
||||
clearStability();
|
||||
submitFrame(face, uprightWidth, uprightHeight,
|
||||
-1f, redColor, redColor);
|
||||
reportState(Stage.MOVE_CLOSER, 0f);
|
||||
return;
|
||||
}
|
||||
|
||||
float progress = stabilityGate.update(trackId,
|
||||
face.left, face.top, face.right, face.bottom,
|
||||
SystemClock.elapsedRealtime());
|
||||
if (progress < 0f) {
|
||||
submitFrame(face, uprightWidth, uprightHeight,
|
||||
-1f, accentColor, accentColor);
|
||||
reportState(Stage.HOLD_STILL, 0f);
|
||||
return;
|
||||
}
|
||||
|
||||
int progressColor = progress >= 1f ? greenColor
|
||||
: progress >= 0.5f ? yellowColor : redColor;
|
||||
submitFrame(face, uprightWidth, uprightHeight,
|
||||
progress, progressColor, progressColor);
|
||||
reportState(progress >= 1f ? Stage.COMPLETE : Stage.CAPTURING, progress);
|
||||
if (progress >= 1f) {
|
||||
File capture = writeCapture(upright, uprightWidth, uprightHeight, face);
|
||||
if (capture == null) {
|
||||
clearStability();
|
||||
listener.onCaptureError();
|
||||
} else {
|
||||
captured = true;
|
||||
listener.onCaptured(capture.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSessionError() {
|
||||
listener.onSessionError();
|
||||
}
|
||||
|
||||
void setMirrored(boolean mirrored) {
|
||||
this.mirrored = mirrored;
|
||||
}
|
||||
|
||||
void resetTracking() {
|
||||
resetRequested = true;
|
||||
overlay.submit(null);
|
||||
}
|
||||
|
||||
private void clearStability() {
|
||||
stabilityGate.reset();
|
||||
}
|
||||
|
||||
private void submitFrame(RectF face, int imageWidth, int imageHeight,
|
||||
float progress, int boxColor, int progressColor) {
|
||||
overlay.submit(new FaceOverlayView.Frame(imageWidth, imageHeight, mirrored,
|
||||
new RectF[]{new RectF(face)}, boxColor, progress, progressColor));
|
||||
}
|
||||
|
||||
private void reportState(Stage stage, float progress) {
|
||||
long now = SystemClock.elapsedRealtime();
|
||||
int bucket = Math.round(progress * 100f);
|
||||
if (stage != lastStage || bucket != lastProgressBucket
|
||||
|| now - lastStateReport >= STATE_REPORT_INTERVAL_MS) {
|
||||
lastStage = stage;
|
||||
lastProgressBucket = bucket;
|
||||
lastStateReport = now;
|
||||
listener.onState(stage, progress);
|
||||
}
|
||||
}
|
||||
|
||||
private File writeCapture(byte[] nv21, int width, int height, RectF face) {
|
||||
if ((!captureDirectory.exists() && !captureDirectory.mkdirs())
|
||||
|| !captureDirectory.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
File destination;
|
||||
try {
|
||||
destination = File.createTempFile("face_", ".jpg", captureDirectory);
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
Rect crop = paddedEvenCrop(face, width, height);
|
||||
try (FileOutputStream output = new FileOutputStream(destination)) {
|
||||
boolean compressed = new YuvImage(
|
||||
nv21, ImageFormat.NV21, width, height, null)
|
||||
.compressToJpeg(crop, 94, output);
|
||||
if (!compressed) {
|
||||
destination.delete();
|
||||
return null;
|
||||
}
|
||||
output.flush();
|
||||
output.getFD().sync();
|
||||
return destination;
|
||||
} catch (IOException | RuntimeException e) {
|
||||
destination.delete();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Rect paddedEvenCrop(RectF face, int width, int height) {
|
||||
float paddingX = face.width() * 0.45f;
|
||||
float paddingY = face.height() * 0.55f;
|
||||
int left = Math.max(0, ((int) Math.floor(face.left - paddingX)) & ~1);
|
||||
int top = Math.max(0, ((int) Math.floor(face.top - paddingY)) & ~1);
|
||||
int right = Math.min(width, ((int) Math.ceil(face.right + paddingX) + 1) & ~1);
|
||||
int bottom = Math.min(height, ((int) Math.ceil(face.bottom + paddingY) + 1) & ~1);
|
||||
if (right <= left + 2 || bottom <= top + 2) {
|
||||
return new Rect(0, 0, width & ~1, height & ~1);
|
||||
}
|
||||
return new Rect(left, top, right, bottom);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package com.example.inspireface_example.view;
|
||||
|
||||
import android.graphics.RectF;
|
||||
import android.os.SystemClock;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.insightface.sdk.inspireface.InspireFace;
|
||||
import com.insightface.sdk.inspireface.base.FaceEulerAngle;
|
||||
import com.insightface.sdk.inspireface.base.ImageStream;
|
||||
import com.insightface.sdk.inspireface.base.MultipleFaceData;
|
||||
import com.insightface.sdk.inspireface.base.Point2f;
|
||||
import com.insightface.sdk.inspireface.base.Session;
|
||||
|
||||
/**
|
||||
* Per-frame pipeline: YUV_420_888 → NV21 → rotate upright → InspireFace track → mode
|
||||
* logic → UI.
|
||||
*
|
||||
* Frames are handed to the SDK already upright with CAMERA_ROTATION_0 (see
|
||||
* {@link Nv21Converter} for why), so every SDK coordinate is directly in preview
|
||||
* orientation. Everything runs on the single-threaded analysis executor, which also
|
||||
* keeps all session calls serialized. The ImageProxy is closed as soon as the NV21 copy
|
||||
* exists so CameraX can refill the buffer while inference runs, and the stream is
|
||||
* created, used and released within this frame because the SDK aliases the byte[]
|
||||
* rather than copying it.
|
||||
*/
|
||||
final class FaceAnalyzer extends UprightFaceCameraAnalyzer {
|
||||
|
||||
interface Listener {
|
||||
/** Called on the analysis thread. */
|
||||
void onUiState(LivenessController.UiState state);
|
||||
|
||||
/** Called on the analysis thread, throttled. */
|
||||
void onPerf(double fps, long latencyMs);
|
||||
|
||||
/**
|
||||
* Called on the analysis thread while the euler readout is enabled, throttled.
|
||||
* {@code angle} is null when no face is tracked.
|
||||
*/
|
||||
void onEulerAngles(FaceEulerAngle angle);
|
||||
|
||||
/** Called once on the analysis thread if the session cannot be created. */
|
||||
void onSessionError();
|
||||
}
|
||||
|
||||
private static final long PERF_REPORT_INTERVAL_MS = 500;
|
||||
private static final long EULER_REPORT_INTERVAL_MS = 100;
|
||||
|
||||
private static final int LANDMARK_FLOATS = 106 * 2;
|
||||
|
||||
private final LivenessController controller;
|
||||
private final FaceOverlayView overlay;
|
||||
private final LandmarkGlView landmarkView;
|
||||
private final Listener listener;
|
||||
/** Front camera previews are displayed mirrored, back camera ones are not. */
|
||||
private volatile boolean mirrored;
|
||||
private float[] landmarkScratch = new float[LANDMARK_FLOATS];
|
||||
|
||||
private volatile boolean eulerEnabled;
|
||||
private volatile boolean landmarksEnabled;
|
||||
private long lastEulerReport;
|
||||
|
||||
// Perf tracking
|
||||
private long windowStart;
|
||||
private int windowFrames;
|
||||
private double fps;
|
||||
private double emaLatencyMs;
|
||||
private long lastPerfReport;
|
||||
|
||||
FaceAnalyzer(LivenessController controller, FaceOverlayView overlay,
|
||||
LandmarkGlView landmarkView, Listener listener, boolean mirrored) {
|
||||
this.controller = controller;
|
||||
this.overlay = overlay;
|
||||
this.landmarkView = landmarkView;
|
||||
this.listener = listener;
|
||||
this.mirrored = mirrored;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Session createSession() {
|
||||
return FaceEngine.createPreviewSession();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onFaces(Session session, ImageStream stream,
|
||||
@Nullable MultipleFaceData faces, byte[] uprightNv21,
|
||||
int uprightWidth, int uprightHeight, long frameStart) {
|
||||
if (faces == null) {
|
||||
return;
|
||||
}
|
||||
LivenessController.UiState state =
|
||||
controller.onFrame(session, stream, faces, uprightWidth, uprightHeight);
|
||||
overlay.submit(buildOverlayFrame(faces, uprightWidth, uprightHeight, state.boxColor));
|
||||
listener.onUiState(state);
|
||||
reportEulerAngles(faces);
|
||||
renderLandmarks(faces, uprightWidth, uprightHeight);
|
||||
trackPerf(frameStart);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSessionError() {
|
||||
listener.onSessionError();
|
||||
}
|
||||
|
||||
/** Safe to call from any thread. */
|
||||
void setEulerEnabled(boolean enabled) {
|
||||
eulerEnabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe to call from any thread; flip together with the camera lens. Rotation needs
|
||||
* no per-lens handling — every frame is pre-rotated by its own rotationDegrees — so
|
||||
* mirroring is the only display difference between the lenses.
|
||||
*/
|
||||
void setMirrored(boolean mirrored) {
|
||||
this.mirrored = mirrored;
|
||||
}
|
||||
|
||||
/** Safe to call from any thread. */
|
||||
void setLandmarksEnabled(boolean enabled) {
|
||||
landmarksEnabled = enabled;
|
||||
}
|
||||
|
||||
/** Dense 106-point landmarks for every tracked face, batched into one GL submission. */
|
||||
private void renderLandmarks(MultipleFaceData faces, int uprightWidth, int uprightHeight) {
|
||||
if (!landmarksEnabled) {
|
||||
return;
|
||||
}
|
||||
if (faces.detectedNum == 0) {
|
||||
landmarkView.clearPoints();
|
||||
return;
|
||||
}
|
||||
int needed = faces.detectedNum * LANDMARK_FLOATS;
|
||||
if (landmarkScratch.length < needed) {
|
||||
landmarkScratch = new float[needed];
|
||||
}
|
||||
int n = 0;
|
||||
for (int i = 0; i < faces.detectedNum; i++) {
|
||||
Point2f[] landmarks = InspireFace.GetFaceDenseLandmarkFromFaceToken(faces.tokens[i]);
|
||||
if (landmarks == null) {
|
||||
continue;
|
||||
}
|
||||
for (Point2f p : landmarks) {
|
||||
landmarkScratch[n++] = p.x;
|
||||
landmarkScratch[n++] = p.y;
|
||||
}
|
||||
}
|
||||
landmarkView.submitPoints(landmarkScratch, n / 2, uprightWidth, uprightHeight, mirrored);
|
||||
}
|
||||
|
||||
private void reportEulerAngles(MultipleFaceData faces) {
|
||||
if (!eulerEnabled) {
|
||||
return;
|
||||
}
|
||||
long now = SystemClock.elapsedRealtime();
|
||||
if (now - lastEulerReport < EULER_REPORT_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
lastEulerReport = now;
|
||||
// Only angles[0] is trustworthy — the 1.2.0 JNI writes face[0]'s angles into
|
||||
// every slot, so per-face readout beyond the first face is impossible anyway.
|
||||
listener.onEulerAngles(faces.detectedNum > 0 ? faces.angles[0] : null);
|
||||
}
|
||||
|
||||
/** The stream is already upright, so SDK rects map to the preview directly. */
|
||||
private FaceOverlayView.Frame buildOverlayFrame(MultipleFaceData faces,
|
||||
int uprightWidth, int uprightHeight, int color) {
|
||||
RectF[] rects = new RectF[faces.detectedNum];
|
||||
for (int i = 0; i < faces.detectedNum; i++) {
|
||||
rects[i] = new RectF(faces.rects[i].x, faces.rects[i].y,
|
||||
faces.rects[i].x + faces.rects[i].width,
|
||||
faces.rects[i].y + faces.rects[i].height);
|
||||
}
|
||||
return new FaceOverlayView.Frame(
|
||||
uprightWidth, uprightHeight, mirrored, rects, color);
|
||||
}
|
||||
|
||||
private void trackPerf(long start) {
|
||||
long now = SystemClock.elapsedRealtime();
|
||||
long cost = now - start;
|
||||
emaLatencyMs = emaLatencyMs == 0 ? cost : emaLatencyMs * 0.85 + cost * 0.15;
|
||||
windowFrames++;
|
||||
if (windowStart == 0) {
|
||||
windowStart = now;
|
||||
} else if (now - windowStart >= 1000) {
|
||||
fps = windowFrames * 1000.0 / (now - windowStart);
|
||||
windowFrames = 0;
|
||||
windowStart = now;
|
||||
}
|
||||
if (now - lastPerfReport >= PERF_REPORT_INTERVAL_MS && fps > 0) {
|
||||
lastPerfReport = now;
|
||||
listener.onPerf(fps, Math.round(emaLatencyMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package com.example.inspireface_example.view;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.camera.view.PreviewView;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import com.example.inspireface_example.FaceModelPrefs;
|
||||
import com.example.inspireface_example.R;
|
||||
import com.example.inspireface_example.permission.CameraPermissionCoordinator;
|
||||
import com.google.android.material.card.MaterialCardView;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/** Camera enrollment flow with first-face tracking and a reset-on-motion stability ring. */
|
||||
public final class FaceCaptureActivity extends AppCompatActivity
|
||||
implements EnrollmentFaceAnalyzer.Listener {
|
||||
|
||||
public static final String EXTRA_CAPTURE_PATH = "capture_path";
|
||||
private static final long COMPLETE_DISPLAY_MS = 350L;
|
||||
|
||||
private final ExecutorService analysisExecutor = Executors.newSingleThreadExecutor();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
private PreviewView previewView;
|
||||
private FaceOverlayView overlayView;
|
||||
private TextView promptTitle;
|
||||
private TextView promptSubtitle;
|
||||
private View flipButton;
|
||||
private CameraPreviewController cameraController;
|
||||
private EnrollmentFaceAnalyzer analyzer;
|
||||
private boolean resultScheduled;
|
||||
private boolean resultReturned;
|
||||
private String pendingCapturePath;
|
||||
private CameraPermissionCoordinator cameraPermission;
|
||||
private boolean engineStartingOrReady;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
cameraPermission = new CameraPermissionCoordinator(this,
|
||||
new CameraPermissionCoordinator.Listener() {
|
||||
@Override
|
||||
public void onCameraPermissionGranted() {
|
||||
startEngine();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCameraPermissionBlocked(boolean requiresSettings) {
|
||||
showCameraPermissionBlocked(requiresSettings);
|
||||
}
|
||||
});
|
||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||
setContentView(R.layout.activity_face_capture);
|
||||
|
||||
previewView = findViewById(R.id.capturePreview);
|
||||
overlayView = findViewById(R.id.captureFaceOverlay);
|
||||
promptTitle = findViewById(R.id.capturePromptTitle);
|
||||
promptSubtitle = findViewById(R.id.capturePromptSubtitle);
|
||||
flipButton = findViewById(R.id.btnFlipCaptureCamera);
|
||||
cameraPermission.bindRecoveryButton(
|
||||
findViewById(R.id.btnCameraPermissionAction));
|
||||
((TextView) findViewById(R.id.currentModel)).setText(
|
||||
getString(R.string.current_model, FaceModelPrefs.get(this).sdkName()));
|
||||
findViewById(R.id.btnBack).setOnClickListener(v -> finish());
|
||||
flipButton.setOnClickListener(v -> flipCamera());
|
||||
applyWindowInsets();
|
||||
|
||||
cameraPermission.requestAccess();
|
||||
}
|
||||
|
||||
private void startEngine() {
|
||||
if (engineStartingOrReady) {
|
||||
return;
|
||||
}
|
||||
engineStartingOrReady = true;
|
||||
promptTitle.setText(R.string.msg_initializing);
|
||||
promptSubtitle.setText(R.string.capture_first_face_hint);
|
||||
analysisExecutor.execute(() -> {
|
||||
boolean ready = FaceEngine.ensureLaunched(this);
|
||||
runOnUiThread(() -> {
|
||||
if (isFinishing() || isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
if (ready) {
|
||||
bindCamera();
|
||||
} else {
|
||||
engineStartingOrReady = false;
|
||||
promptTitle.setText(R.string.msg_engine_failed);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void showCameraPermissionBlocked(boolean requiresSettings) {
|
||||
promptTitle.setText(R.string.msg_permission_required);
|
||||
promptSubtitle.setText(requiresSettings
|
||||
? R.string.camera_permission_settings_hint
|
||||
: R.string.camera_permission_retry_hint);
|
||||
}
|
||||
|
||||
private void bindCamera() {
|
||||
File captureDirectory = new File(getCacheDir(), "face_capture");
|
||||
analyzer = new EnrollmentFaceAnalyzer(
|
||||
this, overlayView, captureDirectory, this);
|
||||
cameraController = new CameraPreviewController(
|
||||
this, this, previewView, analysisExecutor, analyzer,
|
||||
new CameraPreviewController.Listener() {
|
||||
@Override
|
||||
public void onCameraReady(boolean frontCamera) {
|
||||
analyzer.setMirrored(frontCamera);
|
||||
showState(EnrollmentFaceAnalyzer.Stage.NO_FACE, 0f);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLensChanged(boolean frontCamera) {
|
||||
analyzer.setMirrored(frontCamera);
|
||||
analyzer.resetTracking();
|
||||
showState(EnrollmentFaceAnalyzer.Stage.NO_FACE, 0f);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCameraError(int messageRes) {
|
||||
promptTitle.setText(messageRes);
|
||||
}
|
||||
});
|
||||
cameraController.start();
|
||||
}
|
||||
|
||||
private void flipCamera() {
|
||||
if (resultScheduled) {
|
||||
return;
|
||||
}
|
||||
if (cameraController == null || !cameraController.flipCamera()) {
|
||||
Toast.makeText(this, R.string.msg_camera_unavailable, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onState(EnrollmentFaceAnalyzer.Stage stage, float progress) {
|
||||
runOnUiThread(() -> showState(stage, progress));
|
||||
}
|
||||
|
||||
private void showState(EnrollmentFaceAnalyzer.Stage stage, float progress) {
|
||||
if (isFinishing() || isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
switch (stage) {
|
||||
case MOVE_CLOSER:
|
||||
promptTitle.setText(R.string.capture_move_closer);
|
||||
promptSubtitle.setText(R.string.capture_first_face_hint);
|
||||
break;
|
||||
case HOLD_STILL:
|
||||
promptTitle.setText(R.string.capture_hold_still);
|
||||
promptSubtitle.setText(R.string.capture_warmup_hint);
|
||||
break;
|
||||
case CAPTURING:
|
||||
promptTitle.setText(R.string.capture_keep_still);
|
||||
promptSubtitle.setText(getString(
|
||||
R.string.capture_progress, Math.round(progress * 100f)));
|
||||
break;
|
||||
case COMPLETE:
|
||||
promptTitle.setText(R.string.capture_complete);
|
||||
promptSubtitle.setText(R.string.capture_complete_hint);
|
||||
break;
|
||||
case NO_FACE:
|
||||
default:
|
||||
promptTitle.setText(R.string.capture_no_face);
|
||||
promptSubtitle.setText(R.string.capture_first_face_hint);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCaptured(String path) {
|
||||
runOnUiThread(() -> {
|
||||
if (isFinishing() || isDestroyed()) {
|
||||
new File(path).delete();
|
||||
return;
|
||||
}
|
||||
resultScheduled = true;
|
||||
pendingCapturePath = path;
|
||||
flipButton.setEnabled(false);
|
||||
showState(EnrollmentFaceAnalyzer.Stage.COMPLETE, 1f);
|
||||
mainHandler.postDelayed(() -> {
|
||||
if (isFinishing() || isDestroyed()) {
|
||||
new File(path).delete();
|
||||
return;
|
||||
}
|
||||
resultReturned = true;
|
||||
setResult(RESULT_OK,
|
||||
new Intent().putExtra(EXTRA_CAPTURE_PATH, path));
|
||||
finish();
|
||||
}, COMPLETE_DISPLAY_MS);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCaptureError() {
|
||||
runOnUiThread(() -> {
|
||||
promptTitle.setText(R.string.capture_failed);
|
||||
promptSubtitle.setText(R.string.capture_retry_hint);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSessionError() {
|
||||
runOnUiThread(() -> promptTitle.setText(R.string.msg_engine_failed));
|
||||
}
|
||||
|
||||
private void applyWindowInsets() {
|
||||
View topBar = findViewById(R.id.captureTopBar);
|
||||
MaterialCardView promptCard = findViewById(R.id.capturePromptCard);
|
||||
int baseBottom = (int) (24 * getResources().getDisplayMetrics().density);
|
||||
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.captureRoot), (v, insets) -> {
|
||||
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
|
||||
topBar.setPadding(topBar.getPaddingLeft(), bars.top,
|
||||
topBar.getPaddingRight(), topBar.getPaddingBottom());
|
||||
ViewGroup.MarginLayoutParams params =
|
||||
(ViewGroup.MarginLayoutParams) promptCard.getLayoutParams();
|
||||
params.bottomMargin = baseBottom + bars.bottom;
|
||||
promptCard.setLayoutParams(params);
|
||||
return insets;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
cameraPermission.onResume();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
cameraPermission.close();
|
||||
mainHandler.removeCallbacksAndMessages(null);
|
||||
if (!resultReturned && pendingCapturePath != null) {
|
||||
new File(pendingCapturePath).delete();
|
||||
}
|
||||
if (cameraController != null) {
|
||||
cameraController.stop();
|
||||
}
|
||||
if (analyzer != null) {
|
||||
EnrollmentFaceAnalyzer toRelease = analyzer;
|
||||
analysisExecutor.execute(toRelease::release);
|
||||
}
|
||||
analysisExecutor.shutdown();
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package com.example.inspireface_example.view;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.example.inspireface_example.DetectorDefaults;
|
||||
import com.example.inspireface_example.FaceModelPrefs;
|
||||
import com.insightface.sdk.inspireface.InspireFace;
|
||||
import com.insightface.sdk.inspireface.base.CustomParameter;
|
||||
import com.insightface.sdk.inspireface.base.Session;
|
||||
|
||||
/**
|
||||
* Process-wide InspireFace lifecycle: one GlobalLaunch, session factory tuned for
|
||||
* low-latency front-camera preview.
|
||||
*/
|
||||
public final class FaceEngine {
|
||||
|
||||
private static final String TAG = "FaceEngine";
|
||||
private static final long SESSION_RELEASE_WAIT_MS = 2_000;
|
||||
|
||||
/** Max faces tracked per frame — enough to notice "more than one face" and stay cheap. */
|
||||
private static final int PREVIEW_MAX_FACES = 3;
|
||||
private static final int IMAGE_MAX_FACES = 10;
|
||||
|
||||
/**
|
||||
* Long-edge size the tracker scales frames to before detection. Matches the default
|
||||
* 320 detect level; in LIGHT_TRACK mode detection is amortized (~1 in 20 frames) so
|
||||
* this costs little.
|
||||
*/
|
||||
private static final int TRACK_PREVIEW_SIZE = 320;
|
||||
|
||||
private static volatile boolean launched;
|
||||
private static String launchedModel;
|
||||
private static int activeSessions;
|
||||
|
||||
private FaceEngine() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the globally selected model. Changing the home-screen selection terminates the
|
||||
* previous model before the next feature screen launches the new one.
|
||||
*/
|
||||
public static synchronized boolean ensureLaunched(Context context) {
|
||||
String requestedModel = FaceModelPrefs.get(context).sdkName();
|
||||
if (launched && requestedModel.equals(launchedModel)) {
|
||||
return true;
|
||||
}
|
||||
if (launched) {
|
||||
// Back navigation can reveal the home page a fraction before the previous
|
||||
// analysis executor releases its session. Briefly wait for that hand-off;
|
||||
// wait() releases this monitor so releaseSession can make progress.
|
||||
long deadline = android.os.SystemClock.elapsedRealtime() + SESSION_RELEASE_WAIT_MS;
|
||||
while (activeSessions > 0) {
|
||||
long remaining = deadline - android.os.SystemClock.elapsedRealtime();
|
||||
if (remaining <= 0) {
|
||||
Log.e(TAG, "Cannot switch model while " + activeSessions
|
||||
+ " session(s) are active");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
FaceEngine.class.wait(remaining);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!InspireFace.GlobalTerminate()) {
|
||||
Log.e(TAG, "GlobalTerminate failed for " + launchedModel);
|
||||
return false;
|
||||
}
|
||||
launched = false;
|
||||
launchedModel = null;
|
||||
}
|
||||
if (!launched) {
|
||||
launched = Boolean.TRUE.equals(
|
||||
InspireFace.GlobalLaunch(context.getApplicationContext(), requestedModel));
|
||||
launchedModel = launched ? requestedModel : null;
|
||||
Log.i(TAG, "GlobalLaunch(" + requestedModel + ") -> " + launched);
|
||||
}
|
||||
return launched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session for continuous video tracking with both liveness models loaded. All calls on
|
||||
* the returned session must stay on a single thread.
|
||||
*/
|
||||
static synchronized Session createPreviewSession() {
|
||||
// Face quality also loads the pose model — without it yaw/pitch stay 0 and the
|
||||
// shake/head-raise actions can never fire.
|
||||
CustomParameter parameter = InspireFace.CreateCustomParameter()
|
||||
.enableLiveness(true)
|
||||
.enableInteractionLiveness(true)
|
||||
.enableFaceQuality(true);
|
||||
Session session = InspireFace.CreateSession(
|
||||
parameter, InspireFace.DETECT_MODE_LIGHT_TRACK, PREVIEW_MAX_FACES,
|
||||
DetectorDefaults.INPUT_PX, -1);
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
activeSessions++;
|
||||
InspireFace.SetTrackPreviewSize(session, TRACK_PREVIEW_SIZE);
|
||||
InspireFace.SetFaceDetectThreshold(session, 0.5f);
|
||||
InspireFace.SetFilterMinimumFacePixelSize(session, 0);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Lightweight session for camera enrollment: tracking only, no liveness models. */
|
||||
static synchronized Session createTrackingSession() {
|
||||
return createTrackingSession(
|
||||
InspireFace.CreateCustomParameter(), PREVIEW_MAX_FACES, 0);
|
||||
}
|
||||
|
||||
/** First-face camera tracking with feature extraction enabled for live 1:N search. */
|
||||
static synchronized Session createVideoRecognitionSession() {
|
||||
return createTrackingSession(
|
||||
InspireFace.CreateCustomParameter().enableRecognition(true), 1, 24);
|
||||
}
|
||||
|
||||
private static Session createTrackingSession(
|
||||
CustomParameter parameter, int maxFaces, int minimumFacePixelSize) {
|
||||
Session session = InspireFace.CreateSession(
|
||||
parameter, InspireFace.DETECT_MODE_LIGHT_TRACK,
|
||||
maxFaces, DetectorDefaults.INPUT_PX, -1);
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
activeSessions++;
|
||||
InspireFace.SetTrackPreviewSize(session, TRACK_PREVIEW_SIZE);
|
||||
InspireFace.SetFaceDetectThreshold(session, 0.5f);
|
||||
InspireFace.SetFilterMinimumFacePixelSize(session, minimumFacePixelSize);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Session for still-image detection and feature extraction. */
|
||||
public static synchronized Session createRecognitionSession() {
|
||||
return createRecognitionSession(DetectorDefaults.INPUT_PX, IMAGE_MAX_FACES, 0);
|
||||
}
|
||||
|
||||
/** Still-image Session with every attribute model used by the attribute demo. */
|
||||
public static synchronized Session createAttributeSession() {
|
||||
CustomParameter parameter = InspireFace.CreateCustomParameter()
|
||||
.enableMaskDetect(true)
|
||||
.enableFaceQuality(true)
|
||||
.enableFaceAttribute(true)
|
||||
.enableInteractionLiveness(true);
|
||||
return createStillImageSession(
|
||||
parameter, DetectorDefaults.INPUT_PX, IMAGE_MAX_FACES, 0);
|
||||
}
|
||||
|
||||
/** Configurable still-image session used by the recognition parameter demo. */
|
||||
public static synchronized Session createRecognitionSession(
|
||||
int detectPixelLevel, int maxDetectFaceNum, int minimumFacePixelSize) {
|
||||
int safeMaxFaces = Math.max(1, maxDetectFaceNum);
|
||||
int safeMinimumFacePixelSize = Math.max(0, minimumFacePixelSize);
|
||||
return createStillImageSession(
|
||||
InspireFace.CreateCustomParameter().enableRecognition(true),
|
||||
detectPixelLevel, safeMaxFaces, safeMinimumFacePixelSize);
|
||||
}
|
||||
|
||||
/** Detection-only still-image Session used by the landmark demo. */
|
||||
public static synchronized Session createDetectionSession(
|
||||
int detectPixelLevel, int maxDetectFaceNum, int minimumFacePixelSize) {
|
||||
int safeMaxFaces = Math.max(1, maxDetectFaceNum);
|
||||
int safeMinimumFacePixelSize = Math.max(0, minimumFacePixelSize);
|
||||
int previewSize = detectPixelLevel > 0
|
||||
? detectPixelLevel : DetectorDefaults.INPUT_PX;
|
||||
|
||||
/*
|
||||
* Android SDK 1.2.0 exposes enableDetectModeLandmark, but its CreateSession JNI
|
||||
* implementation never copies that Java field into HFSessionCustomParameter. As a
|
||||
* result ALWAYS_DETECT returns valid face tokens without dense landmarks. LIGHT_TRACK
|
||||
* forces landmarks on inside the native tracker (the same path used by the working
|
||||
* camera demo), so use it for this compatibility session. The still-image screen
|
||||
* recreates the session for each new image to prevent tracking state carrying over.
|
||||
*/
|
||||
Session session = InspireFace.CreateSession(
|
||||
InspireFace.CreateCustomParameter(), InspireFace.DETECT_MODE_LIGHT_TRACK,
|
||||
safeMaxFaces, detectPixelLevel, -1);
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
activeSessions++;
|
||||
InspireFace.SetTrackPreviewSize(session, previewSize);
|
||||
InspireFace.SetFaceDetectThreshold(session, 0.5f);
|
||||
InspireFace.SetFilterMinimumFacePixelSize(session, safeMinimumFacePixelSize);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configurable continuous tracker with deterministic 106-point tokens in both tracking
|
||||
* modes. The native compatibility bridge is required because the 1.2.0 Java CreateSession
|
||||
* wrapper omits the detect-mode-landmark field.
|
||||
*/
|
||||
public static synchronized Session createFaceTrackingSession(
|
||||
int detectMode, int detectPixelLevel,
|
||||
int maxDetectFaceNum, int minimumFacePixelSize) {
|
||||
int safeMode = detectMode == InspireFace.DETECT_MODE_TRACK_BY_DETECTION
|
||||
? detectMode : InspireFace.DETECT_MODE_LIGHT_TRACK;
|
||||
int safeMaxFaces = Math.max(1, Math.min(maxDetectFaceNum, IMAGE_MAX_FACES));
|
||||
int safeMinimumFacePixelSize = Math.max(0, minimumFacePixelSize);
|
||||
int previewSize = detectPixelLevel > 0 ? detectPixelLevel : TRACK_PREVIEW_SIZE;
|
||||
Session session = NativeSessionBridge.createLandmarkSession(
|
||||
safeMode, safeMaxFaces, detectPixelLevel, 30);
|
||||
if (session == null && safeMode == InspireFace.DETECT_MODE_LIGHT_TRACK) {
|
||||
// LIGHT_TRACK forces landmarks internally and remains a safe fallback if the
|
||||
// bridge cannot be loaded on an unusual device.
|
||||
session = InspireFace.CreateSession(InspireFace.CreateCustomParameter(),
|
||||
safeMode, safeMaxFaces, detectPixelLevel, -1);
|
||||
}
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
activeSessions++;
|
||||
InspireFace.SetTrackPreviewSize(session, previewSize);
|
||||
InspireFace.SetFaceDetectThreshold(session, 0.5f);
|
||||
InspireFace.SetFilterMinimumFacePixelSize(session, safeMinimumFacePixelSize);
|
||||
return session;
|
||||
}
|
||||
|
||||
private static Session createStillImageSession(
|
||||
CustomParameter parameter, int detectPixelLevel,
|
||||
int maxDetectFaceNum, int minimumFacePixelSize) {
|
||||
int safeMaxFaces = Math.max(1, maxDetectFaceNum);
|
||||
int safeMinimumFacePixelSize = Math.max(0, minimumFacePixelSize);
|
||||
int previewSize = detectPixelLevel > 0
|
||||
? detectPixelLevel : DetectorDefaults.INPUT_PX;
|
||||
Session session = InspireFace.CreateSession(
|
||||
parameter, InspireFace.DETECT_MODE_ALWAYS_DETECT,
|
||||
safeMaxFaces, detectPixelLevel, -1);
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
activeSessions++;
|
||||
InspireFace.SetTrackPreviewSize(session, previewSize);
|
||||
InspireFace.SetFaceDetectThreshold(session, 0.5f);
|
||||
InspireFace.SetFilterMinimumFacePixelSize(session, safeMinimumFacePixelSize);
|
||||
return session;
|
||||
}
|
||||
|
||||
public static synchronized void releaseSession(Session session) {
|
||||
InspireFace.ReleaseSession(session);
|
||||
if (activeSessions > 0) {
|
||||
activeSessions--;
|
||||
}
|
||||
FaceEngine.class.notifyAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.example.inspireface_example.view;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.RectF;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.example.inspireface_example.R;
|
||||
|
||||
/**
|
||||
* Draws face bounding brackets on top of the camera preview.
|
||||
*
|
||||
* Face rectangles are supplied in the upright image coordinate space (the space the
|
||||
* detector worked in, after rotation). The view maps them to screen space assuming the
|
||||
* preview uses FILL_CENTER (center-crop), and mirrors horizontally for the front camera.
|
||||
*/
|
||||
public class FaceOverlayView extends View {
|
||||
|
||||
/** Immutable per-frame snapshot handed over from the analysis thread. */
|
||||
public static final class Frame {
|
||||
final int imageWidth;
|
||||
final int imageHeight;
|
||||
final boolean mirrored;
|
||||
final RectF[] rects;
|
||||
final int color;
|
||||
final float progress;
|
||||
final int progressColor;
|
||||
|
||||
public Frame(int imageWidth, int imageHeight, boolean mirrored,
|
||||
RectF[] rects, int color) {
|
||||
this(imageWidth, imageHeight, mirrored, rects, color, -1f, color);
|
||||
}
|
||||
|
||||
public Frame(int imageWidth, int imageHeight, boolean mirrored,
|
||||
RectF[] rects, int color, float progress, int progressColor) {
|
||||
this.imageWidth = imageWidth;
|
||||
this.imageHeight = imageHeight;
|
||||
this.mirrored = mirrored;
|
||||
this.rects = rects;
|
||||
this.color = color;
|
||||
this.progress = progress;
|
||||
this.progressColor = progressColor;
|
||||
}
|
||||
}
|
||||
|
||||
private final Paint boxPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint progressTrackPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint progressPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final RectF mapped = new RectF();
|
||||
private final RectF progressBounds = new RectF();
|
||||
private volatile Frame frame;
|
||||
|
||||
public FaceOverlayView(Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public FaceOverlayView(Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
boxPaint.setStyle(Paint.Style.STROKE);
|
||||
boxPaint.setStrokeWidth(dp(3));
|
||||
boxPaint.setStrokeCap(Paint.Cap.ROUND);
|
||||
boxPaint.setColor(ContextCompat.getColor(context, R.color.liveness_accent));
|
||||
progressTrackPaint.setStyle(Paint.Style.STROKE);
|
||||
progressTrackPaint.setStrokeWidth(dp(7));
|
||||
progressTrackPaint.setColor(0x66000000);
|
||||
progressPaint.setStyle(Paint.Style.STROKE);
|
||||
progressPaint.setStrokeWidth(dp(7));
|
||||
progressPaint.setStrokeCap(Paint.Cap.ROUND);
|
||||
}
|
||||
|
||||
/** Safe to call from any thread. Pass {@code null} rects to clear. */
|
||||
public void submit(@Nullable Frame f) {
|
||||
frame = f;
|
||||
postInvalidateOnAnimation();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
super.onDraw(canvas);
|
||||
Frame f = frame;
|
||||
if (f == null || f.rects == null || f.rects.length == 0
|
||||
|| f.imageWidth <= 0 || f.imageHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
float vw = getWidth();
|
||||
float vh = getHeight();
|
||||
// FILL_CENTER: uniform scale that covers the view, centered.
|
||||
float scale = Math.max(vw / f.imageWidth, vh / f.imageHeight);
|
||||
float dx = (vw - f.imageWidth * scale) / 2f;
|
||||
float dy = (vh - f.imageHeight * scale) / 2f;
|
||||
|
||||
boxPaint.setColor(f.color);
|
||||
for (int i = 0; i < f.rects.length; i++) {
|
||||
RectF r = f.rects[i];
|
||||
float left = r.left;
|
||||
float right = r.right;
|
||||
if (f.mirrored) {
|
||||
float l = f.imageWidth - right;
|
||||
right = f.imageWidth - left;
|
||||
left = l;
|
||||
}
|
||||
mapped.set(left * scale + dx, r.top * scale + dy,
|
||||
right * scale + dx, r.bottom * scale + dy);
|
||||
drawBrackets(canvas, mapped);
|
||||
if (i == 0 && f.progress >= 0f) {
|
||||
drawProgressRing(canvas, mapped, f.progress, f.progressColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void drawProgressRing(Canvas canvas, RectF face, float progress, int color) {
|
||||
float size = Math.max(face.width(), face.height()) + dp(28);
|
||||
float cx = face.centerX();
|
||||
float cy = face.centerY();
|
||||
progressBounds.set(cx - size / 2f, cy - size / 2f,
|
||||
cx + size / 2f, cy + size / 2f);
|
||||
canvas.drawOval(progressBounds, progressTrackPaint);
|
||||
progressPaint.setColor(color);
|
||||
float clamped = Math.max(0f, Math.min(1f, progress));
|
||||
canvas.drawArc(progressBounds, -90f, Math.max(1f, clamped * 360f),
|
||||
false, progressPaint);
|
||||
}
|
||||
|
||||
/** Corner brackets read better over video than a full box. */
|
||||
private void drawBrackets(Canvas canvas, RectF r) {
|
||||
float len = Math.min(r.width(), r.height()) * 0.22f;
|
||||
float radius = dp(6);
|
||||
// Top-left
|
||||
canvas.drawLine(r.left, r.top + len, r.left, r.top + radius, boxPaint);
|
||||
canvas.drawLine(r.left + radius, r.top, r.left + len, r.top, boxPaint);
|
||||
canvas.drawArc(r.left, r.top, r.left + 2 * radius, r.top + 2 * radius, 180, 90, false, boxPaint);
|
||||
// Top-right
|
||||
canvas.drawLine(r.right - len, r.top, r.right - radius, r.top, boxPaint);
|
||||
canvas.drawLine(r.right, r.top + radius, r.right, r.top + len, boxPaint);
|
||||
canvas.drawArc(r.right - 2 * radius, r.top, r.right, r.top + 2 * radius, 270, 90, false, boxPaint);
|
||||
// Bottom-left
|
||||
canvas.drawLine(r.left, r.bottom - len, r.left, r.bottom - radius, boxPaint);
|
||||
canvas.drawLine(r.left + radius, r.bottom, r.left + len, r.bottom, boxPaint);
|
||||
canvas.drawArc(r.left, r.bottom - 2 * radius, r.left + 2 * radius, r.bottom, 90, 90, false, boxPaint);
|
||||
// Bottom-right
|
||||
canvas.drawLine(r.right - len, r.bottom, r.right - radius, r.bottom, boxPaint);
|
||||
canvas.drawLine(r.right, r.bottom - radius, r.right, r.bottom - len, boxPaint);
|
||||
canvas.drawArc(r.right - 2 * radius, r.bottom - 2 * radius, r.right, r.bottom, 0, 90, false, boxPaint);
|
||||
}
|
||||
|
||||
private float dp(float v) {
|
||||
return v * getResources().getDisplayMetrics().density;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.example.inspireface_example.view;
|
||||
|
||||
/** Pure-Java first-face stability timer, separated so reset/timing behavior is testable. */
|
||||
final class FaceStabilityGate {
|
||||
|
||||
static final long WARMUP_MS = 1_000L;
|
||||
static final long PROGRESS_MS = 2_000L;
|
||||
private static final float FRAME_CENTER_TOLERANCE = 0.045f;
|
||||
private static final float FRAME_SIZE_TOLERANCE = 0.07f;
|
||||
private static final float ANCHOR_CENTER_TOLERANCE = 0.11f;
|
||||
private static final float ANCHOR_SIZE_TOLERANCE = 0.13f;
|
||||
|
||||
private final long warmupMs;
|
||||
private final long progressMs;
|
||||
|
||||
private boolean tracking;
|
||||
private int trackId;
|
||||
private long stableSince;
|
||||
private float anchorX;
|
||||
private float anchorY;
|
||||
private float anchorWidth;
|
||||
private float anchorHeight;
|
||||
private float previousX;
|
||||
private float previousY;
|
||||
private float previousWidth;
|
||||
private float previousHeight;
|
||||
|
||||
FaceStabilityGate() {
|
||||
this(WARMUP_MS, PROGRESS_MS);
|
||||
}
|
||||
|
||||
FaceStabilityGate(long warmupMs, long progressMs) {
|
||||
this.warmupMs = Math.max(0L, warmupMs);
|
||||
this.progressMs = Math.max(1L, progressMs);
|
||||
}
|
||||
|
||||
/** Returns -1 during the configured warm-up, otherwise progress in [0, 1]. */
|
||||
float update(int id, float left, float top, float right, float bottom, long now) {
|
||||
float width = Math.max(1f, right - left);
|
||||
float height = Math.max(1f, bottom - top);
|
||||
float centerX = (left + right) * 0.5f;
|
||||
float centerY = (top + bottom) * 0.5f;
|
||||
if (!tracking || trackId != id || now < stableSince
|
||||
|| !isStable(centerX, centerY, width, height)) {
|
||||
begin(id, centerX, centerY, width, height, now);
|
||||
return -1f;
|
||||
}
|
||||
previousX = centerX;
|
||||
previousY = centerY;
|
||||
previousWidth = width;
|
||||
previousHeight = height;
|
||||
long elapsed = now - stableSince;
|
||||
if (elapsed < warmupMs) {
|
||||
return -1f;
|
||||
}
|
||||
return Math.min(1f, (elapsed - warmupMs) / (float) progressMs);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
tracking = false;
|
||||
stableSince = 0L;
|
||||
}
|
||||
|
||||
private void begin(int id, float centerX, float centerY,
|
||||
float width, float height, long now) {
|
||||
tracking = true;
|
||||
trackId = id;
|
||||
stableSince = now;
|
||||
anchorX = previousX = centerX;
|
||||
anchorY = previousY = centerY;
|
||||
anchorWidth = previousWidth = width;
|
||||
anchorHeight = previousHeight = height;
|
||||
}
|
||||
|
||||
private boolean isStable(float centerX, float centerY, float width, float height) {
|
||||
return centerDelta(centerX, centerY,
|
||||
previousX, previousY, previousWidth, previousHeight)
|
||||
<= FRAME_CENTER_TOLERANCE
|
||||
&& sizeDelta(width, height, previousWidth, previousHeight)
|
||||
<= FRAME_SIZE_TOLERANCE
|
||||
&& centerDelta(centerX, centerY,
|
||||
anchorX, anchorY, anchorWidth, anchorHeight)
|
||||
<= ANCHOR_CENTER_TOLERANCE
|
||||
&& sizeDelta(width, height, anchorWidth, anchorHeight)
|
||||
<= ANCHOR_SIZE_TOLERANCE;
|
||||
}
|
||||
|
||||
private static float centerDelta(float x, float y, float referenceX, float referenceY,
|
||||
float referenceWidth, float referenceHeight) {
|
||||
float scale = Math.max(1f, Math.max(referenceWidth, referenceHeight));
|
||||
return (float) Math.hypot(x - referenceX, y - referenceY) / scale;
|
||||
}
|
||||
|
||||
private static float sizeDelta(float width, float height,
|
||||
float referenceWidth, float referenceHeight) {
|
||||
return Math.max(Math.abs(width - referenceWidth) / Math.max(1f, referenceWidth),
|
||||
Math.abs(height - referenceHeight) / Math.max(1f, referenceHeight));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package com.example.inspireface_example.view;
|
||||
|
||||
import android.graphics.Color;
|
||||
import android.os.SystemClock;
|
||||
import android.util.SparseIntArray;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.insightface.sdk.inspireface.InspireFace;
|
||||
import com.insightface.sdk.inspireface.base.FaceRect;
|
||||
import com.insightface.sdk.inspireface.base.ImageStream;
|
||||
import com.insightface.sdk.inspireface.base.MultipleFaceData;
|
||||
import com.insightface.sdk.inspireface.base.Point2f;
|
||||
import com.insightface.sdk.inspireface.base.Session;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/** Runs configurable multi-face tracking and submits one batched GL frame per camera frame. */
|
||||
public final class FaceTrackingAnalyzer extends UprightFaceCameraAnalyzer {
|
||||
|
||||
public interface Listener {
|
||||
void onSessionReady();
|
||||
|
||||
/** Throttled callback on the camera analysis executor. */
|
||||
void onStats(double fps, long latencyMs);
|
||||
|
||||
void onSessionError();
|
||||
}
|
||||
|
||||
private static final long REPORT_INTERVAL_MS = 300L;
|
||||
private static final int[] TRACK_COLORS = {
|
||||
0xFF00E5A0, 0xFFFFC400, 0xFF40C4FF, 0xFFFF6E9C, 0xFFC6A5FF,
|
||||
0xFFFF8A65, 0xFF76FF03, 0xFF18FFFF, 0xFFFFD180, 0xFFEA80FC
|
||||
};
|
||||
|
||||
private final FaceTrackingGlView overlay;
|
||||
private final int detectMode;
|
||||
private final int detectPixelLevel;
|
||||
private final int maxFaces;
|
||||
private final int minimumFacePixelSize;
|
||||
private final Listener listener;
|
||||
private final float[] pointVertices = new float[
|
||||
FaceTrackingGlView.MAX_FACES * FaceTrackingGlView.LANDMARKS_PER_FACE
|
||||
* FaceTrackingGlView.FLOATS_PER_VERTEX];
|
||||
private final float[] boxVertices = new float[
|
||||
FaceTrackingGlView.MAX_FACES * FaceTrackingGlView.BOX_VERTICES_PER_FACE
|
||||
* FaceTrackingGlView.FLOATS_PER_VERTEX];
|
||||
private final SparseIntArray colorSlotByTrackId = new SparseIntArray();
|
||||
private final int[] currentTrackIds = new int[FaceTrackingGlView.MAX_FACES];
|
||||
private final boolean[] usedColorSlots = new boolean[TRACK_COLORS.length];
|
||||
|
||||
private volatile boolean mirrored = true;
|
||||
private long fpsWindowStart;
|
||||
private int fpsWindowFrames;
|
||||
private double fps;
|
||||
private double latencyEmaMs;
|
||||
private long lastReport;
|
||||
|
||||
public FaceTrackingAnalyzer(FaceTrackingGlView overlay,
|
||||
int detectMode, int detectPixelLevel,
|
||||
int maxFaces, int minimumFacePixelSize,
|
||||
Listener listener) {
|
||||
this.overlay = overlay;
|
||||
this.detectMode = detectMode;
|
||||
this.detectPixelLevel = detectPixelLevel;
|
||||
this.maxFaces = maxFaces;
|
||||
this.minimumFacePixelSize = minimumFacePixelSize;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Session createSession() {
|
||||
return FaceEngine.createFaceTrackingSession(
|
||||
detectMode, detectPixelLevel, maxFaces, minimumFacePixelSize);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSessionReady() {
|
||||
listener.onSessionReady();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onFaces(Session session, ImageStream stream,
|
||||
@Nullable MultipleFaceData faces, byte[] uprightNv21,
|
||||
int uprightWidth, int uprightHeight, long frameStart) {
|
||||
int detected = faces == null ? 0
|
||||
: Math.min(faces.detectedNum, FaceTrackingGlView.MAX_FACES);
|
||||
if (detected <= 0) {
|
||||
overlay.clearTracking();
|
||||
reportStats(frameStart);
|
||||
return;
|
||||
}
|
||||
|
||||
int pointCount = 0;
|
||||
int boxCount = 0;
|
||||
updateTrackColors(faces, detected);
|
||||
for (int i = 0; i < detected; i++) {
|
||||
int color = TRACK_COLORS[colorSlotByTrackId.get(currentTrackIds[i])];
|
||||
Point2f[] landmarks = faces.tokens != null && i < faces.tokens.length
|
||||
&& faces.tokens[i] != null
|
||||
? InspireFace.GetFaceDenseLandmarkFromFaceToken(faces.tokens[i]) : null;
|
||||
if (faces.rects != null && i < faces.rects.length && faces.rects[i] != null) {
|
||||
boxCount = appendBrackets(
|
||||
boxVertices, boxCount, faces.rects[i], color);
|
||||
}
|
||||
if (landmarks == null || landmarks.length == 0) {
|
||||
continue;
|
||||
}
|
||||
for (Point2f point : landmarks) {
|
||||
if (point == null || pointCount >= FaceTrackingGlView.MAX_FACES
|
||||
* FaceTrackingGlView.LANDMARKS_PER_FACE) {
|
||||
continue;
|
||||
}
|
||||
appendVertex(pointVertices, pointCount++, point.x, point.y, color);
|
||||
}
|
||||
}
|
||||
|
||||
overlay.submit(pointVertices, pointCount, boxVertices, boxCount,
|
||||
uprightWidth, uprightHeight, mirrored);
|
||||
reportStats(frameStart);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSessionError() {
|
||||
overlay.clearTracking();
|
||||
listener.onSessionError();
|
||||
}
|
||||
|
||||
public void setMirrored(boolean mirrored) {
|
||||
this.mirrored = mirrored;
|
||||
overlay.clearTracking();
|
||||
}
|
||||
|
||||
public void clearTracking() {
|
||||
overlay.clearTracking();
|
||||
}
|
||||
|
||||
private void reportStats(long frameStart) {
|
||||
long now = SystemClock.elapsedRealtime();
|
||||
long latency = Math.max(0L, now - frameStart);
|
||||
latencyEmaMs = latencyEmaMs == 0.0
|
||||
? latency : latencyEmaMs * 0.85 + latency * 0.15;
|
||||
fpsWindowFrames++;
|
||||
if (fpsWindowStart == 0L) {
|
||||
fpsWindowStart = now;
|
||||
} else if (now - fpsWindowStart >= 1_000L) {
|
||||
fps = fpsWindowFrames * 1_000.0 / (now - fpsWindowStart);
|
||||
fpsWindowFrames = 0;
|
||||
fpsWindowStart = now;
|
||||
}
|
||||
if (now - lastReport >= REPORT_INTERVAL_MS) {
|
||||
lastReport = now;
|
||||
listener.onStats(fps, Math.round(latencyEmaMs));
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps active IDs on distinct palette slots while preserving each surviving ID's color. */
|
||||
private void updateTrackColors(MultipleFaceData faces, int detected) {
|
||||
for (int i = 0; i < detected; i++) {
|
||||
int sdkTrackId = faces.trackIds != null && i < faces.trackIds.length
|
||||
? faces.trackIds[i] : -1;
|
||||
// A missing SDK ID still needs a unique key for this frame.
|
||||
currentTrackIds[i] = sdkTrackId >= 0 ? sdkTrackId : Integer.MIN_VALUE + i;
|
||||
}
|
||||
for (int i = colorSlotByTrackId.size() - 1; i >= 0; i--) {
|
||||
if (!containsTrackId(currentTrackIds, detected, colorSlotByTrackId.keyAt(i))) {
|
||||
colorSlotByTrackId.removeAt(i);
|
||||
}
|
||||
}
|
||||
Arrays.fill(usedColorSlots, false);
|
||||
for (int i = 0; i < colorSlotByTrackId.size(); i++) {
|
||||
usedColorSlots[colorSlotByTrackId.valueAt(i)] = true;
|
||||
}
|
||||
for (int i = 0; i < detected; i++) {
|
||||
int trackId = currentTrackIds[i];
|
||||
if (colorSlotByTrackId.indexOfKey(trackId) >= 0) {
|
||||
continue;
|
||||
}
|
||||
int slot = firstFreeColorSlot();
|
||||
colorSlotByTrackId.put(trackId, slot);
|
||||
usedColorSlots[slot] = true;
|
||||
}
|
||||
}
|
||||
|
||||
private int firstFreeColorSlot() {
|
||||
for (int i = 0; i < usedColorSlots.length; i++) {
|
||||
if (!usedColorSlots[i]) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static boolean containsTrackId(int[] ids, int count, int wanted) {
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (ids[i] == wanted) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int appendBrackets(float[] target, int vertexCount,
|
||||
FaceRect rect, int color) {
|
||||
float left = rect.x;
|
||||
float top = rect.y;
|
||||
float right = rect.x + rect.width;
|
||||
float bottom = rect.y + rect.height;
|
||||
float length = Math.min(rect.width, rect.height) * 0.22f;
|
||||
|
||||
vertexCount = appendLine(target, vertexCount,
|
||||
left, top + length, left, top, color);
|
||||
vertexCount = appendLine(target, vertexCount,
|
||||
left, top, left + length, top, color);
|
||||
vertexCount = appendLine(target, vertexCount,
|
||||
right - length, top, right, top, color);
|
||||
vertexCount = appendLine(target, vertexCount,
|
||||
right, top, right, top + length, color);
|
||||
vertexCount = appendLine(target, vertexCount,
|
||||
left, bottom - length, left, bottom, color);
|
||||
vertexCount = appendLine(target, vertexCount,
|
||||
left, bottom, left + length, bottom, color);
|
||||
vertexCount = appendLine(target, vertexCount,
|
||||
right - length, bottom, right, bottom, color);
|
||||
vertexCount = appendLine(target, vertexCount,
|
||||
right, bottom, right, bottom - length, color);
|
||||
return vertexCount;
|
||||
}
|
||||
|
||||
private static int appendLine(float[] target, int vertexCount,
|
||||
float x0, float y0, float x1, float y1, int color) {
|
||||
appendVertex(target, vertexCount++, x0, y0, color);
|
||||
appendVertex(target, vertexCount++, x1, y1, color);
|
||||
return vertexCount;
|
||||
}
|
||||
|
||||
private static void appendVertex(float[] target, int vertexIndex,
|
||||
float x, float y, int color) {
|
||||
int offset = vertexIndex * FaceTrackingGlView.FLOATS_PER_VERTEX;
|
||||
target[offset] = x;
|
||||
target[offset + 1] = y;
|
||||
target[offset + 2] = Color.red(color) / 255f;
|
||||
target[offset + 3] = Color.green(color) / 255f;
|
||||
target[offset + 4] = Color.blue(color) / 255f;
|
||||
target[offset + 5] = Color.alpha(color) / 255f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package com.example.inspireface_example.view;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.PixelFormat;
|
||||
import android.opengl.GLES20;
|
||||
import android.opengl.GLSurfaceView;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.FloatBuffer;
|
||||
|
||||
import javax.microedition.khronos.egl.EGLConfig;
|
||||
import javax.microedition.khronos.opengles.GL10;
|
||||
|
||||
/**
|
||||
* Transparent OpenGL overlay for multi-face tracking. Colored boxes and dense landmarks are
|
||||
* uploaded in two batches, then rendered on GLSurfaceView's own thread only when a new tracking
|
||||
* result arrives. Every vertex carries its track-ID color, keeping UI work off the main thread.
|
||||
*/
|
||||
public final class FaceTrackingGlView extends GLSurfaceView {
|
||||
|
||||
/** x, y, red, green, blue, alpha. */
|
||||
static final int FLOATS_PER_VERTEX = 6;
|
||||
static final int MAX_FACES = 10;
|
||||
static final int LANDMARKS_PER_FACE = 106;
|
||||
static final int BOX_VERTICES_PER_FACE = 16;
|
||||
|
||||
private final TrackingRenderer renderer;
|
||||
|
||||
public FaceTrackingGlView(Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public FaceTrackingGlView(Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
float density = getResources().getDisplayMetrics().density;
|
||||
renderer = new TrackingRenderer(4.5f * density, 2.2f * density);
|
||||
setEGLContextClientVersion(2);
|
||||
setEGLConfigChooser(8, 8, 8, 8, 0, 0);
|
||||
getHolder().setFormat(PixelFormat.TRANSLUCENT);
|
||||
setZOrderMediaOverlay(true);
|
||||
setRenderer(renderer);
|
||||
setRenderMode(RENDERMODE_WHEN_DIRTY);
|
||||
setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
|
||||
}
|
||||
|
||||
/** Safe from the CameraX analysis thread; data is copied before returning. */
|
||||
void submit(float[] pointVertices, int pointCount,
|
||||
float[] boxVertices, int boxVertexCount,
|
||||
int imageWidth, int imageHeight, boolean mirrored) {
|
||||
renderer.setFrame(pointVertices, pointCount, boxVertices, boxVertexCount,
|
||||
imageWidth, imageHeight, mirrored);
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/** Safe from any thread. */
|
||||
public void clearTracking() {
|
||||
renderer.setFrame(null, 0, null, 0, 0, 0, false);
|
||||
requestRender();
|
||||
}
|
||||
|
||||
private static final class TrackingRenderer implements Renderer {
|
||||
|
||||
private static final int MAX_POINT_VERTICES = MAX_FACES * LANDMARKS_PER_FACE;
|
||||
private static final int MAX_BOX_VERTICES = MAX_FACES * BOX_VERTICES_PER_FACE;
|
||||
private static final int STRIDE_BYTES = FLOATS_PER_VERTEX * 4;
|
||||
|
||||
private static final String VERTEX_SHADER = ""
|
||||
+ "attribute vec2 aPos;\n"
|
||||
+ "attribute vec4 aColor;\n"
|
||||
+ "uniform vec4 uXform;\n"
|
||||
+ "uniform float uPointSize;\n"
|
||||
+ "varying vec4 vColor;\n"
|
||||
+ "void main() {\n"
|
||||
+ " gl_Position = vec4(aPos.x * uXform.x + uXform.y,\n"
|
||||
+ " aPos.y * uXform.z + uXform.w, 0.0, 1.0);\n"
|
||||
+ " gl_PointSize = uPointSize;\n"
|
||||
+ " vColor = aColor;\n"
|
||||
+ "}\n";
|
||||
|
||||
private static final String FRAGMENT_SHADER = ""
|
||||
+ "precision mediump float;\n"
|
||||
+ "varying vec4 vColor;\n"
|
||||
+ "uniform float uPointPass;\n"
|
||||
+ "void main() {\n"
|
||||
+ " float a = vColor.a;\n"
|
||||
+ " if (uPointPass > 0.5) {\n"
|
||||
+ " float dist = length(gl_PointCoord - vec2(0.5));\n"
|
||||
+ " a *= 1.0 - smoothstep(0.35, 0.5, dist);\n"
|
||||
+ " if (a <= 0.01) discard;\n"
|
||||
+ " }\n"
|
||||
+ " gl_FragColor = vec4(vColor.rgb * a, a);\n"
|
||||
+ "}\n";
|
||||
|
||||
private final Object lock = new Object();
|
||||
private final float[] stagedPoints =
|
||||
new float[MAX_POINT_VERTICES * FLOATS_PER_VERTEX];
|
||||
private final float[] stagedBoxes =
|
||||
new float[MAX_BOX_VERTICES * FLOATS_PER_VERTEX];
|
||||
private int stagedPointCount;
|
||||
private int stagedBoxCount;
|
||||
private int imageWidth;
|
||||
private int imageHeight;
|
||||
private boolean mirrored;
|
||||
|
||||
private final FloatBuffer pointBuffer = allocate(MAX_POINT_VERTICES);
|
||||
private final FloatBuffer boxBuffer = allocate(MAX_BOX_VERTICES);
|
||||
private final float pointSizePx;
|
||||
private final float boxWidthPx;
|
||||
|
||||
private int program;
|
||||
private int aPosLoc;
|
||||
private int aColorLoc;
|
||||
private int uXformLoc;
|
||||
private int uPointSizeLoc;
|
||||
private int uPointPassLoc;
|
||||
private int viewWidth;
|
||||
private int viewHeight;
|
||||
|
||||
TrackingRenderer(float pointSizePx, float boxWidthPx) {
|
||||
this.pointSizePx = pointSizePx;
|
||||
this.boxWidthPx = boxWidthPx;
|
||||
}
|
||||
|
||||
void setFrame(float[] points, int pointCount,
|
||||
float[] boxes, int boxCount,
|
||||
int imageWidth, int imageHeight, boolean mirrored) {
|
||||
synchronized (lock) {
|
||||
stagedPointCount = points == null ? 0
|
||||
: Math.min(pointCount, MAX_POINT_VERTICES);
|
||||
stagedBoxCount = boxes == null ? 0
|
||||
: Math.min(boxCount, MAX_BOX_VERTICES);
|
||||
if (stagedPointCount > 0) {
|
||||
System.arraycopy(points, 0, stagedPoints, 0,
|
||||
stagedPointCount * FLOATS_PER_VERTEX);
|
||||
}
|
||||
if (stagedBoxCount > 0) {
|
||||
System.arraycopy(boxes, 0, stagedBoxes, 0,
|
||||
stagedBoxCount * FLOATS_PER_VERTEX);
|
||||
}
|
||||
this.imageWidth = imageWidth;
|
||||
this.imageHeight = imageHeight;
|
||||
this.mirrored = mirrored;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
|
||||
synchronized (lock) {
|
||||
stagedPointCount = 0;
|
||||
stagedBoxCount = 0;
|
||||
}
|
||||
program = GLES20.glCreateProgram();
|
||||
GLES20.glAttachShader(program,
|
||||
compileShader(GLES20.GL_VERTEX_SHADER, VERTEX_SHADER));
|
||||
GLES20.glAttachShader(program,
|
||||
compileShader(GLES20.GL_FRAGMENT_SHADER, FRAGMENT_SHADER));
|
||||
GLES20.glLinkProgram(program);
|
||||
aPosLoc = GLES20.glGetAttribLocation(program, "aPos");
|
||||
aColorLoc = GLES20.glGetAttribLocation(program, "aColor");
|
||||
uXformLoc = GLES20.glGetUniformLocation(program, "uXform");
|
||||
uPointSizeLoc = GLES20.glGetUniformLocation(program, "uPointSize");
|
||||
uPointPassLoc = GLES20.glGetUniformLocation(program, "uPointPass");
|
||||
GLES20.glClearColor(0f, 0f, 0f, 0f);
|
||||
GLES20.glEnable(GLES20.GL_BLEND);
|
||||
GLES20.glBlendFunc(GLES20.GL_ONE, GLES20.GL_ONE_MINUS_SRC_ALPHA);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSurfaceChanged(GL10 gl, int width, int height) {
|
||||
GLES20.glViewport(0, 0, width, height);
|
||||
viewWidth = width;
|
||||
viewHeight = height;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDrawFrame(GL10 gl) {
|
||||
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
|
||||
|
||||
int pointCount;
|
||||
int boxCount;
|
||||
float ax;
|
||||
float bx;
|
||||
float ay;
|
||||
float by;
|
||||
synchronized (lock) {
|
||||
pointCount = stagedPointCount;
|
||||
boxCount = stagedBoxCount;
|
||||
if ((pointCount == 0 && boxCount == 0)
|
||||
|| imageWidth <= 0 || imageHeight <= 0
|
||||
|| viewWidth <= 0 || viewHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
put(pointBuffer, stagedPoints, pointCount);
|
||||
put(boxBuffer, stagedBoxes, boxCount);
|
||||
|
||||
// PreviewView defaults to FILL_CENTER; mirror only the front-camera x axis.
|
||||
float scale = Math.max((float) viewWidth / imageWidth,
|
||||
(float) viewHeight / imageHeight);
|
||||
float dx = (viewWidth - imageWidth * scale) / 2f;
|
||||
float dy = (viewHeight - imageHeight * scale) / 2f;
|
||||
float mx = mirrored ? -scale : scale;
|
||||
float cx = mirrored ? imageWidth * scale + dx : dx;
|
||||
ax = 2f * mx / viewWidth;
|
||||
bx = 2f * cx / viewWidth - 1f;
|
||||
ay = -2f * scale / viewHeight;
|
||||
by = 1f - 2f * dy / viewHeight;
|
||||
}
|
||||
|
||||
GLES20.glUseProgram(program);
|
||||
GLES20.glUniform4f(uXformLoc, ax, bx, ay, by);
|
||||
GLES20.glUniform1f(uPointSizeLoc, pointSizePx);
|
||||
GLES20.glEnableVertexAttribArray(aPosLoc);
|
||||
GLES20.glEnableVertexAttribArray(aColorLoc);
|
||||
|
||||
if (boxCount > 0) {
|
||||
bind(boxBuffer);
|
||||
GLES20.glUniform1f(uPointPassLoc, 0f);
|
||||
GLES20.glLineWidth(boxWidthPx);
|
||||
GLES20.glDrawArrays(GLES20.GL_LINES, 0, boxCount);
|
||||
}
|
||||
if (pointCount > 0) {
|
||||
bind(pointBuffer);
|
||||
GLES20.glUniform1f(uPointPassLoc, 1f);
|
||||
GLES20.glDrawArrays(GLES20.GL_POINTS, 0, pointCount);
|
||||
}
|
||||
|
||||
GLES20.glDisableVertexAttribArray(aColorLoc);
|
||||
GLES20.glDisableVertexAttribArray(aPosLoc);
|
||||
}
|
||||
|
||||
private void bind(FloatBuffer buffer) {
|
||||
buffer.position(0);
|
||||
GLES20.glVertexAttribPointer(aPosLoc, 2, GLES20.GL_FLOAT,
|
||||
false, STRIDE_BYTES, buffer);
|
||||
buffer.position(2);
|
||||
GLES20.glVertexAttribPointer(aColorLoc, 4, GLES20.GL_FLOAT,
|
||||
false, STRIDE_BYTES, buffer);
|
||||
}
|
||||
|
||||
private static FloatBuffer allocate(int vertexCount) {
|
||||
return ByteBuffer.allocateDirect(
|
||||
vertexCount * FLOATS_PER_VERTEX * 4)
|
||||
.order(ByteOrder.nativeOrder())
|
||||
.asFloatBuffer();
|
||||
}
|
||||
|
||||
private static void put(FloatBuffer buffer, float[] source, int vertexCount) {
|
||||
buffer.position(0);
|
||||
buffer.put(source, 0, vertexCount * FLOATS_PER_VERTEX);
|
||||
buffer.position(0);
|
||||
}
|
||||
|
||||
private static int compileShader(int type, String source) {
|
||||
int shader = GLES20.glCreateShader(type);
|
||||
GLES20.glShaderSource(shader, source);
|
||||
GLES20.glCompileShader(shader);
|
||||
return shader;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.example.inspireface_example.view;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.PixelFormat;
|
||||
import android.opengl.GLES20;
|
||||
import android.opengl.GLSurfaceView;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.example.inspireface_example.R;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.FloatBuffer;
|
||||
|
||||
import javax.microedition.khronos.egl.EGLConfig;
|
||||
import javax.microedition.khronos.opengles.GL10;
|
||||
|
||||
/**
|
||||
* Transparent GL overlay that renders the dense facial landmarks as one GL_POINTS draw
|
||||
* call: a single ~1 KB vertex upload per frame, the image→screen mapping folded into a
|
||||
* vertex-shader uniform, and round anti-aliased sprites from gl_PointCoord. Renders only
|
||||
* when a new frame of points arrives (RENDERMODE_WHEN_DIRTY); hidden, the surface is
|
||||
* destroyed and costs nothing.
|
||||
*
|
||||
* Points are supplied in the upright image space the SDK reports (same space as the face
|
||||
* rects) and mapped with the same FILL_CENTER + front-mirror convention as
|
||||
* {@link FaceOverlayView}.
|
||||
*/
|
||||
public class LandmarkGlView extends GLSurfaceView {
|
||||
|
||||
private final PointsRenderer renderer;
|
||||
|
||||
public LandmarkGlView(Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public LandmarkGlView(Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
renderer = new PointsRenderer(
|
||||
4f * getResources().getDisplayMetrics().density,
|
||||
ContextCompat.getColor(context, R.color.liveness_accent));
|
||||
setEGLContextClientVersion(2);
|
||||
setEGLConfigChooser(8, 8, 8, 8, 0, 0);
|
||||
getHolder().setFormat(PixelFormat.TRANSLUCENT);
|
||||
setZOrderMediaOverlay(true); // above the camera surface, below regular views
|
||||
setRenderer(renderer);
|
||||
setRenderMode(RENDERMODE_WHEN_DIRTY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe to call from any thread. {@code xy} holds {@code count} interleaved (x, y)
|
||||
* pairs in upright image space; the array is copied before returning.
|
||||
*/
|
||||
void submitPoints(float[] xy, int count, int imageWidth, int imageHeight, boolean mirrored) {
|
||||
renderer.setPoints(xy, count, imageWidth, imageHeight, mirrored);
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/** Safe to call from any thread. */
|
||||
void clearPoints() {
|
||||
renderer.setPoints(null, 0, 0, 0, false);
|
||||
requestRender();
|
||||
}
|
||||
|
||||
private static final class PointsRenderer implements Renderer {
|
||||
|
||||
private static final int MAX_POINTS = 512;
|
||||
|
||||
private static final String VERTEX_SHADER = ""
|
||||
+ "attribute vec2 aPos;\n"
|
||||
+ "uniform vec4 uXform;\n" // (scaleX, offsetX, scaleY, offsetY) image → NDC
|
||||
+ "uniform float uPointSize;\n"
|
||||
+ "void main() {\n"
|
||||
+ " gl_Position = vec4(aPos.x * uXform.x + uXform.y,\n"
|
||||
+ " aPos.y * uXform.z + uXform.w, 0.0, 1.0);\n"
|
||||
+ " gl_PointSize = uPointSize;\n"
|
||||
+ "}\n";
|
||||
|
||||
// Premultiplied output: the compositor treats a TRANSLUCENT surface as
|
||||
// premultiplied, so straight alpha would leave dst alpha = a² and draw an
|
||||
// over-bright halo on the anti-aliased fringe.
|
||||
private static final String FRAGMENT_SHADER = ""
|
||||
+ "precision mediump float;\n"
|
||||
+ "uniform vec4 uColor;\n"
|
||||
+ "void main() {\n"
|
||||
+ " float dist = length(gl_PointCoord - vec2(0.5));\n"
|
||||
+ " float a = uColor.a * (1.0 - smoothstep(0.35, 0.5, dist));\n"
|
||||
+ " if (a <= 0.01) discard;\n"
|
||||
+ " gl_FragColor = vec4(uColor.rgb * a, a);\n"
|
||||
+ "}\n";
|
||||
|
||||
private final Object lock = new Object();
|
||||
private final float[] staging = new float[MAX_POINTS * 2];
|
||||
private int stagingCount;
|
||||
private int imageWidth;
|
||||
private int imageHeight;
|
||||
private boolean mirrored;
|
||||
|
||||
private final FloatBuffer vertexBuffer;
|
||||
private final float pointSizePx;
|
||||
private final float[] color = new float[4];
|
||||
|
||||
private int program;
|
||||
private int aPosLoc;
|
||||
private int uXformLoc;
|
||||
private int uPointSizeLoc;
|
||||
private int uColorLoc;
|
||||
private int viewWidth;
|
||||
private int viewHeight;
|
||||
|
||||
PointsRenderer(float pointSizePx, int argbColor) {
|
||||
this.pointSizePx = pointSizePx;
|
||||
color[0] = Color.red(argbColor) / 255f;
|
||||
color[1] = Color.green(argbColor) / 255f;
|
||||
color[2] = Color.blue(argbColor) / 255f;
|
||||
color[3] = Color.alpha(argbColor) / 255f;
|
||||
vertexBuffer = ByteBuffer.allocateDirect(MAX_POINTS * 2 * 4)
|
||||
.order(ByteOrder.nativeOrder())
|
||||
.asFloatBuffer();
|
||||
}
|
||||
|
||||
void setPoints(float[] xy, int count, int imageWidth, int imageHeight, boolean mirrored) {
|
||||
synchronized (lock) {
|
||||
stagingCount = xy == null ? 0 : Math.min(count, MAX_POINTS);
|
||||
if (stagingCount > 0) {
|
||||
System.arraycopy(xy, 0, staging, 0, stagingCount * 2);
|
||||
}
|
||||
this.imageWidth = imageWidth;
|
||||
this.imageHeight = imageHeight;
|
||||
this.mirrored = mirrored;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
|
||||
// A recreated surface must start empty: GLSurfaceView always draws one frame
|
||||
// after creation, which would otherwise flash the pre-destruction points.
|
||||
synchronized (lock) {
|
||||
stagingCount = 0;
|
||||
}
|
||||
// The context is recreated whenever the view is shown again — rebuild everything.
|
||||
program = GLES20.glCreateProgram();
|
||||
GLES20.glAttachShader(program, compileShader(GLES20.GL_VERTEX_SHADER, VERTEX_SHADER));
|
||||
GLES20.glAttachShader(program, compileShader(GLES20.GL_FRAGMENT_SHADER, FRAGMENT_SHADER));
|
||||
GLES20.glLinkProgram(program);
|
||||
aPosLoc = GLES20.glGetAttribLocation(program, "aPos");
|
||||
uXformLoc = GLES20.glGetUniformLocation(program, "uXform");
|
||||
uPointSizeLoc = GLES20.glGetUniformLocation(program, "uPointSize");
|
||||
uColorLoc = GLES20.glGetUniformLocation(program, "uColor");
|
||||
GLES20.glClearColor(0f, 0f, 0f, 0f);
|
||||
GLES20.glEnable(GLES20.GL_BLEND);
|
||||
GLES20.glBlendFunc(GLES20.GL_ONE, GLES20.GL_ONE_MINUS_SRC_ALPHA);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSurfaceChanged(GL10 gl, int width, int height) {
|
||||
GLES20.glViewport(0, 0, width, height);
|
||||
viewWidth = width;
|
||||
viewHeight = height;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDrawFrame(GL10 gl) {
|
||||
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
|
||||
|
||||
int count;
|
||||
float ax;
|
||||
float bx;
|
||||
float ay;
|
||||
float by;
|
||||
synchronized (lock) {
|
||||
count = stagingCount;
|
||||
if (count == 0 || imageWidth <= 0 || imageHeight <= 0
|
||||
|| viewWidth <= 0 || viewHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
vertexBuffer.position(0);
|
||||
vertexBuffer.put(staging, 0, count * 2);
|
||||
vertexBuffer.position(0);
|
||||
// Same FILL_CENTER mapping as FaceOverlayView, folded into x' = ax*x + bx.
|
||||
float scale = Math.max((float) viewWidth / imageWidth, (float) viewHeight / imageHeight);
|
||||
float dx = (viewWidth - imageWidth * scale) / 2f;
|
||||
float dy = (viewHeight - imageHeight * scale) / 2f;
|
||||
float mx = mirrored ? -scale : scale;
|
||||
float cx = mirrored ? imageWidth * scale + dx : dx;
|
||||
ax = 2f * mx / viewWidth;
|
||||
bx = 2f * cx / viewWidth - 1f;
|
||||
ay = -2f * scale / viewHeight;
|
||||
by = 1f - 2f * dy / viewHeight;
|
||||
}
|
||||
|
||||
GLES20.glUseProgram(program);
|
||||
GLES20.glEnableVertexAttribArray(aPosLoc);
|
||||
GLES20.glVertexAttribPointer(aPosLoc, 2, GLES20.GL_FLOAT, false, 0, vertexBuffer);
|
||||
GLES20.glUniform4f(uXformLoc, ax, bx, ay, by);
|
||||
GLES20.glUniform1f(uPointSizeLoc, pointSizePx);
|
||||
GLES20.glUniform4f(uColorLoc, color[0], color[1], color[2], color[3]);
|
||||
GLES20.glDrawArrays(GLES20.GL_POINTS, 0, count);
|
||||
GLES20.glDisableVertexAttribArray(aPosLoc);
|
||||
}
|
||||
|
||||
private static int compileShader(int type, String source) {
|
||||
int shader = GLES20.glCreateShader(type);
|
||||
GLES20.glShaderSource(shader, source);
|
||||
GLES20.glCompileShader(shader);
|
||||
return shader;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package com.example.inspireface_example.view;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.camera.view.PreviewView;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import com.example.inspireface_example.FaceModelPrefs;
|
||||
import com.example.inspireface_example.LocalePrefs;
|
||||
import com.example.inspireface_example.R;
|
||||
import com.example.inspireface_example.permission.CameraPermissionCoordinator;
|
||||
import com.google.android.material.card.MaterialCardView;
|
||||
import com.google.android.material.progressindicator.LinearProgressIndicator;
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial;
|
||||
import com.insightface.sdk.inspireface.base.FaceEulerAngle;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* Shared camera screen used by the three dedicated feature activities. Subclasses choose
|
||||
* one fixed controller mode while this class owns the common CameraX and overlay lifecycle.
|
||||
*/
|
||||
public class LivenessActivity extends AppCompatActivity implements FaceAnalyzer.Listener {
|
||||
|
||||
private PreviewView previewView;
|
||||
private FaceOverlayView overlayView;
|
||||
private LandmarkGlView landmarkView;
|
||||
private TextView promptTitle;
|
||||
private TextView promptSub;
|
||||
private TextView perfText;
|
||||
private TextView eulerText;
|
||||
private SwitchMaterial switchEuler;
|
||||
private LinearProgressIndicator promptProgress;
|
||||
private View btnRestart;
|
||||
|
||||
private final ExecutorService analysisExecutor = Executors.newSingleThreadExecutor();
|
||||
private LivenessController controller;
|
||||
private FaceAnalyzer analyzer;
|
||||
private LivenessController.UiState lastState;
|
||||
private CameraPreviewController cameraController;
|
||||
private CameraPermissionCoordinator cameraPermission;
|
||||
private boolean engineStartingOrReady;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
cameraPermission = new CameraPermissionCoordinator(this,
|
||||
new CameraPermissionCoordinator.Listener() {
|
||||
@Override
|
||||
public void onCameraPermissionGranted() {
|
||||
startEngine();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCameraPermissionBlocked(boolean requiresSettings) {
|
||||
showCameraPermissionBlocked(requiresSettings);
|
||||
}
|
||||
});
|
||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||
setContentView(R.layout.activity_liveness);
|
||||
|
||||
previewView = findViewById(R.id.previewView);
|
||||
overlayView = findViewById(R.id.faceOverlay);
|
||||
landmarkView = findViewById(R.id.landmarkGlView);
|
||||
promptTitle = findViewById(R.id.promptTitle);
|
||||
promptSub = findViewById(R.id.promptSub);
|
||||
perfText = findViewById(R.id.perfText);
|
||||
eulerText = findViewById(R.id.eulerText);
|
||||
switchEuler = findViewById(R.id.switchEuler);
|
||||
promptProgress = findViewById(R.id.promptProgress);
|
||||
btnRestart = findViewById(R.id.btnRestart);
|
||||
cameraPermission.bindRecoveryButton(
|
||||
findViewById(R.id.btnCameraPermissionAction));
|
||||
|
||||
((TextView) findViewById(R.id.pageTitle)).setText(pageTitleRes());
|
||||
((TextView) findViewById(R.id.currentModel)).setText(
|
||||
getString(R.string.current_model, FaceModelPrefs.get(this).sdkName()));
|
||||
findViewById(R.id.btnBack).setOnClickListener(v -> finish());
|
||||
|
||||
switchEuler.setOnCheckedChangeListener((button, checked) -> {
|
||||
if (analyzer != null) {
|
||||
analyzer.setEulerEnabled(checked);
|
||||
}
|
||||
eulerText.setText(R.string.euler_no_face);
|
||||
eulerText.setVisibility(checked ? View.VISIBLE : View.GONE);
|
||||
});
|
||||
applyWindowInsets();
|
||||
|
||||
controller = new LivenessController(this);
|
||||
controller.setMode(initialMode());
|
||||
btnRestart.setOnClickListener(v -> controller.restart());
|
||||
// In-app language toggle (bottom-right): English by default, Chinese on demand.
|
||||
findViewById(R.id.langSwitch).setOnClickListener(v -> LocalePrefs.toggle(this));
|
||||
findViewById(R.id.btnFlipCamera).setOnClickListener(v -> flipCamera());
|
||||
|
||||
cameraPermission.requestAccess();
|
||||
}
|
||||
|
||||
private void applyWindowInsets() {
|
||||
View topBar = findViewById(R.id.topBar);
|
||||
MaterialCardView promptCard = findViewById(R.id.promptCard);
|
||||
int cardBaseMargin = (int) (24 * getResources().getDisplayMetrics().density);
|
||||
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.livenessRoot), (v, insets) -> {
|
||||
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
|
||||
topBar.setPadding(topBar.getPaddingLeft(), bars.top,
|
||||
topBar.getPaddingRight(), topBar.getPaddingBottom());
|
||||
ViewGroup.MarginLayoutParams lp =
|
||||
(ViewGroup.MarginLayoutParams) promptCard.getLayoutParams();
|
||||
lp.bottomMargin = cardBaseMargin + bars.bottom;
|
||||
promptCard.setLayoutParams(lp);
|
||||
return insets;
|
||||
});
|
||||
}
|
||||
|
||||
/** GlobalLaunch copies model assets on first run — keep it off the main thread. */
|
||||
private void startEngine() {
|
||||
if (engineStartingOrReady) {
|
||||
return;
|
||||
}
|
||||
engineStartingOrReady = true;
|
||||
promptTitle.setText(R.string.msg_initializing);
|
||||
promptSub.setVisibility(View.GONE);
|
||||
analysisExecutor.execute(() -> {
|
||||
boolean ok = FaceEngine.ensureLaunched(this);
|
||||
runOnUiThread(() -> {
|
||||
if (isDestroyed() || isFinishing()) {
|
||||
return; // model copy can outlive the activity — don't bind a dead lifecycle
|
||||
}
|
||||
if (ok) {
|
||||
bindCamera();
|
||||
} else {
|
||||
engineStartingOrReady = false;
|
||||
promptTitle.setText(R.string.msg_engine_failed);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void showCameraPermissionBlocked(boolean requiresSettings) {
|
||||
promptTitle.setText(R.string.msg_permission_required);
|
||||
promptSub.setText(requiresSettings
|
||||
? R.string.camera_permission_settings_hint
|
||||
: R.string.camera_permission_retry_hint);
|
||||
promptSub.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
private void bindCamera() {
|
||||
analyzer = new FaceAnalyzer(controller, overlayView, landmarkView, this, true);
|
||||
analyzer.setEulerEnabled(switchEuler.isChecked());
|
||||
analyzer.setLandmarksEnabled(false);
|
||||
cameraController = new CameraPreviewController(
|
||||
this, this, previewView, analysisExecutor, analyzer,
|
||||
new CameraPreviewController.Listener() {
|
||||
@Override
|
||||
public void onCameraReady(boolean frontCamera) {
|
||||
analyzer.setMirrored(frontCamera);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLensChanged(boolean frontCamera) {
|
||||
analyzer.setMirrored(frontCamera);
|
||||
controller.restart();
|
||||
overlayView.submit(null);
|
||||
landmarkView.clearPoints();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCameraError(int messageRes) {
|
||||
promptTitle.setText(messageRes);
|
||||
}
|
||||
});
|
||||
cameraController.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches between the front and back lens. The SDK needs no per-lens handling:
|
||||
* every frame is pre-rotated by its own rotationDegrees before
|
||||
* CreateImageStreamFromByteBuffer (always CAMERA_ROTATION_0), so the new lens's
|
||||
* different sensor orientation is absorbed per frame. Only the display mirroring
|
||||
* flips, and the mode state restarts so stale tracking can't leak across lenses.
|
||||
*/
|
||||
private void flipCamera() {
|
||||
if (cameraController == null || !cameraController.flipCamera()) {
|
||||
Toast.makeText(this, R.string.msg_camera_unavailable, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
/** Silent liveness is the behavior of the original activity and the first home tile. */
|
||||
protected LivenessController.Mode initialMode() {
|
||||
return LivenessController.Mode.SILENT;
|
||||
}
|
||||
|
||||
protected int pageTitleRes() {
|
||||
return R.string.mode_silent;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// FaceAnalyzer.Listener (analysis thread)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public void onUiState(LivenessController.UiState state) {
|
||||
runOnUiThread(() -> {
|
||||
if (state.sameContent(lastState)) {
|
||||
return;
|
||||
}
|
||||
lastState = state;
|
||||
promptTitle.setText(state.title);
|
||||
if (state.subtitle != null) {
|
||||
promptSub.setText(state.subtitle);
|
||||
promptSub.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
promptSub.setVisibility(View.GONE);
|
||||
}
|
||||
if (state.progress >= 0) {
|
||||
promptProgress.setProgress(state.progress);
|
||||
promptProgress.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
promptProgress.setVisibility(View.GONE);
|
||||
}
|
||||
btnRestart.setVisibility(state.showRestart ? View.VISIBLE : View.GONE);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPerf(double fps, long latencyMs) {
|
||||
runOnUiThread(() -> {
|
||||
perfText.setVisibility(View.VISIBLE);
|
||||
perfText.setText(String.format(Locale.US,
|
||||
getString(R.string.perf_format), fps, latencyMs));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEulerAngles(FaceEulerAngle angle) {
|
||||
runOnUiThread(() -> {
|
||||
if (!switchEuler.isChecked()) {
|
||||
return;
|
||||
}
|
||||
if (angle == null) {
|
||||
eulerText.setText(R.string.euler_no_face);
|
||||
} else {
|
||||
eulerText.setText(String.format(Locale.US,
|
||||
getString(R.string.euler_format), angle.yaw, angle.pitch, angle.roll));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSessionError() {
|
||||
runOnUiThread(() -> promptTitle.setText(R.string.msg_engine_failed));
|
||||
}
|
||||
|
||||
// GL lifecycle follows visibility (start/stop), not focus (resume/pause): in
|
||||
// multi-window the activity can be paused but visible with the camera and analysis
|
||||
// still running — the landmark overlay must keep rendering there.
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
cameraPermission.onResume();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
landmarkView.onResume();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
landmarkView.onPause();
|
||||
super.onStop();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
cameraPermission.close();
|
||||
if (cameraController != null) {
|
||||
cameraController.stop();
|
||||
}
|
||||
if (analyzer != null) {
|
||||
FaceAnalyzer toRelease = analyzer;
|
||||
analysisExecutor.execute(toRelease::release);
|
||||
}
|
||||
analysisExecutor.shutdown();
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
package com.example.inspireface_example.view;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.os.SystemClock;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.style.ForegroundColorSpan;
|
||||
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.example.inspireface_example.R;
|
||||
import com.insightface.sdk.inspireface.InspireFace;
|
||||
import com.insightface.sdk.inspireface.base.CustomParameter;
|
||||
import com.insightface.sdk.inspireface.base.FaceInteractionsActions;
|
||||
import com.insightface.sdk.inspireface.base.FaceRect;
|
||||
import com.insightface.sdk.inspireface.base.ImageStream;
|
||||
import com.insightface.sdk.inspireface.base.MultipleFaceData;
|
||||
import com.insightface.sdk.inspireface.base.RGBLivenessConfidence;
|
||||
import com.insightface.sdk.inspireface.base.Session;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
|
||||
/**
|
||||
* Per-frame liveness logic for both modes. All {@link #onFrame} calls must come from the
|
||||
* single analysis thread; mode switches from the UI thread are serialized with
|
||||
* {@code synchronized}.
|
||||
*/
|
||||
final class LivenessController {
|
||||
|
||||
enum Mode { SILENT, ACTION, POSE }
|
||||
|
||||
/** Cooperative challenges the SDK can recognize. */
|
||||
enum ActionType {
|
||||
BLINK(R.string.action_blink, R.string.name_blink),
|
||||
SHAKE(R.string.action_shake, R.string.name_shake),
|
||||
JAW_OPEN(R.string.action_jaw_open, R.string.name_jaw_open),
|
||||
HEAD_RAISE(R.string.action_head_raise, R.string.name_head_raise);
|
||||
|
||||
final int promptRes;
|
||||
final int nameRes;
|
||||
|
||||
ActionType(int promptRes, int nameRes) {
|
||||
this.promptRes = promptRes;
|
||||
this.nameRes = nameRes;
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot the UI renders; immutable, built on the analysis thread. */
|
||||
static final class UiState {
|
||||
final String title;
|
||||
final CharSequence subtitle; // null hides the row
|
||||
final int progress; // 0..100, -1 hides the bar
|
||||
final boolean showRestart;
|
||||
final int boxColor;
|
||||
|
||||
UiState(String title, CharSequence subtitle, int progress, boolean showRestart, int boxColor) {
|
||||
this.title = title;
|
||||
this.subtitle = subtitle;
|
||||
this.progress = progress;
|
||||
this.showRestart = showRestart;
|
||||
this.boxColor = boxColor;
|
||||
}
|
||||
|
||||
boolean sameContent(UiState o) {
|
||||
return o != null
|
||||
&& progress == o.progress
|
||||
&& showRestart == o.showRestart
|
||||
&& boxColor == o.boxColor
|
||||
&& title.equals(o.title)
|
||||
&& (subtitle == null ? o.subtitle == null
|
||||
: o.subtitle != null && subtitle.toString().equals(o.subtitle.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Silent-mode tunables ----
|
||||
/** RGB anti-spoofing score above which a face counts as live (SDK author default). */
|
||||
private static final float RGB_LIVENESS_THRESHOLD = 0.88f;
|
||||
/** Frames averaged before giving a verdict; smooths single-frame noise. */
|
||||
private static final int SCORE_WINDOW = 8;
|
||||
private static final int MIN_VERDICT_SAMPLES = 3;
|
||||
/**
|
||||
* Run the anti-spoofing pipeline on every Nth tracked frame. Each call converts the
|
||||
* full frame internally (the SDK's own acknowledged hot spot), so skipping frames
|
||||
* buys preview smoothness at no visible cost given the score averaging.
|
||||
*/
|
||||
private static final int SILENT_PIPELINE_INTERVAL = 2;
|
||||
|
||||
// ---- Action-mode tunables ----
|
||||
private static final int ACTIONS_PER_RUN = 3;
|
||||
private static final long ACTION_TIMEOUT_MS = 8_000;
|
||||
/**
|
||||
* Consecutive good frames required before the challenge sequence starts. Must be
|
||||
* >= the SDK's 9-call action warm-up so flags are live for the first challenge.
|
||||
*/
|
||||
private static final int STABLE_FRAMES_TO_START = 10;
|
||||
/**
|
||||
* Shake latches while both yaw extremes sit in the SDK's rolling 10-slot window, so
|
||||
* a single 0 read can still hide a half-shake residue from the previous step. Only
|
||||
* arm SHAKE after the window has provably flushed.
|
||||
*/
|
||||
private static final int SHAKE_ARM_ZERO_FRAMES = 10;
|
||||
private static final long FACE_LOST_GRACE_MS = 800;
|
||||
private static final float MAX_START_YAW_DEG = 20f;
|
||||
private static final float MAX_START_PITCH_DEG = 15f;
|
||||
|
||||
// ---- Pose-mode tunables ----
|
||||
/** Latest action shown large plus up to 5 smaller history entries. */
|
||||
private static final int POSE_HISTORY_MAX = 6;
|
||||
|
||||
// ---- Shared gating ----
|
||||
/** Faces narrower than this fraction of the frame width are too far away to judge. */
|
||||
private static final float MIN_FACE_WIDTH_RATIO = 0.18f;
|
||||
|
||||
private enum Phase { WAIT_FACE, CHALLENGE, PASSED, FAILED }
|
||||
|
||||
private final Context context;
|
||||
private final Random random = new Random();
|
||||
|
||||
private final CustomParameter silentParam = InspireFace.CreateCustomParameter().enableLiveness(true);
|
||||
private final CustomParameter actionParam = InspireFace.CreateCustomParameter().enableInteractionLiveness(true);
|
||||
|
||||
private final int colorNeutral;
|
||||
private final int colorWarn;
|
||||
private final int colorFail;
|
||||
|
||||
private Mode mode = Mode.SILENT;
|
||||
|
||||
// Silent state
|
||||
private final ArrayDeque<Float> scoreWindow = new ArrayDeque<>();
|
||||
private int silentFrameCounter;
|
||||
private UiState lastSilentState;
|
||||
private int silentTrackId = -1;
|
||||
|
||||
// Pose state
|
||||
private final ArrayDeque<ActionType> poseHistory = new ArrayDeque<>();
|
||||
private final int[] posePrevFlags = new int[ActionType.values().length];
|
||||
private int poseTrackId = -1;
|
||||
|
||||
// Action state
|
||||
private Phase phase = Phase.WAIT_FACE;
|
||||
private final List<ActionType> sequence = new ArrayList<>();
|
||||
private int actionIndex;
|
||||
private long actionDeadline;
|
||||
/**
|
||||
* Edge trigger: jawOpen/headRaise stay 1 while the pose is held and shake latches for
|
||||
* ~10 pipeline calls, so a leftover from the previous step could complete the next one
|
||||
* instantly. Each step first has to observe the flag at 0 before a 1 counts —
|
||||
* SHAKE needs a full window of zeros (see SHAKE_ARM_ZERO_FRAMES).
|
||||
*/
|
||||
private boolean actionArmed;
|
||||
private int armZeroStreak;
|
||||
private int stableFrames;
|
||||
private int lockedTrackId = -1;
|
||||
private long faceLostSince;
|
||||
private int failReasonRes;
|
||||
|
||||
LivenessController(Context context) {
|
||||
// Deliberately the activity context, not the application one: per-app locales
|
||||
// (AppCompatDelegate) only wrap activity contexts below API 33, and every prompt
|
||||
// string is resolved through this reference. Lifetime matches the activity.
|
||||
this.context = context;
|
||||
colorNeutral = ContextCompat.getColor(context, R.color.liveness_accent);
|
||||
colorWarn = ContextCompat.getColor(context, R.color.liveness_warn);
|
||||
colorFail = ContextCompat.getColor(context, R.color.liveness_fail);
|
||||
}
|
||||
|
||||
synchronized Mode getMode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
synchronized void setMode(Mode newMode) {
|
||||
if (mode != newMode) {
|
||||
mode = newMode;
|
||||
reset();
|
||||
}
|
||||
}
|
||||
|
||||
synchronized void restart() {
|
||||
reset();
|
||||
}
|
||||
|
||||
private void reset() {
|
||||
clearSilentState();
|
||||
phase = Phase.WAIT_FACE;
|
||||
sequence.clear();
|
||||
actionIndex = 0;
|
||||
stableFrames = 0;
|
||||
lockedTrackId = -1;
|
||||
faceLostSince = 0;
|
||||
poseHistory.clear();
|
||||
resetPoseEdges();
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes one tracked frame. Runs the pipeline appropriate for the current mode while
|
||||
* {@code stream} is still alive and returns what the UI should display.
|
||||
*/
|
||||
synchronized UiState onFrame(Session session, ImageStream stream,
|
||||
MultipleFaceData faces, int uprightWidth, int uprightHeight) {
|
||||
switch (mode) {
|
||||
case SILENT:
|
||||
return onSilentFrame(session, stream, faces, uprightWidth);
|
||||
case POSE:
|
||||
return onPoseFrame(session, stream, faces);
|
||||
case ACTION:
|
||||
default:
|
||||
return onActionFrame(session, stream, faces, uprightWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Silent (RGB anti-spoofing) mode
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private UiState onSilentFrame(Session session, ImageStream stream,
|
||||
MultipleFaceData faces, int uprightWidth) {
|
||||
if (faces.detectedNum == 0) {
|
||||
clearSilentState();
|
||||
return new UiState(str(R.string.msg_no_face), null, -1, false, colorNeutral);
|
||||
}
|
||||
if (faces.detectedNum > 1) {
|
||||
clearSilentState();
|
||||
return new UiState(str(R.string.msg_multiple_faces), null, -1, false, colorWarn);
|
||||
}
|
||||
int idx = largestFaceIndex(faces);
|
||||
if (!isFaceBigEnough(faces.rects[idx], uprightWidth)) {
|
||||
clearSilentState();
|
||||
return new UiState(str(R.string.msg_face_too_small), null, -1, false, colorWarn);
|
||||
}
|
||||
// A different track id means a different subject — never blend its scores or show
|
||||
// the previous subject's cached verdict.
|
||||
if (faces.trackIds[idx] != silentTrackId) {
|
||||
clearSilentState();
|
||||
silentTrackId = faces.trackIds[idx];
|
||||
}
|
||||
|
||||
if (silentFrameCounter++ % SILENT_PIPELINE_INTERVAL != 0 && lastSilentState != null) {
|
||||
return lastSilentState;
|
||||
}
|
||||
if (!InspireFace.MultipleFacePipelineProcess(session, stream, faces, silentParam)) {
|
||||
return new UiState(str(R.string.silent_analyzing), null, -1, false, colorNeutral);
|
||||
}
|
||||
RGBLivenessConfidence liveness = InspireFace.GetRGBLivenessConfidence(session);
|
||||
if (liveness == null || liveness.num <= idx) {
|
||||
return new UiState(str(R.string.silent_analyzing), null, -1, false, colorNeutral);
|
||||
}
|
||||
|
||||
scoreWindow.addLast(liveness.confidence[idx]);
|
||||
while (scoreWindow.size() > SCORE_WINDOW) {
|
||||
scoreWindow.removeFirst();
|
||||
}
|
||||
float sum = 0f;
|
||||
for (float v : scoreWindow) {
|
||||
sum += v;
|
||||
}
|
||||
float avg = sum / scoreWindow.size();
|
||||
String scoreText = str(R.string.silent_score, avg);
|
||||
int progress = Math.round(avg * 100);
|
||||
|
||||
UiState state;
|
||||
if (scoreWindow.size() < MIN_VERDICT_SAMPLES) {
|
||||
state = new UiState(str(R.string.silent_analyzing), scoreText, progress, false, colorNeutral);
|
||||
} else {
|
||||
boolean live = avg >= RGB_LIVENESS_THRESHOLD;
|
||||
state = new UiState(
|
||||
str(live ? R.string.silent_real : R.string.silent_fake),
|
||||
scoreText, progress, false, live ? colorNeutral : colorFail);
|
||||
}
|
||||
lastSilentState = state;
|
||||
return state;
|
||||
}
|
||||
|
||||
private void clearSilentState() {
|
||||
scoreWindow.clear();
|
||||
silentFrameCounter = 0;
|
||||
lastSilentState = null;
|
||||
silentTrackId = -1;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Cooperative action mode
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private UiState onActionFrame(Session session, ImageStream stream,
|
||||
MultipleFaceData faces, int uprightWidth) {
|
||||
long now = SystemClock.elapsedRealtime();
|
||||
switch (phase) {
|
||||
case PASSED:
|
||||
return new UiState(str(R.string.action_passed), null, 100, true, colorNeutral);
|
||||
case FAILED:
|
||||
return new UiState(str(failReasonRes), null, -1, true, colorFail);
|
||||
case WAIT_FACE:
|
||||
return onWaitFace(session, stream, faces, uprightWidth);
|
||||
case CHALLENGE:
|
||||
default:
|
||||
return onChallenge(session, stream, faces, now);
|
||||
}
|
||||
}
|
||||
|
||||
private UiState onWaitFace(Session session, ImageStream stream,
|
||||
MultipleFaceData faces, int uprightWidth) {
|
||||
if (faces.detectedNum == 0) {
|
||||
stableFrames = 0;
|
||||
return new UiState(str(R.string.msg_no_face), null, -1, false, colorNeutral);
|
||||
}
|
||||
if (faces.detectedNum > 1) {
|
||||
stableFrames = 0;
|
||||
return new UiState(str(R.string.msg_multiple_faces), null, -1, false, colorWarn);
|
||||
}
|
||||
int idx = largestFaceIndex(faces);
|
||||
if (!isFaceBigEnough(faces.rects[idx], uprightWidth)) {
|
||||
stableFrames = 0;
|
||||
return new UiState(str(R.string.msg_face_too_small), null, -1, false, colorWarn);
|
||||
}
|
||||
// Pre-warm the SDK's 10-call action window during get-ready so the first
|
||||
// challenge is detectable the moment it is shown.
|
||||
InspireFace.MultipleFacePipelineProcess(session, stream, faces, actionParam);
|
||||
// angles[] is only trustworthy at index 0 — the 1.2.0 JNI writes face[0]'s angles
|
||||
// into every slot. We require exactly one face here, so idx is always 0.
|
||||
boolean frontal = Math.abs(faces.angles[idx].yaw) <= MAX_START_YAW_DEG
|
||||
&& Math.abs(faces.angles[idx].pitch) <= MAX_START_PITCH_DEG;
|
||||
if (!frontal) {
|
||||
stableFrames = 0;
|
||||
return new UiState(str(R.string.action_get_ready), null, -1, false, colorWarn);
|
||||
}
|
||||
stableFrames++;
|
||||
if (stableFrames < STABLE_FRAMES_TO_START) {
|
||||
int progress = stableFrames * 100 / STABLE_FRAMES_TO_START;
|
||||
return new UiState(str(R.string.action_get_ready), null, progress, false, colorNeutral);
|
||||
}
|
||||
startChallenge(faces.trackIds[idx]);
|
||||
return challengeState(SystemClock.elapsedRealtime(), null);
|
||||
}
|
||||
|
||||
private void startChallenge(int trackId) {
|
||||
List<ActionType> pool = new ArrayList<>(Arrays.asList(ActionType.values()));
|
||||
Collections.shuffle(pool, random);
|
||||
sequence.clear();
|
||||
sequence.addAll(pool.subList(0, Math.min(ACTIONS_PER_RUN, pool.size())));
|
||||
actionIndex = 0;
|
||||
lockedTrackId = trackId;
|
||||
faceLostSince = 0;
|
||||
armCurrentAction();
|
||||
phase = Phase.CHALLENGE;
|
||||
}
|
||||
|
||||
private void armCurrentAction() {
|
||||
actionDeadline = SystemClock.elapsedRealtime() + ACTION_TIMEOUT_MS;
|
||||
actionArmed = false;
|
||||
armZeroStreak = 0;
|
||||
}
|
||||
|
||||
private UiState onChallenge(Session session, ImageStream stream,
|
||||
MultipleFaceData faces, long now) {
|
||||
int idx = indexOfTrackId(faces, lockedTrackId);
|
||||
if (idx < 0) {
|
||||
if (faceLostSince == 0) {
|
||||
faceLostSince = now;
|
||||
}
|
||||
if (now - faceLostSince > FACE_LOST_GRACE_MS) {
|
||||
return fail(R.string.action_face_lost);
|
||||
}
|
||||
return new UiState(str(R.string.msg_no_face), stepText(), -1, false, colorWarn);
|
||||
}
|
||||
faceLostSince = 0;
|
||||
|
||||
if (now > actionDeadline) {
|
||||
return fail(R.string.action_failed_timeout);
|
||||
}
|
||||
|
||||
if (InspireFace.MultipleFacePipelineProcess(session, stream, faces, actionParam)) {
|
||||
FaceInteractionsActions actions = InspireFace.GetFaceInteractionActionsResult(session);
|
||||
if (actions != null && actions.num > idx) {
|
||||
ActionType current = sequence.get(actionIndex);
|
||||
int flag = actionFlag(actions, idx, current);
|
||||
// normal==1 marks the SDK's warm-up (fresh track or post-blink window
|
||||
// reset) during which every flag reads a placeholder 0. Those zeros must
|
||||
// not open the gate, or a pose held through a blink would count as fresh.
|
||||
boolean warmingUp = actions.normal[idx] == 1;
|
||||
if (!actionArmed) {
|
||||
// -1 means "not evaluated" — only a real 0 opens the gate.
|
||||
armZeroStreak = !warmingUp && flag == 0 ? armZeroStreak + 1 : 0;
|
||||
actionArmed = armZeroStreak
|
||||
>= (current == ActionType.SHAKE ? SHAKE_ARM_ZERO_FRAMES : 1);
|
||||
} else if (flag > 0 && !warmingUp) {
|
||||
actionIndex++;
|
||||
if (actionIndex >= sequence.size()) {
|
||||
phase = Phase.PASSED;
|
||||
lockedTrackId = -1;
|
||||
return new UiState(str(R.string.action_passed), null, 100, true, colorNeutral);
|
||||
}
|
||||
armCurrentAction();
|
||||
}
|
||||
}
|
||||
}
|
||||
String warn = faces.detectedNum > 1 ? str(R.string.msg_multiple_faces) : null;
|
||||
return challengeState(now, warn);
|
||||
}
|
||||
|
||||
private UiState challengeState(long now, String warnOverride) {
|
||||
long msLeft = Math.max(0, actionDeadline - now);
|
||||
String subtitle = warnOverride != null
|
||||
? warnOverride
|
||||
: stepText() + " · " + str(R.string.action_time_left, (int) ((msLeft + 999) / 1000));
|
||||
int progress = (int) (msLeft * 100 / ACTION_TIMEOUT_MS);
|
||||
return new UiState(str(sequence.get(actionIndex).promptRes), subtitle, progress, false, colorNeutral);
|
||||
}
|
||||
|
||||
private String stepText() {
|
||||
return str(R.string.action_step, actionIndex + 1, sequence.size());
|
||||
}
|
||||
|
||||
private UiState fail(int reasonRes) {
|
||||
phase = Phase.FAILED;
|
||||
failReasonRes = reasonRes;
|
||||
lockedTrackId = -1;
|
||||
return new UiState(str(reasonRes), null, -1, true, colorFail);
|
||||
}
|
||||
|
||||
private static int actionFlag(FaceInteractionsActions actions, int idx, ActionType type) {
|
||||
switch (type) {
|
||||
case BLINK:
|
||||
return actions.blink[idx];
|
||||
case SHAKE:
|
||||
return actions.shake[idx];
|
||||
case JAW_OPEN:
|
||||
return actions.jawOpen[idx];
|
||||
case HEAD_RAISE:
|
||||
default:
|
||||
return actions.headRaise[idx];
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Pose recognition mode — display whatever action the user performs
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private UiState onPoseFrame(Session session, ImageStream stream, MultipleFaceData faces) {
|
||||
if (faces.detectedNum == 0) {
|
||||
resetPoseEdges();
|
||||
poseTrackId = -1;
|
||||
// Without a face there is no "current" action — show the full history small.
|
||||
return new UiState(str(R.string.msg_no_face), poseHistoryLine(true), -1, false, colorNeutral);
|
||||
}
|
||||
if (faces.detectedNum > 1) {
|
||||
// Edge state is keyed to one face; following the "largest" of two similar
|
||||
// faces would flap and fabricate rising edges.
|
||||
return new UiState(str(R.string.msg_multiple_faces), poseHistoryLine(true), -1, false, colorWarn);
|
||||
}
|
||||
int idx = largestFaceIndex(faces);
|
||||
if (faces.trackIds[idx] != poseTrackId) {
|
||||
resetPoseEdges();
|
||||
poseTrackId = faces.trackIds[idx];
|
||||
}
|
||||
if (InspireFace.MultipleFacePipelineProcess(session, stream, faces, actionParam)) {
|
||||
FaceInteractionsActions actions = InspireFace.GetFaceInteractionActionsResult(session);
|
||||
// Skip the SDK warm-up (normal==1: fresh track or post-blink window reset):
|
||||
// its placeholder zeros would read as "pose released" and make a pose held
|
||||
// through a natural blink re-register as a duplicate entry.
|
||||
if (actions != null && actions.num > idx && actions.normal[idx] != 1) {
|
||||
for (ActionType type : ActionType.values()) {
|
||||
int flag = actionFlag(actions, idx, type);
|
||||
if (flag < 0) {
|
||||
continue; // not evaluated this call — keep the previous edge state
|
||||
}
|
||||
// Rising edge only: level-triggered flags (jawOpen/headRaise) and the
|
||||
// window-latched shake must return to 0 before they register again.
|
||||
if (flag == 1 && posePrevFlags[type.ordinal()] == 0) {
|
||||
poseHistory.addFirst(type);
|
||||
while (poseHistory.size() > POSE_HISTORY_MAX) {
|
||||
poseHistory.removeLast();
|
||||
}
|
||||
}
|
||||
posePrevFlags[type.ordinal()] = flag;
|
||||
}
|
||||
}
|
||||
}
|
||||
String title = poseHistory.isEmpty()
|
||||
? str(R.string.pose_hint)
|
||||
: str(poseHistory.peekFirst().nameRes);
|
||||
return new UiState(title, poseHistoryLine(false), -1, false, colorNeutral);
|
||||
}
|
||||
|
||||
/** Edge-detector state only; the visible history is kept until reset/mode switch. */
|
||||
private void resetPoseEdges() {
|
||||
Arrays.fill(posePrevFlags, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* History entries joined oldest-last with decreasing opacity, so past actions visually
|
||||
* fade out as new ones push them toward the tail.
|
||||
*/
|
||||
private CharSequence poseHistoryLine(boolean includeHead) {
|
||||
int skip = includeHead ? 0 : 1;
|
||||
if (poseHistory.size() <= skip) {
|
||||
return null;
|
||||
}
|
||||
SpannableStringBuilder line = new SpannableStringBuilder();
|
||||
int i = 0;
|
||||
int shown = 0;
|
||||
int total = poseHistory.size() - skip;
|
||||
for (ActionType type : poseHistory) {
|
||||
if (i++ < skip) {
|
||||
continue;
|
||||
}
|
||||
if (line.length() > 0) {
|
||||
line.append(" · ");
|
||||
}
|
||||
int start = line.length();
|
||||
line.append(str(type.nameRes));
|
||||
int alpha = 230 - (total <= 1 ? 0 : 160 * shown / (total - 1));
|
||||
line.setSpan(new ForegroundColorSpan(Color.argb(alpha, 255, 255, 255)),
|
||||
start, line.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
shown++;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static int largestFaceIndex(MultipleFaceData faces) {
|
||||
int best = 0;
|
||||
long bestArea = -1;
|
||||
for (int i = 0; i < faces.detectedNum; i++) {
|
||||
FaceRect r = faces.rects[i];
|
||||
long area = (long) r.width * r.height;
|
||||
if (area > bestArea) {
|
||||
bestArea = area;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private static int indexOfTrackId(MultipleFaceData faces, int trackId) {
|
||||
for (int i = 0; i < faces.detectedNum; i++) {
|
||||
if (faces.trackIds[i] == trackId) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static boolean isFaceBigEnough(FaceRect rect, int uprightWidth) {
|
||||
return rect.width >= uprightWidth * MIN_FACE_WIDTH_RATIO;
|
||||
}
|
||||
|
||||
private String str(int res, Object... args) {
|
||||
return args.length == 0 ? context.getString(res) : context.getString(res, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.example.inspireface_example.view;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.insightface.sdk.inspireface.base.Session;
|
||||
|
||||
/**
|
||||
* Small compatibility bridge for the landmark flag omitted by InspireFace Android 1.2.0's
|
||||
* CreateSession JNI wrapper. Session release and all processing still use the official API.
|
||||
*/
|
||||
final class NativeSessionBridge {
|
||||
|
||||
private static final String TAG = "NativeSessionBridge";
|
||||
private static final int ENABLE_DETECT_MODE_LANDMARK = 0x00000200;
|
||||
private static final boolean AVAILABLE;
|
||||
|
||||
static {
|
||||
boolean available;
|
||||
try {
|
||||
System.loadLibrary("inspireface_session_bridge");
|
||||
available = true;
|
||||
} catch (UnsatisfiedLinkError error) {
|
||||
Log.e(TAG, "Could not load Session compatibility bridge", error);
|
||||
available = false;
|
||||
}
|
||||
AVAILABLE = available;
|
||||
}
|
||||
|
||||
private NativeSessionBridge() {
|
||||
}
|
||||
|
||||
@Nullable
|
||||
static Session createLandmarkSession(int detectMode, int maxFaces,
|
||||
int detectPixelLevel, int trackByDetectFps) {
|
||||
if (!AVAILABLE) {
|
||||
return null;
|
||||
}
|
||||
long handle = nativeCreateLandmarkSession(
|
||||
ENABLE_DETECT_MODE_LANDMARK, detectMode,
|
||||
maxFaces, detectPixelLevel, trackByDetectFps);
|
||||
if (handle == 0L) {
|
||||
return null;
|
||||
}
|
||||
Session session = new Session();
|
||||
session.handle = handle;
|
||||
return session;
|
||||
}
|
||||
|
||||
private static native long nativeCreateLandmarkSession(
|
||||
int customOptions, int detectMode, int maxFaces,
|
||||
int detectPixelLevel, int trackByDetectFps);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.example.inspireface_example.view;
|
||||
|
||||
import androidx.camera.core.ImageProxy;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/**
|
||||
* Converts CameraX YUV_420_888 frames to tightly packed NV21, plus an NV21 rotation step
|
||||
* so frames can be handed to InspireFace already upright with CAMERA_ROTATION_0.
|
||||
*
|
||||
* Rotating on the Java side is deliberate: the 1.2.0 SDK's RGB anti-spoofing crop mixes
|
||||
* rotated and unrotated coordinate spaces when a non-zero rotation constant is used, which
|
||||
* silently degrades silent-liveness scores. An upright buffer avoids that entirely and
|
||||
* makes every SDK output coordinate match the preview orientation.
|
||||
*
|
||||
* On virtually all camera HALs the U/V planes of YUV_420_888 alias one interleaved
|
||||
* VU buffer (i.e. the memory already is NV21). That is detected once on the first
|
||||
* frame; afterwards the chroma plane is moved with a single bulk copy. All buffers are
|
||||
* reused across frames, so the converter allocates only on size changes.
|
||||
*
|
||||
* Not thread-safe: use one instance per analysis thread.
|
||||
*/
|
||||
final class Nv21Converter {
|
||||
|
||||
private static final int UNKNOWN = -1;
|
||||
private static final int INTERLEAVED = 1;
|
||||
private static final int PLANAR = 0;
|
||||
|
||||
private byte[] out;
|
||||
private byte[] rotated;
|
||||
private int uvLayout = UNKNOWN;
|
||||
|
||||
/**
|
||||
* Returns the frame as NV21. The returned array is owned by the converter and
|
||||
* overwritten by the next call.
|
||||
*/
|
||||
byte[] convert(ImageProxy image) {
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
int ySize = width * height;
|
||||
int total = ySize + ySize / 2;
|
||||
if (out == null || out.length != total) {
|
||||
out = new byte[total];
|
||||
uvLayout = UNKNOWN;
|
||||
}
|
||||
|
||||
ImageProxy.PlaneProxy[] planes = image.getPlanes();
|
||||
copyLuma(planes[0], width, height, out);
|
||||
|
||||
ImageProxy.PlaneProxy uPlane = planes[1];
|
||||
ImageProxy.PlaneProxy vPlane = planes[2];
|
||||
if (uvLayout == UNKNOWN) {
|
||||
uvLayout = isVuInterleaved(uPlane, vPlane, width, height) ? INTERLEAVED : PLANAR;
|
||||
}
|
||||
if (uvLayout == INTERLEAVED) {
|
||||
copyChromaInterleaved(uPlane, vPlane, ySize, out);
|
||||
} else {
|
||||
copyChromaPlanar(uPlane, vPlane, width, height, ySize, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates a tight NV21 buffer clockwise by the given degrees so it appears upright.
|
||||
* Returns {@code src} itself for 0°; otherwise a reused internal buffer.
|
||||
*/
|
||||
byte[] rotateUpright(byte[] src, int width, int height, int rotationDegrees) {
|
||||
if (rotationDegrees == 0) {
|
||||
return src;
|
||||
}
|
||||
if (rotated == null || rotated.length != src.length) {
|
||||
rotated = new byte[src.length];
|
||||
}
|
||||
int ySize = width * height;
|
||||
int i = 0;
|
||||
switch (rotationDegrees) {
|
||||
case 90:
|
||||
for (int x = 0; x < width; x++) {
|
||||
for (int y = height - 1; y >= 0; y--) {
|
||||
rotated[i++] = src[y * width + x];
|
||||
}
|
||||
}
|
||||
for (int x = 0; x < width; x += 2) {
|
||||
for (int y = height / 2 - 1; y >= 0; y--) {
|
||||
int s = ySize + y * width + x;
|
||||
rotated[i++] = src[s];
|
||||
rotated[i++] = src[s + 1];
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 180:
|
||||
for (int p = ySize - 1; p >= 0; p--) {
|
||||
rotated[i++] = src[p];
|
||||
}
|
||||
for (int p = src.length - 2; p >= ySize; p -= 2) {
|
||||
rotated[i++] = src[p];
|
||||
rotated[i++] = src[p + 1];
|
||||
}
|
||||
break;
|
||||
case 270:
|
||||
default:
|
||||
for (int x = width - 1; x >= 0; x--) {
|
||||
for (int y = 0; y < height; y++) {
|
||||
rotated[i++] = src[y * width + x];
|
||||
}
|
||||
}
|
||||
for (int x = width - 2; x >= 0; x -= 2) {
|
||||
for (int y = 0; y < height / 2; y++) {
|
||||
int s = ySize + y * width + x;
|
||||
rotated[i++] = src[s];
|
||||
rotated[i++] = src[s + 1];
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
return rotated;
|
||||
}
|
||||
|
||||
private static void copyLuma(ImageProxy.PlaneProxy yPlane, int width, int height, byte[] out) {
|
||||
ByteBuffer buf = yPlane.getBuffer();
|
||||
int base = buf.position();
|
||||
int rowStride = yPlane.getRowStride();
|
||||
if (rowStride == width) {
|
||||
buf.get(out, 0, width * height);
|
||||
} else {
|
||||
for (int row = 0; row < height; row++) {
|
||||
buf.position(base + row * rowStride);
|
||||
buf.get(out, row * width, width);
|
||||
}
|
||||
}
|
||||
buf.position(base);
|
||||
}
|
||||
|
||||
/** The V buffer already views VUVU…: one bulk copy plus the trailing U byte it cannot see. */
|
||||
private static void copyChromaInterleaved(ImageProxy.PlaneProxy uPlane, ImageProxy.PlaneProxy vPlane,
|
||||
int ySize, byte[] out) {
|
||||
ByteBuffer vBuf = vPlane.getBuffer();
|
||||
int vPos = vBuf.position();
|
||||
int vuSize = ySize / 2;
|
||||
int n = Math.min(vBuf.remaining(), vuSize);
|
||||
vBuf.get(out, ySize, n);
|
||||
vBuf.position(vPos);
|
||||
if (n < vuSize) {
|
||||
ByteBuffer uBuf = uPlane.getBuffer();
|
||||
out[ySize + vuSize - 1] = uBuf.get(uBuf.limit() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyChromaPlanar(ImageProxy.PlaneProxy uPlane, ImageProxy.PlaneProxy vPlane,
|
||||
int width, int height, int ySize, byte[] out) {
|
||||
ByteBuffer uBuf = uPlane.getBuffer();
|
||||
ByteBuffer vBuf = vPlane.getBuffer();
|
||||
int uBase = uBuf.position();
|
||||
int vBase = vBuf.position();
|
||||
int uRowStride = uPlane.getRowStride();
|
||||
int vRowStride = vPlane.getRowStride();
|
||||
int uPixStride = uPlane.getPixelStride();
|
||||
int vPixStride = vPlane.getPixelStride();
|
||||
int pos = ySize;
|
||||
for (int row = 0; row < height / 2; row++) {
|
||||
int uRow = uBase + row * uRowStride;
|
||||
int vRow = vBase + row * vRowStride;
|
||||
for (int col = 0; col < width / 2; col++) {
|
||||
out[pos++] = vBuf.get(vRow + col * vPixStride);
|
||||
out[pos++] = uBuf.get(uRow + col * uPixStride);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the U/V planes alias a single interleaved VU buffer with no row
|
||||
* padding, i.e. shifting V by one byte yields exactly the U plane.
|
||||
*/
|
||||
private static boolean isVuInterleaved(ImageProxy.PlaneProxy uPlane, ImageProxy.PlaneProxy vPlane,
|
||||
int width, int height) {
|
||||
if (uPlane.getPixelStride() != 2 || vPlane.getPixelStride() != 2) {
|
||||
return false;
|
||||
}
|
||||
ByteBuffer uBuf = uPlane.getBuffer();
|
||||
ByteBuffer vBuf = vPlane.getBuffer();
|
||||
int vPos = vBuf.position();
|
||||
int uLimit = uBuf.limit();
|
||||
vBuf.position(vPos + 1);
|
||||
uBuf.limit(uLimit - 1);
|
||||
boolean interleaved = vBuf.remaining() == (width * height / 2 - 2)
|
||||
&& vBuf.compareTo(uBuf) == 0;
|
||||
vBuf.position(vPos);
|
||||
uBuf.limit(uLimit);
|
||||
return interleaved;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.inspireface_example.view;
|
||||
|
||||
import com.example.inspireface_example.R;
|
||||
|
||||
/** Dedicated pose/action-recognition screen. */
|
||||
public final class PoseActivity extends LivenessActivity {
|
||||
|
||||
@Override
|
||||
protected LivenessController.Mode initialMode() {
|
||||
return LivenessController.Mode.POSE;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int pageTitleRes() {
|
||||
return R.string.mode_pose;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.example.inspireface_example.view;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.RectF;
|
||||
import android.os.SystemClock;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.example.inspireface_example.R;
|
||||
import com.example.inspireface_example.face.FaceRecord;
|
||||
import com.example.inspireface_example.face.FaceRepository;
|
||||
import com.insightface.sdk.inspireface.InspireFace;
|
||||
import com.insightface.sdk.inspireface.base.FaceFeature;
|
||||
import com.insightface.sdk.inspireface.base.FaceRect;
|
||||
import com.insightface.sdk.inspireface.base.ImageStream;
|
||||
import com.insightface.sdk.inspireface.base.MultipleFaceData;
|
||||
import com.insightface.sdk.inspireface.base.Session;
|
||||
|
||||
/** Tracks only SDK face 0 and searches it after roughly one stable second. */
|
||||
public final class RecognitionFaceAnalyzer extends UprightFaceCameraAnalyzer {
|
||||
|
||||
public enum State {
|
||||
NO_FACE, MOVE_CLOSER, HOLD_STILL, SEARCHING, MATCHED, NO_MATCH, EMPTY_LIBRARY
|
||||
}
|
||||
|
||||
public interface Listener {
|
||||
/** Called on the camera analysis executor. */
|
||||
void onState(State state, @Nullable FaceRecord record,
|
||||
float confidence, float threshold);
|
||||
|
||||
void onSessionError();
|
||||
}
|
||||
|
||||
private static final long STABLE_RECOGNITION_MS = 1_000L;
|
||||
private static final float MIN_FACE_WIDTH_RATIO = 0.12f;
|
||||
|
||||
private final FaceOverlayView overlay;
|
||||
private final FaceRepository repository;
|
||||
private final boolean libraryEmpty;
|
||||
private final Listener listener;
|
||||
private final FaceStabilityGate stabilityGate =
|
||||
new FaceStabilityGate(STABLE_RECOGNITION_MS, 1L);
|
||||
private final int waitingColor;
|
||||
private final int matchColor;
|
||||
private final int noMatchColor;
|
||||
private final int warningColor;
|
||||
|
||||
private volatile boolean mirrored = true;
|
||||
private volatile boolean resetRequested;
|
||||
private boolean recognizedStableRun;
|
||||
private State lastState;
|
||||
private int currentBoxColor;
|
||||
|
||||
public RecognitionFaceAnalyzer(Context context, FaceOverlayView overlay,
|
||||
FaceRepository repository, boolean libraryEmpty,
|
||||
Listener listener) {
|
||||
this.overlay = overlay;
|
||||
this.repository = repository;
|
||||
this.libraryEmpty = libraryEmpty;
|
||||
this.listener = listener;
|
||||
waitingColor = ContextCompat.getColor(context, R.color.liveness_accent);
|
||||
matchColor = waitingColor;
|
||||
noMatchColor = ContextCompat.getColor(context, R.color.liveness_fail);
|
||||
warningColor = ContextCompat.getColor(context, R.color.liveness_warn);
|
||||
currentBoxColor = waitingColor;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void beforeFrame() {
|
||||
if (resetRequested) {
|
||||
resetRequested = false;
|
||||
resetRecognition();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Session createSession() {
|
||||
return FaceEngine.createVideoRecognitionSession();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onFaces(Session session, ImageStream stream,
|
||||
@Nullable MultipleFaceData faces, byte[] uprightNv21,
|
||||
int uprightWidth, int uprightHeight, long frameStart) {
|
||||
if (faces == null || faces.detectedNum == 0) {
|
||||
resetRecognition();
|
||||
overlay.submit(null);
|
||||
report(State.NO_FACE, null, Float.NaN, Float.NaN);
|
||||
return;
|
||||
}
|
||||
|
||||
FaceRect first = faces.rects[0];
|
||||
RectF face = new RectF(first.x, first.y,
|
||||
first.x + first.width, first.y + first.height);
|
||||
if (face.width() < uprightWidth * MIN_FACE_WIDTH_RATIO) {
|
||||
resetRecognition();
|
||||
currentBoxColor = warningColor;
|
||||
submitFace(face, uprightWidth, uprightHeight);
|
||||
report(State.MOVE_CLOSER, null, Float.NaN, Float.NaN);
|
||||
return;
|
||||
}
|
||||
|
||||
int trackId = faces.trackIds != null && faces.trackIds.length > 0
|
||||
? faces.trackIds[0] : 0;
|
||||
float stable = stabilityGate.update(trackId,
|
||||
face.left, face.top, face.right, face.bottom,
|
||||
SystemClock.elapsedRealtime());
|
||||
if (stable < 0f) {
|
||||
recognizedStableRun = false;
|
||||
currentBoxColor = waitingColor;
|
||||
submitFace(face, uprightWidth, uprightHeight);
|
||||
report(State.HOLD_STILL, null, Float.NaN, Float.NaN);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!recognizedStableRun) {
|
||||
recognizedStableRun = true;
|
||||
if (libraryEmpty) {
|
||||
currentBoxColor = warningColor;
|
||||
submitFace(face, uprightWidth, uprightHeight);
|
||||
report(State.EMPTY_LIBRARY, null, Float.NaN, Float.NaN);
|
||||
} else {
|
||||
recognize(session, stream, faces, face, uprightWidth, uprightHeight);
|
||||
}
|
||||
} else {
|
||||
submitFace(face, uprightWidth, uprightHeight);
|
||||
}
|
||||
}
|
||||
|
||||
private void recognize(Session session, ImageStream stream, MultipleFaceData faces,
|
||||
RectF face, int imageWidth, int imageHeight) {
|
||||
currentBoxColor = warningColor;
|
||||
submitFace(face, imageWidth, imageHeight);
|
||||
report(State.SEARCHING, null, Float.NaN, Float.NaN);
|
||||
FaceFeature feature = InspireFace.ExtractFaceFeature(
|
||||
session, stream, faces.tokens[0]);
|
||||
FaceRepository.SearchResult result = repository.search(feature);
|
||||
if (result.matched && result.record != null) {
|
||||
currentBoxColor = matchColor;
|
||||
report(State.MATCHED, result.record, result.confidence, result.threshold);
|
||||
} else {
|
||||
currentBoxColor = noMatchColor;
|
||||
report(State.NO_MATCH, null, result.confidence, result.threshold);
|
||||
}
|
||||
submitFace(face, imageWidth, imageHeight);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSessionError() {
|
||||
listener.onSessionError();
|
||||
}
|
||||
|
||||
public void setMirrored(boolean mirrored) {
|
||||
this.mirrored = mirrored;
|
||||
}
|
||||
|
||||
public void resetTracking() {
|
||||
resetRequested = true;
|
||||
overlay.submit(null);
|
||||
}
|
||||
|
||||
private void resetRecognition() {
|
||||
stabilityGate.reset();
|
||||
recognizedStableRun = false;
|
||||
currentBoxColor = waitingColor;
|
||||
}
|
||||
|
||||
private void submitFace(RectF face, int imageWidth, int imageHeight) {
|
||||
overlay.submit(new FaceOverlayView.Frame(
|
||||
imageWidth, imageHeight, mirrored,
|
||||
new RectF[]{new RectF(face)}, currentBoxColor));
|
||||
}
|
||||
|
||||
private void report(State state, @Nullable FaceRecord record,
|
||||
float confidence, float threshold) {
|
||||
if (state == lastState && state != State.MATCHED && state != State.NO_MATCH) {
|
||||
return;
|
||||
}
|
||||
lastState = state;
|
||||
listener.onState(state, record, confidence, threshold);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.example.inspireface_example.view;
|
||||
|
||||
import android.os.SystemClock;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.camera.core.ImageAnalysis;
|
||||
import androidx.camera.core.ImageProxy;
|
||||
|
||||
import com.insightface.sdk.inspireface.InspireFace;
|
||||
import com.insightface.sdk.inspireface.base.ImageStream;
|
||||
import com.insightface.sdk.inspireface.base.MultipleFaceData;
|
||||
import com.insightface.sdk.inspireface.base.Session;
|
||||
|
||||
/**
|
||||
* Shared camera-to-InspireFace pipeline. Subclasses receive an upright NV21 frame,
|
||||
* tracked faces and a live stream while this class owns conversion and native lifetimes.
|
||||
*/
|
||||
public abstract class UprightFaceCameraAnalyzer implements ImageAnalysis.Analyzer {
|
||||
|
||||
private final Nv21Converter converter = new Nv21Converter();
|
||||
private Session session;
|
||||
private boolean sessionFailed;
|
||||
private volatile boolean released;
|
||||
|
||||
@Override
|
||||
public final void analyze(@NonNull ImageProxy image) {
|
||||
if (released || sessionFailed || shouldSkipFrame()) {
|
||||
image.close();
|
||||
return;
|
||||
}
|
||||
beforeFrame();
|
||||
if (session == null) {
|
||||
session = createSession();
|
||||
if (session == null) {
|
||||
sessionFailed = true;
|
||||
image.close();
|
||||
onSessionError();
|
||||
return;
|
||||
}
|
||||
onSessionReady();
|
||||
}
|
||||
|
||||
long frameStart = SystemClock.elapsedRealtime();
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
int rotationDegrees = image.getImageInfo().getRotationDegrees();
|
||||
byte[] nv21;
|
||||
try {
|
||||
nv21 = converter.convert(image);
|
||||
} finally {
|
||||
image.close();
|
||||
}
|
||||
byte[] upright = converter.rotateUpright(nv21, width, height, rotationDegrees);
|
||||
boolean swapped = rotationDegrees == 90 || rotationDegrees == 270;
|
||||
int uprightWidth = swapped ? height : width;
|
||||
int uprightHeight = swapped ? width : height;
|
||||
|
||||
ImageStream stream = InspireFace.CreateImageStreamFromByteBuffer(
|
||||
upright, uprightWidth, uprightHeight,
|
||||
InspireFace.STREAM_YUV_NV21, InspireFace.CAMERA_ROTATION_0);
|
||||
if (stream == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
MultipleFaceData faces = InspireFace.ExecuteFaceTrack(session, stream);
|
||||
onFaces(session, stream, faces, upright,
|
||||
uprightWidth, uprightHeight, frameStart);
|
||||
} finally {
|
||||
InspireFace.ReleaseImageStream(stream);
|
||||
}
|
||||
}
|
||||
|
||||
/** Called before lazily creating the SDK session, on the analysis executor. */
|
||||
protected void beforeFrame() {
|
||||
}
|
||||
|
||||
/** Lets a completed state cheaply close subsequent camera frames. */
|
||||
protected boolean shouldSkipFrame() {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected abstract Session createSession();
|
||||
|
||||
/** Called once after lazy Session creation succeeds, on the analysis executor. */
|
||||
protected void onSessionReady() {
|
||||
}
|
||||
|
||||
protected abstract void onFaces(Session session, ImageStream stream,
|
||||
@Nullable MultipleFaceData faces, byte[] uprightNv21,
|
||||
int uprightWidth, int uprightHeight, long frameStart);
|
||||
|
||||
protected abstract void onSessionError();
|
||||
|
||||
/** Must be queued on the same executor used by CameraX analysis. */
|
||||
public final void release() {
|
||||
released = true;
|
||||
if (session != null) {
|
||||
FaceEngine.releaseSession(session);
|
||||
session = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package com.example.inspireface_example.widget;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.RectF;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.example.inspireface_example.R;
|
||||
|
||||
/**
|
||||
* Maps SDK face rectangles over a centerCrop ImageView and lets the user select one.
|
||||
* The selected face is green; other detected faces remain visible in white.
|
||||
*/
|
||||
public final class FaceImageOverlayView extends View {
|
||||
|
||||
public interface OnFaceSelectedListener {
|
||||
void onFaceSelected(int index);
|
||||
}
|
||||
|
||||
private final Paint outlinePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint facePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint badgePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint badgeTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final RectF mappedRect = new RectF();
|
||||
private final int accentColor;
|
||||
private int imageWidth;
|
||||
private int imageHeight;
|
||||
private RectF[] faceRects = new RectF[0];
|
||||
private int selectedIndex = -1;
|
||||
private int pressedIndex = -1;
|
||||
private OnFaceSelectedListener listener;
|
||||
|
||||
public FaceImageOverlayView(Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public FaceImageOverlayView(Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
outlinePaint.setStyle(Paint.Style.STROKE);
|
||||
outlinePaint.setStrokeWidth(dp(5f));
|
||||
outlinePaint.setColor(0xB3000000);
|
||||
|
||||
accentColor = ContextCompat.getColor(context, R.color.liveness_accent);
|
||||
facePaint.setStyle(Paint.Style.STROKE);
|
||||
facePaint.setStrokeCap(Paint.Cap.ROUND);
|
||||
|
||||
badgePaint.setStyle(Paint.Style.FILL);
|
||||
badgeTextPaint.setTextAlign(Paint.Align.CENTER);
|
||||
badgeTextPaint.setFakeBoldText(true);
|
||||
badgeTextPaint.setTextSize(dp(11f));
|
||||
setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
|
||||
}
|
||||
|
||||
public void showFace(int imageWidth, int imageHeight, @Nullable RectF faceRect) {
|
||||
showFaces(imageWidth, imageHeight,
|
||||
faceRect == null ? null : new RectF[]{faceRect}, faceRect == null ? -1 : 0);
|
||||
}
|
||||
|
||||
public void showFaces(int imageWidth, int imageHeight,
|
||||
@Nullable RectF[] faces, int selectedIndex) {
|
||||
this.imageWidth = imageWidth;
|
||||
this.imageHeight = imageHeight;
|
||||
if (faces == null || faces.length == 0) {
|
||||
faceRects = new RectF[0];
|
||||
this.selectedIndex = -1;
|
||||
} else {
|
||||
faceRects = new RectF[faces.length];
|
||||
for (int i = 0; i < faces.length; i++) {
|
||||
faceRects[i] = new RectF(faces[i]);
|
||||
}
|
||||
this.selectedIndex = selectedIndex >= 0 && selectedIndex < faces.length
|
||||
? selectedIndex : 0;
|
||||
}
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void setSelectedIndex(int index) {
|
||||
if (index >= 0 && index < faceRects.length && selectedIndex != index) {
|
||||
selectedIndex = index;
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public void setOnFaceSelectedListener(@Nullable OnFaceSelectedListener listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
public void clearFace() {
|
||||
faceRects = new RectF[0];
|
||||
selectedIndex = -1;
|
||||
pressedIndex = -1;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
super.onDraw(canvas);
|
||||
if (faceRects.length == 0 || imageWidth <= 0 || imageHeight <= 0
|
||||
|| getWidth() <= 0 || getHeight() <= 0) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < faceRects.length; i++) {
|
||||
mapRect(faceRects[i], mappedRect);
|
||||
boolean selected = i == selectedIndex;
|
||||
drawBrackets(canvas, mappedRect, outlinePaint);
|
||||
facePaint.setStrokeWidth(dp(selected ? 3f : 2f));
|
||||
facePaint.setColor(selected ? accentColor : 0xE6FFFFFF);
|
||||
drawBrackets(canvas, mappedRect, facePaint);
|
||||
drawBadge(canvas, mappedRect, i, selected);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent event) {
|
||||
switch (event.getActionMasked()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
pressedIndex = hitTest(event.getX(), event.getY());
|
||||
return pressedIndex >= 0;
|
||||
case MotionEvent.ACTION_UP:
|
||||
int releasedIndex = hitTest(event.getX(), event.getY());
|
||||
if (pressedIndex >= 0 && releasedIndex == pressedIndex) {
|
||||
selectedIndex = releasedIndex;
|
||||
invalidate();
|
||||
performClick();
|
||||
if (listener != null) {
|
||||
listener.onFaceSelected(releasedIndex);
|
||||
}
|
||||
}
|
||||
pressedIndex = -1;
|
||||
return true;
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
pressedIndex = -1;
|
||||
return true;
|
||||
default:
|
||||
return pressedIndex >= 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean performClick() {
|
||||
super.performClick();
|
||||
return true;
|
||||
}
|
||||
|
||||
private int hitTest(float x, float y) {
|
||||
int hit = -1;
|
||||
float smallestArea = Float.MAX_VALUE;
|
||||
for (int i = 0; i < faceRects.length; i++) {
|
||||
mapRect(faceRects[i], mappedRect);
|
||||
RectF touchTarget = new RectF(mappedRect);
|
||||
touchTarget.inset(-dp(8f), -dp(8f));
|
||||
float area = mappedRect.width() * mappedRect.height();
|
||||
if (touchTarget.contains(x, y) && area < smallestArea) {
|
||||
smallestArea = area;
|
||||
hit = i;
|
||||
}
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
private void mapRect(RectF source, RectF out) {
|
||||
// Must match android:scaleType="centerCrop" on the image directly underneath.
|
||||
float scale = Math.max((float) getWidth() / imageWidth,
|
||||
(float) getHeight() / imageHeight);
|
||||
float dx = (getWidth() - imageWidth * scale) / 2f;
|
||||
float dy = (getHeight() - imageHeight * scale) / 2f;
|
||||
out.set(source.left * scale + dx, source.top * scale + dy,
|
||||
source.right * scale + dx, source.bottom * scale + dy);
|
||||
}
|
||||
|
||||
private void drawBrackets(Canvas canvas, RectF face, Paint paint) {
|
||||
float len = Math.min(face.width(), face.height()) * 0.22f;
|
||||
float radius = Math.min(dp(6f), len * 0.45f);
|
||||
canvas.drawLine(face.left, face.top + len,
|
||||
face.left, face.top + radius, paint);
|
||||
canvas.drawLine(face.left + radius, face.top,
|
||||
face.left + len, face.top, paint);
|
||||
canvas.drawArc(face.left, face.top, face.left + 2f * radius,
|
||||
face.top + 2f * radius, 180f, 90f, false, paint);
|
||||
|
||||
canvas.drawLine(face.right - len, face.top,
|
||||
face.right - radius, face.top, paint);
|
||||
canvas.drawLine(face.right, face.top + radius,
|
||||
face.right, face.top + len, paint);
|
||||
canvas.drawArc(face.right - 2f * radius, face.top, face.right,
|
||||
face.top + 2f * radius, 270f, 90f, false, paint);
|
||||
|
||||
canvas.drawLine(face.left, face.bottom - len,
|
||||
face.left, face.bottom - radius, paint);
|
||||
canvas.drawLine(face.left + radius, face.bottom,
|
||||
face.left + len, face.bottom, paint);
|
||||
canvas.drawArc(face.left, face.bottom - 2f * radius,
|
||||
face.left + 2f * radius, face.bottom,
|
||||
90f, 90f, false, paint);
|
||||
|
||||
canvas.drawLine(face.right - len, face.bottom,
|
||||
face.right - radius, face.bottom, paint);
|
||||
canvas.drawLine(face.right, face.bottom - radius,
|
||||
face.right, face.bottom - len, paint);
|
||||
canvas.drawArc(face.right - 2f * radius, face.bottom - 2f * radius,
|
||||
face.right, face.bottom, 0f, 90f, false, paint);
|
||||
}
|
||||
|
||||
private void drawBadge(Canvas canvas, RectF face, int index, boolean selected) {
|
||||
float badgeRadius = dp(10f);
|
||||
float cx = face.left + badgeRadius;
|
||||
float cy = face.top + badgeRadius;
|
||||
cx = Math.max(badgeRadius + dp(2f), cx);
|
||||
cy = Math.max(badgeRadius + dp(2f), cy);
|
||||
badgePaint.setColor(selected ? accentColor : 0xE6FFFFFF);
|
||||
badgeTextPaint.setColor(0xFF07110F);
|
||||
canvas.drawCircle(cx, cy, badgeRadius, badgePaint);
|
||||
Paint.FontMetrics fm = badgeTextPaint.getFontMetrics();
|
||||
float baseline = cy - (fm.ascent + fm.descent) / 2f;
|
||||
canvas.drawText(String.valueOf(index + 1), cx, baseline, badgeTextPaint);
|
||||
}
|
||||
|
||||
private float dp(float value) {
|
||||
return value * getResources().getDisplayMetrics().density;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.example.inspireface_example.widget;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.example.inspireface_example.R;
|
||||
import com.insightface.sdk.inspireface.base.Point2f;
|
||||
|
||||
/** Draws one selected face's dense landmarks over a centerCrop ImageView. */
|
||||
public final class FaceLandmarkOverlayView extends View {
|
||||
|
||||
private final Paint outlinePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint pointPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private float[] points = new float[0];
|
||||
private int pointCount;
|
||||
private int imageWidth;
|
||||
private int imageHeight;
|
||||
|
||||
public FaceLandmarkOverlayView(Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public FaceLandmarkOverlayView(Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
outlinePaint.setStyle(Paint.Style.FILL);
|
||||
outlinePaint.setColor(0xCC000000);
|
||||
pointPaint.setStyle(Paint.Style.FILL);
|
||||
pointPaint.setColor(ContextCompat.getColor(context, R.color.liveness_accent));
|
||||
setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
|
||||
}
|
||||
|
||||
public void showPoints(int imageWidth, int imageHeight, @Nullable Point2f[] landmarks) {
|
||||
showPoints(imageWidth, imageHeight, landmarks, 0f, 0f);
|
||||
}
|
||||
|
||||
public void showPoints(int imageWidth, int imageHeight,
|
||||
@Nullable Point2f[] landmarks,
|
||||
float offsetX, float offsetY) {
|
||||
this.imageWidth = imageWidth;
|
||||
this.imageHeight = imageHeight;
|
||||
if (landmarks == null || landmarks.length == 0) {
|
||||
points = new float[0];
|
||||
pointCount = 0;
|
||||
} else {
|
||||
points = new float[landmarks.length * 2];
|
||||
pointCount = 0;
|
||||
for (Point2f landmark : landmarks) {
|
||||
if (landmark == null) {
|
||||
continue;
|
||||
}
|
||||
points[pointCount * 2] = landmark.x - offsetX;
|
||||
points[pointCount * 2 + 1] = landmark.y - offsetY;
|
||||
pointCount++;
|
||||
}
|
||||
}
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void clearPoints() {
|
||||
points = new float[0];
|
||||
pointCount = 0;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
super.onDraw(canvas);
|
||||
if (pointCount == 0 || imageWidth <= 0 || imageHeight <= 0
|
||||
|| getWidth() <= 0 || getHeight() <= 0) {
|
||||
return;
|
||||
}
|
||||
float scale = Math.max((float) getWidth() / imageWidth,
|
||||
(float) getHeight() / imageHeight);
|
||||
float dx = (getWidth() - imageWidth * scale) / 2f;
|
||||
float dy = (getHeight() - imageHeight * scale) / 2f;
|
||||
float outlineRadius = dp(2.8f);
|
||||
float pointRadius = dp(1.6f);
|
||||
for (int i = 0; i < pointCount; i++) {
|
||||
float x = points[i * 2] * scale + dx;
|
||||
float y = points[i * 2 + 1] * scale + dy;
|
||||
canvas.drawCircle(x, y, outlineRadius, outlinePaint);
|
||||
canvas.drawCircle(x, y, pointRadius, pointPaint);
|
||||
}
|
||||
}
|
||||
|
||||
private float dp(float value) {
|
||||
return value * getResources().getDisplayMetrics().density;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.example.inspireface_example.widget;
|
||||
|
||||
import android.animation.ValueAnimator;
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.Typeface;
|
||||
import android.util.AttributeSet;
|
||||
import android.util.TypedValue;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.example.inspireface_example.R;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/** Circular similarity result with an animated progress ring and centered verdict. */
|
||||
public final class SimilarityGaugeView extends View {
|
||||
|
||||
private final Paint trackPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint progressPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint valuePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final RectF arcBounds = new RectF();
|
||||
|
||||
private final int accentColor;
|
||||
private final int failColor;
|
||||
private float displayedPercent;
|
||||
private boolean hasResult;
|
||||
private boolean matched;
|
||||
private String label = "";
|
||||
private ValueAnimator animator;
|
||||
|
||||
public SimilarityGaugeView(Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public SimilarityGaugeView(Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
accentColor = ContextCompat.getColor(context, R.color.liveness_accent);
|
||||
failColor = ContextCompat.getColor(context, R.color.liveness_fail);
|
||||
|
||||
trackPaint.setStyle(Paint.Style.STROKE);
|
||||
trackPaint.setStrokeCap(Paint.Cap.ROUND);
|
||||
trackPaint.setColor(ContextCompat.getColor(context, R.color.compare_ring_track));
|
||||
|
||||
progressPaint.setStyle(Paint.Style.STROKE);
|
||||
progressPaint.setStrokeCap(Paint.Cap.ROUND);
|
||||
|
||||
valuePaint.setTextAlign(Paint.Align.CENTER);
|
||||
valuePaint.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD));
|
||||
valuePaint.setColor(ContextCompat.getColor(context, R.color.white));
|
||||
|
||||
labelPaint.setTextAlign(Paint.Align.CENTER);
|
||||
labelPaint.setColor(ContextCompat.getColor(context, R.color.home_text_secondary));
|
||||
}
|
||||
|
||||
public void showMessage(String message) {
|
||||
cancelAnimator();
|
||||
hasResult = false;
|
||||
displayedPercent = 0f;
|
||||
label = message;
|
||||
setContentDescription(message);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void showResult(float percent, boolean isMatched, String verdict) {
|
||||
cancelAnimator();
|
||||
hasResult = true;
|
||||
matched = isMatched;
|
||||
label = verdict;
|
||||
float target = Math.max(0f, Math.min(100f, percent));
|
||||
animator = ValueAnimator.ofFloat(0f, target);
|
||||
animator.setDuration(550L);
|
||||
animator.addUpdateListener(animation -> {
|
||||
displayedPercent = (float) animation.getAnimatedValue();
|
||||
invalidate();
|
||||
});
|
||||
animator.start();
|
||||
setContentDescription(String.format(Locale.US, "%.1f%%, %s", target, verdict));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
super.onDraw(canvas);
|
||||
float stroke = dp(11f);
|
||||
trackPaint.setStrokeWidth(stroke);
|
||||
progressPaint.setStrokeWidth(stroke);
|
||||
progressPaint.setColor(matched ? accentColor : failColor);
|
||||
|
||||
float cx = getWidth() / 2f;
|
||||
float cy = getHeight() / 2f;
|
||||
float radius = Math.min(getWidth(), getHeight()) / 2f - stroke - dp(4f);
|
||||
arcBounds.set(cx - radius, cy - radius, cx + radius, cy + radius);
|
||||
canvas.drawArc(arcBounds, -90f, 360f, false, trackPaint);
|
||||
if (hasResult && displayedPercent > 0f) {
|
||||
canvas.drawArc(arcBounds, -90f, displayedPercent * 3.6f, false, progressPaint);
|
||||
}
|
||||
|
||||
valuePaint.setTextSize(sp(31f));
|
||||
labelPaint.setTextSize(sp(13f));
|
||||
String value = hasResult ? String.format(Locale.US, "%.1f%%", displayedPercent) : "—";
|
||||
canvas.drawText(value, cx, cy + dp(3f), valuePaint);
|
||||
canvas.drawText(label, cx, cy + dp(30f), labelPaint);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
cancelAnimator();
|
||||
super.onDetachedFromWindow();
|
||||
}
|
||||
|
||||
private void cancelAnimator() {
|
||||
if (animator != null) {
|
||||
animator.cancel();
|
||||
animator = null;
|
||||
}
|
||||
}
|
||||
|
||||
private float dp(float value) {
|
||||
return value * getResources().getDisplayMetrics().density;
|
||||
}
|
||||
|
||||
private float sp(float value) {
|
||||
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, value,
|
||||
getResources().getDisplayMetrics());
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/liveness_chip_bg" />
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<group android:translateY="960">
|
||||
<path
|
||||
android:fillColor="@color/home_tile_text_dark"
|
||||
android:pathData="M520,-40v-240l-84,-80 -40,176 -276,-56 16,-80 192,40 64,-324 -72,28v136h-80v-188l158,-68q35,-15 51.5,-19.5T480,-720q21,0 39,11t29,29l40,64q26,42 70.5,69T760,-520v80q-66,0 -123.5,-27.5T540,-540l-24,120 84,80v300h-80ZM483.5,-763.5Q460,-787 460,-820t23.5,-56.5Q507,-900 540,-900t56.5,23.5Q620,-853 620,-820t-23.5,56.5Q573,-740 540,-740t-56.5,-23.5Z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<group android:translateY="960">
|
||||
<path
|
||||
android:fillColor="@color/home_tile_text_dark"
|
||||
android:pathData="M480,-880q39,0 80,8.5t80,25.5l-110,51q-12,-2 -24.5,-3.5T480,-800q-14,0 -27,1t-27,4q42,70 114,112.5T700,-640h14l35,77q-90,11 -188,-21T390,-708q-35,85 -95.5,140T160,-486q0,139 93.5,232.5T480,-160q136,0 229,-96t91,-224q0,-14 -1,-24.5t-3,-25.5l50,-111q18,42 26,81t8,80q0,80 -30.5,152.5t-84,127.5Q712,-145 639,-112.5T480,-80q-82,0 -155,-31.5t-127.5,-86Q143,-252 111.5,-325T80,-480q0,-86 33,-159.5t88.5,-127Q257,-820 329,-850t151,-30ZM395.5,-475.5Q410,-461 410,-440t-14.5,35.5Q381,-390 360,-390t-35.5,-14.5Q310,-419 310,-440t14.5,-35.5Q339,-490 360,-490t35.5,14.5ZM573,-720ZM635.5,-475.5Q650,-461 650,-440t-14.5,35.5Q621,-390 600,-390t-35.5,-14.5Q550,-419 550,-440t14.5,-35.5Q579,-490 600,-490t35.5,14.5ZM780,-920l44,96 96,44 -96,44 -44,96 -44,-96 -96,-44 96,-44 44,-96ZM177,-581q51,-29 89,-75t57,-103q-51,29 -89,75t-57,103Zm146,-178Z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<group android:translateY="960">
|
||||
<path
|
||||
android:fillColor="@color/home_tile_text_dark"
|
||||
android:pathData="M320,-160l-56,-57 103,-103H80v-80h287L264,-503l56,-57 200,200 -200,200ZM640,-400L440,-600l200,-200 56,57 -103,103h287v80H593l103,103 -56,57Z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<group android:translateY="960">
|
||||
<path
|
||||
android:fillColor="@color/home_tile_text_dark"
|
||||
android:pathData="M287,-527q-47,-47 -47,-113t47,-113q47,-47 113,-47t113,47q47,47 47,113t-47,113q-47,47 -113,47t-113,-47ZM80,-160v-112q0,-33 17,-62t47,-44q51,-26 115,-44t141,-18h14q6,0 12,2 -8,18 -13.5,37.5T404,-360h-4q-71,0 -127.5,18T180,-306q-9,5 -14.5,14t-5.5,20v32h252q6,21 16,41.5t22,38.5H80ZM640,-120l-12,-60q-12,-5 -22.5,-10.5T584,-204l-58,18 -40,-68 46,-40q-2,-14 -2,-26t2,-26l-46,-40 40,-68 58,18q11,-8 21.5,-13.5T628,-460l12,-60h80l12,60q12,5 22.5,11t21.5,15l58,-20 40,70 -46,40q2,12 2,25t-2,25l46,40 -40,68 -58,-18q-11,8 -21.5,13.5T732,-180l-12,60h-80ZM736.5,-263.5Q760,-287 760,-320t-23.5,-56.5Q713,-400 680,-400t-56.5,23.5Q600,-353 600,-320t23.5,56.5Q647,-240 680,-240t56.5,-23.5ZM456.5,-583.5Q480,-607 480,-640t-23.5,-56.5Q433,-720 400,-720t-56.5,23.5Q320,-673 320,-640t23.5,56.5Q367,-560 400,-560t56.5,-23.5ZM400,-640ZM412,-240Z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<group android:translateY="960">
|
||||
<path
|
||||
android:fillColor="@color/home_tile_text_dark"
|
||||
android:pathData="M423.5,-743.5Q400,-767 400,-800t23.5,-56.5Q447,-880 480,-880t56.5,23.5Q560,-833 560,-800t-23.5,56.5Q513,-720 480,-720t-56.5,-23.5ZM360,-80v-520q-60,-5 -122,-15t-118,-25l20,-80q78,21 166,30.5t174,9.5q86,0 174,-9.5T820,-720l20,80q-56,15 -118,25t-122,15v520h-80v-240h-80v240h-80Z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<group android:translateY="960">
|
||||
<path
|
||||
android:fillColor="@color/home_tile_text_dark"
|
||||
android:pathData="M440,-480q-66,0 -113,-47t-47,-113q0,-66 47,-113t113,-47q66,0 113,47t47,113q0,66 -47,113t-113,47ZM440,-560q33,0 56.5,-23.5T520,-640q0,-33 -23.5,-56.5T440,-720q-33,0 -56.5,23.5T360,-640q0,33 23.5,56.5T440,-560ZM884,-20L756,-148q-21,12 -45,20t-51,8q-75,0 -127.5,-52.5T480,-300q0,-75 52.5,-127.5T660,-480q75,0 127.5,52.5T840,-300q0,27 -8,51t-20,45L940,-76l-56,56ZM731,-229q29,-29 29,-71t-29,-71q-29,-29 -71,-29t-71,29q-29,29 -29,71t29,71q29,29 71,29t71,-29ZM120,-160v-111q0,-34 17,-63t47,-44q51,-26 115,-44t142,-18q-12,18 -20.5,38.5T407,-359q-60,5 -107,20.5T221,-306q-10,5 -15.5,14.5T200,-271v31h207q5,22 13.5,42t20.5,38H120ZM440,-640ZM407,-240Z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<group android:translateY="960">
|
||||
<path
|
||||
android:fillColor="@color/home_tile_text_dark"
|
||||
android:pathData="M380.5,-480.5Q340,-521 340,-580t40.5,-99.5Q421,-720 480,-720t99.5,40.5Q620,-639 620,-580t-40.5,99.5Q539,-440 480,-440t-99.5,-40.5ZM523,-537q17,-17 17,-43t-17,-43q-17,-17 -43,-17t-43,17q-17,17 -17,43t17,43q17,17 43,17t43,-17ZM480,-80q-139,-35 -229.5,-159.5T160,-516v-244l320,-120 320,120v244q0,152 -90.5,276.5T480,-80ZM480,-480ZM480,-795l-240,90v189q0,54 15,105t41,96q42,-21 88,-33t96,-12q50,0 96,12t88,33q26,-45 41,-96t15,-105v-189l-240,-90ZM410,-272q-34,8 -65,22 29,30 63,52t72,34q38,-12 72,-34t63,-52q-31,-14 -65,-22t-70,-8q-36,0 -70,8Z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<group android:translateY="960">
|
||||
<path
|
||||
android:fillColor="@color/home_tile_text_dark"
|
||||
android:pathData="M367,-367q-47,-47 -47,-113t47,-113q47,-47 113,-47t113,47q47,47 47,113t-47,113q-47,47 -113,47t-113,-47ZM536.5,-423.5Q560,-447 560,-480t-23.5,-56.5Q513,-560 480,-560t-56.5,23.5Q400,-513 400,-480t23.5,56.5Q447,-400 480,-400t56.5,-23.5ZM480,-480ZM200,-120q-33,0 -56.5,-23.5T120,-200v-160h80v160h160v80H200ZM600,-120v-80h160v-160h80v160q0,33 -23.5,56.5T760,-120H600ZM120,-600v-160q0,-33 23.5,-56.5T200,-840h160v80H200v160h-80ZM760,-600v-160H600v-80h160q33,0 56.5,23.5T840,-760v160h-80Z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -1,170 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#0D53B4" />
|
||||
</shape>
|
||||
|
||||
@@ -1,30 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:antialias="false"
|
||||
android:filter="false"
|
||||
android:gravity="fill"
|
||||
android:src="@drawable/ic_launcher_inspireface_foreground" />
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.core.widget.NestedScrollView 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:id="@+id/attributeRoot"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:fillViewport="true"
|
||||
android:paddingStart="20dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingEnd="20dp"
|
||||
android:paddingBottom="28dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btnBack"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:text="@string/back"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:maxLines="1"
|
||||
android:text="@string/face_attribute_title"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="17sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/currentModel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:gravity="center"
|
||||
android:maxLines="1"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingEnd="10dp"
|
||||
android:paddingBottom="5dp"
|
||||
android:textColor="@color/liveness_accent"
|
||||
android:textSize="11sp"
|
||||
tools:text="Model · Megatron" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="@string/face_attribute_heading"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:text="@string/face_attribute_hint"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/attributeImageCard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="340dp"
|
||||
android:layout_marginTop="18dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
app:cardBackgroundColor="@color/home_surface"
|
||||
app:cardCornerRadius="20dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeColor="@color/home_stroke"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/attributeImage"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@string/attribute_photo"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<com.example.inspireface_example.widget.FaceImageOverlayView
|
||||
android:id="@+id/attributeFaceOverlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/attributeImagePlaceholder"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:padding="24dp"
|
||||
android:text="@string/attribute_choose_photo_hint"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/attributeMagnifierCard"
|
||||
android:layout_width="132dp"
|
||||
android:layout_height="132dp"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:layout_marginBottom="46dp"
|
||||
android:visibility="gone"
|
||||
app:cardBackgroundColor="@color/home_surface"
|
||||
app:cardCornerRadius="14dp"
|
||||
app:cardElevation="5dp"
|
||||
app:strokeColor="@color/liveness_accent"
|
||||
app:strokeWidth="2dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/attributeMagnifierImage"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@string/attribute_selected_face_crop"
|
||||
android:scaleType="centerCrop" />
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/attributeImageStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:background="@color/compare_status_scrim"
|
||||
android:gravity="center"
|
||||
android:padding="8dp"
|
||||
android:text="@string/no_image_selected"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="12sp" />
|
||||
</FrameLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnChooseAttributePhoto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:enabled="false"
|
||||
android:text="@string/attribute_choose_photo"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
app:backgroundTint="@color/liveness_accent" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/attribute_multi_face_hint"
|
||||
android:textColor="@color/home_text_muted"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
app:cardBackgroundColor="@color/home_surface"
|
||||
app:cardCornerRadius="20dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeColor="@color/home_stroke"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/attribute_result_title"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/attributeResultPlaceholder"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:gravity="center"
|
||||
android:paddingTop="20dp"
|
||||
android:paddingBottom="20dp"
|
||||
android:text="@string/attribute_result_empty"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/attributeResultValues"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout style="@style/AttributeResultRow">
|
||||
<TextView style="@style/AttributeResultLabel" android:text="@string/attribute_mask" />
|
||||
<TextView android:id="@+id/attributeMaskValue" style="@style/AttributeResultValue" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout style="@style/AttributeResultRow">
|
||||
<TextView style="@style/AttributeResultLabel" android:text="@string/attribute_age" />
|
||||
<TextView android:id="@+id/attributeAgeValue" style="@style/AttributeResultValue" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout style="@style/AttributeResultRow">
|
||||
<TextView style="@style/AttributeResultLabel" android:text="@string/attribute_quality" />
|
||||
<TextView android:id="@+id/attributeQualityValue" style="@style/AttributeResultValue" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout style="@style/AttributeResultRow">
|
||||
<TextView style="@style/AttributeResultLabel" android:text="@string/attribute_expression" />
|
||||
<TextView android:id="@+id/attributeExpressionValue" style="@style/AttributeResultValue" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout style="@style/AttributeResultRow">
|
||||
<TextView style="@style/AttributeResultLabel" android:text="@string/attribute_race" />
|
||||
<TextView android:id="@+id/attributeRaceValue" style="@style/AttributeResultValue" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout style="@style/AttributeResultRow">
|
||||
<TextView style="@style/AttributeResultLabel" android:text="@string/attribute_gender" />
|
||||
<TextView android:id="@+id/attributeGenderValue" style="@style/AttributeResultValue" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout style="@style/AttributeResultRow">
|
||||
<TextView style="@style/AttributeResultLabel" android:text="@string/attribute_left_eye" />
|
||||
<TextView android:id="@+id/attributeLeftEyeValue" style="@style/AttributeResultValue" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout style="@style/AttributeResultRow">
|
||||
<TextView style="@style/AttributeResultLabel" android:text="@string/attribute_right_eye" />
|
||||
<TextView android:id="@+id/attributeRightEyeValue" style="@style/AttributeResultValue" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
@@ -0,0 +1,146 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout 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:id="@+id/captureRoot"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:keepScreenOn="true">
|
||||
|
||||
<androidx.camera.view.PreviewView
|
||||
android:id="@+id/capturePreview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<com.example.inspireface_example.view.FaceOverlayView
|
||||
android:id="@+id/captureFaceOverlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/captureTopBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btnBack"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:text="@string/back"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:maxLines="1"
|
||||
android:text="@string/face_capture_title"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/currentModel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:gravity="center"
|
||||
android:maxLines="1"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingEnd="10dp"
|
||||
android:paddingBottom="5dp"
|
||||
android:textColor="@color/liveness_accent"
|
||||
android:textSize="11sp"
|
||||
tools:text="Model · Megatron" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btnFlipCaptureCamera"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:paddingStart="14dp"
|
||||
android:paddingTop="7dp"
|
||||
android:paddingEnd="14dp"
|
||||
android:paddingBottom="7dp"
|
||||
android:text="@string/flip_camera"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/capturePromptCard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="24dp"
|
||||
app:cardBackgroundColor="@color/liveness_scrim"
|
||||
app:cardCornerRadius="20dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="0dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/capturePromptTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:text="@string/msg_initializing"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/capturePromptSubtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="7dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/capture_first_face_hint"
|
||||
android:textColor="#B3FFFFFF"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnCameraPermissionAction"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:text="@string/camera_permission_try_again"
|
||||
android:textColor="@color/black"
|
||||
android:visibility="gone"
|
||||
app:backgroundTint="@color/liveness_accent" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,372 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.core.widget.NestedScrollView 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:id="@+id/compareRoot"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:fillViewport="true"
|
||||
android:paddingStart="20dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingEnd="20dp"
|
||||
android:paddingBottom="28dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btnBack"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:text="@string/back"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:maxLines="1"
|
||||
android:text="@string/face_compare_title"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="17sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/currentModel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:gravity="center"
|
||||
android:maxLines="1"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingEnd="10dp"
|
||||
android:paddingBottom="5dp"
|
||||
android:textColor="@color/liveness_accent"
|
||||
android:textSize="11sp"
|
||||
tools:text="Model · Megatron" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="26dp"
|
||||
android:text="@string/face_compare_heading"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="27sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="7dp"
|
||||
android:lineSpacingExtra="2dp"
|
||||
android:text="@string/face_compare_hint"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardImageA"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
app:cardBackgroundColor="@color/home_surface"
|
||||
app:cardCornerRadius="20dp"
|
||||
app:cardElevation="0dp"
|
||||
app:layout_constraintDimensionRatio="1:1"
|
||||
app:layout_constraintEnd_toStartOf="@id/cardImageB"
|
||||
app:layout_constraintHorizontal_chainStyle="spread"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:strokeColor="@color/home_stroke"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageA"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@string/image_a"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<com.example.inspireface_example.widget.FaceImageOverlayView
|
||||
android:id="@+id/faceOverlayA"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/placeholderA"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/add_symbol"
|
||||
android:textColor="@color/liveness_accent"
|
||||
android:textSize="34sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="@string/upload_image_a"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusA"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:background="@color/compare_status_scrim"
|
||||
android:gravity="center"
|
||||
android:maxLines="1"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingTop="7dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingBottom="7dp"
|
||||
android:text="@string/image_empty"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardImageB"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginStart="12dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
app:cardBackgroundColor="@color/home_surface"
|
||||
app:cardCornerRadius="20dp"
|
||||
app:cardElevation="0dp"
|
||||
app:layout_constraintDimensionRatio="1:1"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/cardImageA"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:strokeColor="@color/home_stroke"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageB"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@string/image_b"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<com.example.inspireface_example.widget.FaceImageOverlayView
|
||||
android:id="@+id/faceOverlayB"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/placeholderB"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/add_symbol"
|
||||
android:textColor="@color/liveness_accent"
|
||||
android:textSize="34sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="@string/upload_image_b"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusB"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:background="@color/compare_status_scrim"
|
||||
android:gravity="center"
|
||||
android:maxLines="1"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingTop="7dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingBottom="7dp"
|
||||
android:text="@string/image_empty"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/similarity_title"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="17sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="200dp"
|
||||
android:layout_marginTop="10dp">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/compareCropCardA"
|
||||
android:layout_width="58dp"
|
||||
android:layout_height="58dp"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:visibility="invisible"
|
||||
app:cardBackgroundColor="@color/home_surface"
|
||||
app:cardCornerRadius="14dp"
|
||||
app:cardElevation="3dp"
|
||||
app:layout_constraintBottom_toBottomOf="@id/similarityGauge"
|
||||
app:layout_constraintEnd_toStartOf="@id/similarityGauge"
|
||||
app:layout_constraintHorizontal_chainStyle="packed"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/similarityGauge"
|
||||
app:strokeColor="@color/liveness_accent"
|
||||
app:strokeWidth="2dp"
|
||||
tools:visibility="visible">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/compareCropA"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@string/compare_selected_face_a"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:background="@color/compare_status_scrim"
|
||||
android:gravity="center"
|
||||
android:text="@string/compare_face_a_badge"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
</FrameLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.example.inspireface_example.widget.SimilarityGaugeView
|
||||
android:id="@+id/similarityGauge"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="200dp"
|
||||
app:layout_constraintEnd_toStartOf="@id/compareCropCardB"
|
||||
app:layout_constraintStart_toEndOf="@id/compareCropCardA"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintWidth_max="200dp" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/compareCropCardB"
|
||||
android:layout_width="58dp"
|
||||
android:layout_height="58dp"
|
||||
android:layout_marginStart="6dp"
|
||||
android:visibility="invisible"
|
||||
app:cardBackgroundColor="@color/home_surface"
|
||||
app:cardCornerRadius="14dp"
|
||||
app:cardElevation="3dp"
|
||||
app:layout_constraintBottom_toBottomOf="@id/similarityGauge"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/similarityGauge"
|
||||
app:layout_constraintTop_toTopOf="@id/similarityGauge"
|
||||
app:strokeColor="@color/liveness_accent"
|
||||
app:strokeWidth="2dp"
|
||||
tools:visibility="visible">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/compareCropB"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@string/compare_selected_face_b"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:background="@color/compare_status_scrim"
|
||||
android:gravity="center"
|
||||
android:text="@string/compare_face_b_badge"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
</FrameLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/comparisonDetails"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:minHeight="20dp"
|
||||
android:textColor="@color/home_text_muted"
|
||||
android:textSize="11sp"
|
||||
tools:text="cosine confidence 0.82 · threshold 0.45" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/on_device_privacy"
|
||||
android:textColor="@color/home_text_muted"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
@@ -0,0 +1,628 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.core.widget.NestedScrollView 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:id="@+id/detectionRoot"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:fillViewport="true"
|
||||
android:paddingStart="20dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingEnd="20dp"
|
||||
android:paddingBottom="28dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btnBack"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:text="@string/back"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:maxLines="1"
|
||||
android:text="@string/face_detection_title"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="17sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/currentModel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:gravity="center"
|
||||
android:maxLines="1"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingEnd="10dp"
|
||||
android:paddingBottom="5dp"
|
||||
android:textColor="@color/liveness_accent"
|
||||
android:textSize="11sp"
|
||||
tools:text="Model · Megatron" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="@string/face_detection_heading"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:text="@string/face_detection_hint"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<com.google.android.material.tabs.TabLayout
|
||||
android:id="@+id/detectionTabs"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
app:tabIndicatorColor="@color/liveness_accent"
|
||||
app:tabSelectedTextColor="@color/liveness_accent"
|
||||
app:tabTextColor="@color/home_text_secondary">
|
||||
|
||||
<com.google.android.material.tabs.TabItem
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/detection_tab_image" />
|
||||
|
||||
<com.google.android.material.tabs.TabItem
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/detection_tab_tracking" />
|
||||
</com.google.android.material.tabs.TabLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/imageDetectionPanel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
app:cardBackgroundColor="@color/home_surface"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeColor="@color/home_stroke"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/switchDenseLandmarks"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="true"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:text="@string/detection_show_dense_landmarks"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="14sp" />
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/detectionImageCard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="360dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
app:cardBackgroundColor="@color/home_surface"
|
||||
app:cardCornerRadius="20dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeColor="@color/home_stroke"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/detectionImage"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@string/detection_photo"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<com.example.inspireface_example.widget.FaceImageOverlayView
|
||||
android:id="@+id/detectionFaceOverlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<com.example.inspireface_example.widget.FaceLandmarkOverlayView
|
||||
android:id="@+id/detectionLandmarkOverlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/detectionImagePlaceholder"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:padding="24dp"
|
||||
android:text="@string/detection_choose_photo_hint"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/landmarkMagnifierCard"
|
||||
android:layout_width="148dp"
|
||||
android:layout_height="148dp"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:layout_marginBottom="48dp"
|
||||
android:visibility="gone"
|
||||
app:cardBackgroundColor="@color/home_surface"
|
||||
app:cardCornerRadius="14dp"
|
||||
app:cardElevation="5dp"
|
||||
app:strokeColor="@color/liveness_accent"
|
||||
app:strokeWidth="2dp">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/landmarkMagnifierImage"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@string/detection_landmark_magnifier"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<com.example.inspireface_example.widget.FaceLandmarkOverlayView
|
||||
android:id="@+id/landmarkMagnifierOverlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/landmarkMagnifierBadge"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|start"
|
||||
android:layout_margin="7dp"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:paddingStart="7dp"
|
||||
android:paddingTop="3dp"
|
||||
android:paddingEnd="7dp"
|
||||
android:paddingBottom="3dp"
|
||||
android:text="@string/detection_106_badge"
|
||||
android:textColor="@color/liveness_accent"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
</FrameLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/detectionImageStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:background="@color/compare_status_scrim"
|
||||
android:gravity="center"
|
||||
android:padding="8dp"
|
||||
android:text="@string/no_image_selected"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="12sp" />
|
||||
</FrameLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnChooseDetectionPhoto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:enabled="false"
|
||||
android:text="@string/detection_choose_photo"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
app:backgroundTint="@color/liveness_accent" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="7dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/detection_multi_face_hint"
|
||||
android:textColor="@color/home_text_muted"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/landmarkDisplayHint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/detection_landmark_behavior_hint"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
app:cardBackgroundColor="@color/home_surface"
|
||||
app:cardCornerRadius="18dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeColor="@color/home_stroke"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/sessionSettingsHeader"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="64dp"
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/recognition_session_parameters"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/sessionStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="11sp"
|
||||
tools:text="Session · 640 px · max 10 · min 24 px" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/sessionSettingsToggleText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:text="@string/recognition_settings_expand"
|
||||
android:textColor="@color/liveness_accent"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/sessionSettingsContent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/recognition_session_parameters_hint"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:text="@string/recognition_input_px"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/inputPxGroup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
app:checkedChip="@id/inputPx640"
|
||||
app:selectionRequired="true"
|
||||
app:singleSelection="true">
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/inputPx320"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_320" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/inputPx640"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_640" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/inputPx1280"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_1280" />
|
||||
</com.google.android.material.chip.ChipGroup>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="@string/recognition_max_faces"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/maxFacesGroup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
app:checkedChip="@id/maxFaces10"
|
||||
app:selectionRequired="true"
|
||||
app:singleSelection="true">
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/maxFaces1"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_1" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/maxFaces3"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_3" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/maxFaces5"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_5" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/maxFaces10"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_10" />
|
||||
</com.google.android.material.chip.ChipGroup>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="@string/recognition_min_face_px"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/minFaceGroup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
app:checkedChip="@id/minFace24"
|
||||
app:selectionRequired="true"
|
||||
app:singleSelection="true">
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/minFace24"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_24" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/minFace48"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_48" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/minFace64"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_64" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/minFace128"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_128" />
|
||||
</com.google.android.material.chip.ChipGroup>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnApplySession"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/recognition_apply_session"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
app:backgroundTint="@color/liveness_accent" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnResetSession"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/recognition_reset_defaults"
|
||||
android:textColor="@color/white"
|
||||
app:strokeColor="@color/liveness_accent" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/videoTrackingPanel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:keepScreenOn="true"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="500dp"
|
||||
app:cardBackgroundColor="@color/black"
|
||||
app:cardCornerRadius="20dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeColor="@color/home_stroke"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.camera.view.PreviewView
|
||||
android:id="@+id/detectionTrackingPreview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<com.example.inspireface_example.view.FaceTrackingGlView
|
||||
android:id="@+id/detectionTrackingGlOverlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btnFlipTrackingCamera"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|end"
|
||||
android:layout_margin="14dp"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:paddingStart="14dp"
|
||||
android:paddingTop="7dp"
|
||||
android:paddingEnd="14dp"
|
||||
android:paddingBottom="7dp"
|
||||
android:text="@string/flip_camera"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:layout_margin="14dp"
|
||||
app:cardBackgroundColor="@color/liveness_scrim"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="0dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="14dp">
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/trackingLoadingIndicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:indeterminate="true"
|
||||
app:indicatorColor="@color/liveness_accent"
|
||||
app:indicatorSize="24dp"
|
||||
app:trackThickness="3dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/detectionTrackingStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:text="@string/detection_tracking_initializing"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnCameraPermissionAction"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/camera_permission_try_again"
|
||||
android:textColor="@color/black"
|
||||
android:visibility="gone"
|
||||
app:backgroundTint="@color/liveness_accent" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
</FrameLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<include
|
||||
layout="@layout/view_face_tracking_settings"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
@@ -0,0 +1,171 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout 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:id="@+id/managementRoot"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="20dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingEnd="20dp"
|
||||
android:paddingBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btnBack"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:text="@string/back"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:maxLines="1"
|
||||
android:text="@string/face_management_title"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="17sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/currentModel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:gravity="center"
|
||||
android:maxLines="1"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingEnd="10dp"
|
||||
android:paddingBottom="5dp"
|
||||
android:textColor="@color/liveness_accent"
|
||||
android:textSize="11sp"
|
||||
tools:text="Model · Megatron" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="@string/face_library_heading"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/storageScope"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="13sp"
|
||||
tools:text="Megatron uses an isolated feature DB and crop directory." />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/faceCount"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:textColor="@color/liveness_accent"
|
||||
android:textSize="12sp"
|
||||
tools:text="12 stored identities" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="@string/search_faces">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/searchInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionDone"
|
||||
android:inputType="text"
|
||||
android:maxLines="1"
|
||||
android:textColor="@color/white" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnAddFace"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="56dp"
|
||||
android:layout_marginStart="10dp"
|
||||
android:enabled="false"
|
||||
android:text="@string/add_face"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
app:backgroundTint="@color/liveness_accent" />
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ListView
|
||||
android:id="@+id/faceList"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:divider="@android:color/transparent"
|
||||
android:dividerHeight="0dp"
|
||||
android:paddingBottom="8dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/emptyState"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="28dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/no_faces_title"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/no_faces_hint"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/loadingIndicator"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_gravity="center"
|
||||
app:indicatorColor="@color/liveness_accent" />
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,650 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.core.widget.NestedScrollView 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:id="@+id/recognitionRoot"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:fillViewport="true"
|
||||
android:keepScreenOn="true"
|
||||
android:paddingStart="20dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingEnd="20dp"
|
||||
android:paddingBottom="28dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btnBack"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:text="@string/back"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:maxLines="1"
|
||||
android:text="@string/face_recognition_title"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="17sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/currentModel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:gravity="center"
|
||||
android:maxLines="1"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingEnd="10dp"
|
||||
android:paddingBottom="5dp"
|
||||
android:textColor="@color/liveness_accent"
|
||||
android:textSize="11sp"
|
||||
tools:text="Model · Megatron" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="@string/face_recognition_heading"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:text="@string/face_recognition_hint"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<com.google.android.material.tabs.TabLayout
|
||||
android:id="@+id/recognitionTabs"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
app:tabIndicatorColor="@color/liveness_accent"
|
||||
app:tabSelectedTextColor="@color/liveness_accent"
|
||||
app:tabTextColor="@color/home_text_secondary">
|
||||
|
||||
<com.google.android.material.tabs.TabItem
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/recognition_tab_photo" />
|
||||
|
||||
<com.google.android.material.tabs.TabItem
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/recognition_tab_video" />
|
||||
</com.google.android.material.tabs.TabLayout>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/emptyLibraryTip"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:visibility="gone"
|
||||
app:cardBackgroundColor="@color/home_surface"
|
||||
app:cardCornerRadius="14dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeColor="@color/liveness_warn"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/emptyLibraryTipText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="14dp"
|
||||
android:textColor="@color/liveness_warn"
|
||||
android:textSize="13sp"
|
||||
tools:text="No identities are enrolled for Megatron." />
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/photoRecognitionPanel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/recognitionImageCard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="300dp"
|
||||
android:layout_marginTop="14dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
app:cardBackgroundColor="@color/home_surface"
|
||||
app:cardCornerRadius="20dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeColor="@color/home_stroke"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/recognitionImage"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@string/recognition_photo"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<com.example.inspireface_example.widget.FaceImageOverlayView
|
||||
android:id="@+id/recognitionFaceOverlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/recognitionImagePlaceholder"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:padding="24dp"
|
||||
android:text="@string/recognition_choose_photo_hint"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/recognitionSelectedFaceCropCard"
|
||||
android:layout_width="82dp"
|
||||
android:layout_height="82dp"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:layout_marginBottom="46dp"
|
||||
android:visibility="gone"
|
||||
app:cardBackgroundColor="@color/home_surface"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="4dp"
|
||||
app:strokeColor="@color/liveness_accent"
|
||||
app:strokeWidth="2dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/recognitionSelectedFaceCrop"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@string/recognition_selected_face_crop"
|
||||
android:scaleType="centerCrop" />
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/recognitionImageStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:background="@color/compare_status_scrim"
|
||||
android:gravity="center"
|
||||
android:padding="8dp"
|
||||
android:text="@string/no_image_selected"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="12sp" />
|
||||
</FrameLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnChooseRecognitionPhoto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:enabled="false"
|
||||
android:text="@string/recognition_choose_photo"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
app:backgroundTint="@color/liveness_accent" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="7dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/recognition_multi_face_hint"
|
||||
android:textColor="@color/home_text_muted"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
app:cardBackgroundColor="@color/home_surface"
|
||||
app:cardCornerRadius="18dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeColor="@color/home_stroke"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/sessionSettingsHeader"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="64dp"
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/recognition_session_parameters"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/sessionStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="11sp"
|
||||
tools:text="Session · 640 px · max 10 · min 24 px" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/sessionSettingsToggleText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:text="@string/recognition_settings_expand"
|
||||
android:textColor="@color/liveness_accent"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/sessionSettingsContent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/recognition_session_parameters_hint"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:text="@string/recognition_input_px"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/inputPxGroup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
app:checkedChip="@id/inputPx640"
|
||||
app:selectionRequired="true"
|
||||
app:singleSelection="true">
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/inputPx320"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_320" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/inputPx640"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_640" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/inputPx1280"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_1280" />
|
||||
</com.google.android.material.chip.ChipGroup>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="@string/recognition_max_faces"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/maxFacesGroup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
app:checkedChip="@id/maxFaces10"
|
||||
app:selectionRequired="true"
|
||||
app:singleSelection="true">
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/maxFaces1"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_1" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/maxFaces3"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_3" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/maxFaces5"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_5" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/maxFaces10"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_10" />
|
||||
</com.google.android.material.chip.ChipGroup>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="@string/recognition_min_face_px"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/minFaceGroup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
app:checkedChip="@id/minFace24"
|
||||
app:selectionRequired="true"
|
||||
app:singleSelection="true">
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/minFace24"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_24" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/minFace48"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_48" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/minFace64"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_64" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/minFace128"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_128" />
|
||||
</com.google.android.material.chip.ChipGroup>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnApplySession"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/recognition_apply_session"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
app:backgroundTint="@color/liveness_accent" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnResetSession"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/recognition_reset_defaults"
|
||||
android:textColor="@color/white"
|
||||
app:strokeColor="@color/liveness_accent" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/recognitionResultCard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:visibility="gone"
|
||||
app:cardBackgroundColor="@color/home_surface"
|
||||
app:cardCornerRadius="18dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeColor="@color/home_stroke"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/recognitionResultCrop"
|
||||
android:layout_width="76dp"
|
||||
android:layout_height="76dp"
|
||||
android:contentDescription="@string/stored_face_crop"
|
||||
android:scaleType="centerCrop"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="14dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/recognitionResultStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/liveness_accent"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold"
|
||||
tools:text="Match found" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/recognitionResultName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
tools:text="Ada" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/recognitionResultDetails"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="12sp"
|
||||
tools:text="ID · 7 · cosine confidence 0.823 · threshold 0.450" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/videoRecognitionPanel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="520dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:visibility="gone"
|
||||
app:cardBackgroundColor="@color/black"
|
||||
app:cardCornerRadius="20dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeColor="@color/home_stroke"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.camera.view.PreviewView
|
||||
android:id="@+id/recognitionVideoPreview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<com.example.inspireface_example.view.FaceOverlayView
|
||||
android:id="@+id/recognitionVideoOverlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btnFlipRecognitionCamera"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|end"
|
||||
android:layout_margin="14dp"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:paddingStart="14dp"
|
||||
android:paddingTop="7dp"
|
||||
android:paddingEnd="14dp"
|
||||
android:paddingBottom="7dp"
|
||||
android:text="@string/flip_camera"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:layout_margin="14dp"
|
||||
app:cardBackgroundColor="@color/liveness_scrim"
|
||||
app:cardCornerRadius="18dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="0dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/videoRecognitionStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:text="@string/recognition_video_initializing"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="19sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/videoRecognitionName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/liveness_accent"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone"
|
||||
tools:text="Ada" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/videoRecognitionDetails"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/recognition_video_first_face_hint"
|
||||
android:textColor="#B3FFFFFF"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnCameraPermissionAction"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/camera_permission_try_again"
|
||||
android:textColor="@color/black"
|
||||
android:visibility="gone"
|
||||
app:backgroundTint="@color/liveness_accent" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
</FrameLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
@@ -0,0 +1,332 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.core.widget.NestedScrollView 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:id="@+id/homeRoot"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:fillViewport="true"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingEnd="24dp"
|
||||
android:paddingBottom="24dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/app_name"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/langSwitch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:minHeight="40dp"
|
||||
android:gravity="center"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:text="@string/lang_switch"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
android:text="@string/home_title"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="34sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:lineSpacingExtra="2dp"
|
||||
android:text="@string/home_subtitle"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
app:cardBackgroundColor="@color/home_surface"
|
||||
app:cardCornerRadius="20dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeColor="@color/home_stroke"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/model_section_title"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="17sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/model_section_hint"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButtonToggleGroup
|
||||
android:id="@+id/modelToggle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
app:checkedButton="@id/btnModelMegatron"
|
||||
app:selectionRequired="true"
|
||||
app:singleSelection="true">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnModelPikachu"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/model_pikachu"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/white"
|
||||
app:strokeColor="@color/liveness_accent" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnModelMegatron"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/model_megatron"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/white"
|
||||
app:strokeColor="@color/liveness_accent" />
|
||||
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="30dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:text="@string/home_section_analysis"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardDetection"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
app:cardBackgroundColor="@color/home_tile_yellow"
|
||||
app:cardCornerRadius="22dp"
|
||||
app:cardElevation="0dp"
|
||||
app:layout_constraintDimensionRatio="1:1"
|
||||
app:layout_constraintEnd_toStartOf="@id/cardAttribute"
|
||||
app:layout_constraintHorizontal_chainStyle="spread"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<include layout="@layout/item_home_detection" />
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardAttribute"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginStart="12dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
app:cardBackgroundColor="@color/home_tile_lime"
|
||||
app:cardCornerRadius="22dp"
|
||||
app:cardElevation="0dp"
|
||||
app:layout_constraintDimensionRatio="1:1"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/cardDetection"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<include layout="@layout/item_home_attribute" />
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardPose"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
app:cardBackgroundColor="@color/home_tile_purple"
|
||||
app:cardCornerRadius="22dp"
|
||||
app:cardElevation="0dp"
|
||||
app:layout_constraintDimensionRatio="1:1"
|
||||
app:layout_constraintEnd_toEndOf="@id/cardDetection"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/cardDetection">
|
||||
|
||||
<include layout="@layout/item_home_pose" />
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:text="@string/home_section_recognition"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardCompare"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
app:cardBackgroundColor="@color/home_tile_orange"
|
||||
app:cardCornerRadius="22dp"
|
||||
app:cardElevation="0dp"
|
||||
app:layout_constraintDimensionRatio="1:1"
|
||||
app:layout_constraintEnd_toStartOf="@id/cardRecognition"
|
||||
app:layout_constraintHorizontal_chainStyle="spread"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<include layout="@layout/item_home_compare" />
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardRecognition"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginStart="12dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
app:cardBackgroundColor="@color/home_tile_cyan"
|
||||
app:cardCornerRadius="22dp"
|
||||
app:cardElevation="0dp"
|
||||
app:layout_constraintDimensionRatio="1:1"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/cardCompare"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<include layout="@layout/item_home_recognition" />
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardManagement"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
app:cardBackgroundColor="@color/home_tile_pink"
|
||||
app:cardCornerRadius="22dp"
|
||||
app:cardElevation="0dp"
|
||||
app:layout_constraintDimensionRatio="1:1"
|
||||
app:layout_constraintEnd_toEndOf="@id/cardCompare"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/cardCompare">
|
||||
|
||||
<include layout="@layout/item_home_management" />
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:text="@string/home_section_anti_fraud"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardSilent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
app:cardBackgroundColor="@color/home_tile_green"
|
||||
app:cardCornerRadius="22dp"
|
||||
app:cardElevation="0dp"
|
||||
app:layout_constraintDimensionRatio="1:1"
|
||||
app:layout_constraintEnd_toStartOf="@id/cardAction"
|
||||
app:layout_constraintHorizontal_chainStyle="spread"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<include layout="@layout/item_home_silent" />
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardAction"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginStart="12dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
app:cardBackgroundColor="@color/home_tile_blue"
|
||||
app:cardCornerRadius="22dp"
|
||||
app:cardElevation="0dp"
|
||||
app:layout_constraintDimensionRatio="1:1"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/cardSilent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<include layout="@layout/item_home_action" />
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
@@ -0,0 +1,241 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout 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:id="@+id/livenessRoot"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/black"
|
||||
android:keepScreenOn="true">
|
||||
|
||||
<androidx.camera.view.PreviewView
|
||||
android:id="@+id/previewView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<com.example.inspireface_example.view.LandmarkGlView
|
||||
android:id="@+id/landmarkGlView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.example.inspireface_example.view.FaceOverlayView
|
||||
android:id="@+id/faceOverlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/topBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btnBack"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:minWidth="40dp"
|
||||
android:text="@string/back"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pageTitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:maxLines="1"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
tools:text="Silent liveness" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/currentModel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:gravity="center"
|
||||
android:maxLines="1"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingEnd="10dp"
|
||||
android:paddingBottom="5dp"
|
||||
android:textColor="@color/liveness_accent"
|
||||
android:textSize="11sp"
|
||||
tools:text="Model · Megatron" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/perfText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingBottom="4dp"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="12sp"
|
||||
android:visibility="gone"
|
||||
tools:text="30 FPS · 12 ms" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/switchEuler"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/switch_euler"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btnFlipCamera"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:paddingStart="14dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingEnd="14dp"
|
||||
android:paddingBottom="6dp"
|
||||
android:text="@string/flip_camera"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/eulerText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingBottom="4dp"
|
||||
android:textColor="@color/liveness_accent"
|
||||
android:textSize="12sp"
|
||||
android:visibility="gone"
|
||||
tools:text="Yaw -3.2° · Pitch 5.1° · Roll 0.8°" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/langSwitch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:paddingStart="14dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingEnd="14dp"
|
||||
android:paddingBottom="6dp"
|
||||
android:text="@string/lang_switch"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/promptCard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="24dp"
|
||||
app:cardBackgroundColor="@color/liveness_scrim"
|
||||
app:cardCornerRadius="20dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="0dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/promptTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:text="@string/msg_initializing"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/promptSub"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:gravity="center"
|
||||
android:textColor="#B3FFFFFF"
|
||||
android:textSize="14sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/promptProgress"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:max="100"
|
||||
android:visibility="gone"
|
||||
app:indicatorColor="@color/liveness_accent"
|
||||
app:trackColor="#33FFFFFF"
|
||||
app:trackCornerRadius="4dp"
|
||||
app:trackThickness="8dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnRestart"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:backgroundTint="@color/liveness_accent"
|
||||
android:text="@string/btn_restart"
|
||||
android:textColor="@color/black"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnCameraPermissionAction"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:text="@string/camera_permission_try_again"
|
||||
android:textColor="@color/black"
|
||||
android:visibility="gone"
|
||||
app:backgroundTint="@color/liveness_accent" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.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:id="@+id/main"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Hello World!"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,107 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="20dp"
|
||||
android:paddingEnd="20dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="240dp"
|
||||
app:cardBackgroundColor="@color/home_background"
|
||||
app:cardCornerRadius="18dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeColor="@color/home_stroke"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/editorImage"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@string/enrollment_image"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<com.example.inspireface_example.widget.FaceImageOverlayView
|
||||
android:id="@+id/editorFaceOverlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/editorPlaceholder"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:padding="20dp"
|
||||
android:text="@string/choose_enrollment_image"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/editorImageStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:background="@color/compare_status_scrim"
|
||||
android:gravity="center"
|
||||
android:padding="8dp"
|
||||
android:text="@string/no_image_selected"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="12sp" />
|
||||
</FrameLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnCaptureEditorFace"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="@string/capture_or_replace_face"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
app:backgroundTint="@color/liveness_accent" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnChooseEditorImage"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/choose_or_replace_image"
|
||||
android:textColor="@color/white"
|
||||
app:strokeColor="@color/liveness_accent" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/tap_face_to_select"
|
||||
android:textColor="@color/home_text_muted"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:hint="@string/face_name">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/editorName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPersonName"
|
||||
android:maxLines="1" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingBottom="10dp">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardBackgroundColor="@color/home_surface"
|
||||
app:cardCornerRadius="18dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeColor="@color/home_stroke"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="94dp"
|
||||
android:orientation="horizontal"
|
||||
android:padding="11dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/faceCrop"
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="72dp"
|
||||
android:background="@color/compare_ring_track"
|
||||
android:contentDescription="@string/stored_face_crop"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="13dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/faceName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxLines="1"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/faceId"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:textColor="@color/home_text_muted"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btnEditFace"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:text="@string/edit_short"
|
||||
android:textColor="@color/liveness_accent"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btnDeleteFace"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:text="@string/delete_short"
|
||||
android:textColor="@color/liveness_fail"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="bottom"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:contentDescription="@null"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_home_action_liveness" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/menu_badge_action"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<Space
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/mode_action"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
android:textSize="19sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:maxLines="2"
|
||||
android:text="@string/home_action_desc"
|
||||
android:textColor="@color/home_tile_text_dark_secondary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="bottom"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:contentDescription="@null"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_home_attributes" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/menu_badge_attribute"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<Space
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/face_attribute_title"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
android:textSize="19sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:maxLines="2"
|
||||
android:text="@string/home_attribute_desc"
|
||||
android:textColor="@color/home_tile_text_dark_secondary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="bottom"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:contentDescription="@null"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_home_compare" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/menu_badge_compare"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<Space
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/face_compare_title"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
android:textSize="19sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:maxLines="2"
|
||||
android:text="@string/home_compare_desc"
|
||||
android:textColor="@color/home_tile_text_dark_secondary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="bottom"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:contentDescription="@null"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_home_tracking" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/menu_badge_detection"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<Space
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/face_detection_title"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
android:textSize="19sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:maxLines="2"
|
||||
android:text="@string/home_detection_desc"
|
||||
android:textColor="@color/home_tile_text_dark_secondary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="bottom"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:contentDescription="@null"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_home_management" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/menu_badge_management"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<Space
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/face_management_title"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
android:textSize="19sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:maxLines="2"
|
||||
android:text="@string/home_management_desc"
|
||||
android:textColor="@color/home_tile_text_dark_secondary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="bottom"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:contentDescription="@null"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_home_pose" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/menu_badge_pose"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<Space
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/mode_pose"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
android:textSize="19sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:maxLines="2"
|
||||
android:text="@string/home_pose_desc"
|
||||
android:textColor="@color/home_tile_text_dark_secondary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="bottom"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:contentDescription="@null"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_home_recognition" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/menu_badge_recognition"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<Space
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/face_recognition_title"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
android:textSize="19sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:maxLines="2"
|
||||
android:text="@string/home_recognition_desc"
|
||||
android:textColor="@color/home_tile_text_dark_secondary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="bottom"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:contentDescription="@null"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_home_silent_liveness" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/menu_badge_rgb"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<Space
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/mode_silent"
|
||||
android:textColor="@color/home_tile_text_dark"
|
||||
android:textSize="19sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:maxLines="2"
|
||||
android:text="@string/home_silent_desc"
|
||||
android:textColor="@color/home_tile_text_dark_secondary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,268 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView 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="wrap_content"
|
||||
app:cardBackgroundColor="@color/home_surface"
|
||||
app:cardCornerRadius="18dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeColor="@color/home_stroke"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/trackingSettingsHeader"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="64dp"
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/detection_tracking_settings"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/trackingSessionStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="11sp"
|
||||
tools:text="Light · 640 px · max 10 · min 24 px" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/trackingSettingsToggleText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:text="@string/recognition_settings_expand"
|
||||
android:textColor="@color/liveness_accent"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/trackingSettingsContent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/detection_tracking_settings_hint"
|
||||
android:textColor="@color/home_text_secondary"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:text="@string/detection_tracking_mode"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/trackingModeGroup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
app:checkedChip="@id/trackingModeLight"
|
||||
app:selectionRequired="true"
|
||||
app:singleSelection="true">
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/trackingModeLight"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/detection_tracking_mode_light" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/trackingModeTbd"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/detection_tracking_mode_tbd" />
|
||||
</com.google.android.material.chip.ChipGroup>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="@string/recognition_input_px"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/trackingInputPxGroup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
app:checkedChip="@id/trackingInputPx640"
|
||||
app:selectionRequired="true"
|
||||
app:singleSelection="true">
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/trackingInputPx320"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_320" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/trackingInputPx640"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_640" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/trackingInputPx1280"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_1280" />
|
||||
</com.google.android.material.chip.ChipGroup>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="@string/recognition_max_faces"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/trackingMaxFacesGroup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
app:checkedChip="@id/trackingMaxFaces10"
|
||||
app:selectionRequired="true"
|
||||
app:singleSelection="true">
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/trackingMaxFaces1"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_1" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/trackingMaxFaces3"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_3" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/trackingMaxFaces5"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_5" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/trackingMaxFaces10"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_10" />
|
||||
</com.google.android.material.chip.ChipGroup>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="@string/recognition_min_face_px"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/trackingMinFaceGroup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
app:checkedChip="@id/trackingMinFace24"
|
||||
app:selectionRequired="true"
|
||||
app:singleSelection="true">
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/trackingMinFace24"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_24" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/trackingMinFace48"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_48" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/trackingMinFace64"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_64" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/trackingMinFace128"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checkable="true"
|
||||
android:text="@string/value_128" />
|
||||
</com.google.android.material.chip.ChipGroup>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnResetTrackingSession"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/recognition_reset_defaults"
|
||||
android:textColor="@color/white"
|
||||
app:strokeColor="@color/liveness_accent" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 980 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 5.5 KiB |
@@ -0,0 +1,268 @@
|
||||
<resources>
|
||||
<string name="app_name">InspireFace 示例</string>
|
||||
|
||||
<!-- 活体检测页面 -->
|
||||
<string name="mode_silent">静默活体</string>
|
||||
<string name="mode_action">动作活体</string>
|
||||
<string name="mode_pose">姿态识别</string>
|
||||
<string name="msg_initializing">引擎初始化中…</string>
|
||||
<string name="msg_no_face">请将脸部对准取景框</string>
|
||||
<string name="msg_multiple_faces">请保持画面中只有一张人脸</string>
|
||||
<string name="msg_face_too_small">请靠近一点</string>
|
||||
<string name="msg_permission_required">需要相机权限才能使用</string>
|
||||
<string name="msg_engine_failed">人脸引擎初始化失败</string>
|
||||
<string name="msg_no_front_camera">此设备没有可用相机</string>
|
||||
<string name="msg_camera_unavailable">该摄像头不可用</string>
|
||||
<string name="flip_camera">切换镜头</string>
|
||||
<string name="back">←</string>
|
||||
<string name="current_model">模型 · %1$s</string>
|
||||
<string name="camera_privacy_notice_title">相机与人脸数据说明</string>
|
||||
<string name="camera_privacy_notice_message">相机仅用于视频功能和相机录入,人脸画面在当前设备处理。录入的人脸裁剪图与特征保存在 App 内部,按模型隔离,不进入设备备份,也不会上传。拒绝相机权限后仍可继续使用照片输入功能。</string>
|
||||
<string name="camera_permission_continue">继续</string>
|
||||
<string name="camera_permission_not_now">暂不使用</string>
|
||||
<string name="camera_permission_rationale_title">允许使用相机</string>
|
||||
<string name="camera_permission_rationale_message">仅当前视频功能需要相机权限;拒绝后仍可继续使用照片输入功能。</string>
|
||||
<string name="camera_permission_try_again">重新授权</string>
|
||||
<string name="camera_permission_open_settings">前往系统设置</string>
|
||||
<string name="camera_permission_retry_hint">相机权限尚未开启,点击“重新授权”后继续。</string>
|
||||
<string name="camera_permission_settings_hint">相机权限已被阻止,请前往系统设置允许“相机”,然后返回此页面。</string>
|
||||
|
||||
<string name="silent_real">真人</string>
|
||||
<string name="silent_fake">疑似攻击</string>
|
||||
<string name="silent_analyzing">检测中…</string>
|
||||
<string name="silent_score">活体分数 %1$.2f</string>
|
||||
|
||||
<string name="action_get_ready">请正对相机并保持稳定</string>
|
||||
<string name="action_blink">请眨眼</string>
|
||||
<string name="action_shake">请左右摇头</string>
|
||||
<string name="action_jaw_open">请张张嘴</string>
|
||||
<string name="action_head_raise">请抬头</string>
|
||||
<string name="action_step">第 %1$d / %2$d 步</string>
|
||||
<string name="action_time_left">剩余 %1$d 秒</string>
|
||||
<string name="action_passed">活体检测通过</string>
|
||||
<string name="action_failed_timeout">超时,检测失败</string>
|
||||
<string name="action_face_lost">人脸丢失,请重新开始</string>
|
||||
<string name="btn_restart">重新开始</string>
|
||||
|
||||
<string name="pose_hint">做个动作试试:眨眼、摇头、张嘴、抬头</string>
|
||||
<string name="name_blink">眨眼</string>
|
||||
<string name="name_shake">摇头</string>
|
||||
<string name="name_jaw_open">张嘴</string>
|
||||
<string name="name_head_raise">抬头</string>
|
||||
|
||||
<string name="perf_format">%1$.1f FPS · %2$d ms</string>
|
||||
<string name="switch_euler">欧拉角</string>
|
||||
<!-- Label of the language toggle: names the language it switches TO. -->
|
||||
<string name="lang_switch">English</string>
|
||||
<string name="euler_format">偏航 %1$.1f° · 俯仰 %2$.1f° · 翻滚 %3$.1f°</string>
|
||||
<string name="euler_no_face">—</string>
|
||||
|
||||
<!-- 主页 -->
|
||||
<string name="home_title">探索人脸智能</string>
|
||||
<string name="home_subtitle">选择全局模型,然后进入一个 InspireFace 功能演示。</string>
|
||||
<string name="model_section_title">全局模型</string>
|
||||
<string name="model_section_hint">所选模型将在打开功能页面时加载。</string>
|
||||
<string name="home_section_analysis">人脸分析</string>
|
||||
<string name="home_section_recognition">人脸识别</string>
|
||||
<string name="home_section_anti_fraud">反欺诈</string>
|
||||
<string name="home_silent_desc">RGB 防伪检测,实时展示活体置信度</string>
|
||||
<string name="home_action_desc">按提示完成随机人脸动作序列</string>
|
||||
<string name="home_pose_desc">识别眨眼、摇头、张嘴与抬头动作</string>
|
||||
<string name="home_compare_desc">上传两张人像照片并比较人脸相似度</string>
|
||||
<string name="home_management_desc">管理当前模型独立的身份、特征和裁剪图</string>
|
||||
<string name="home_recognition_desc">在当前模型的人脸库中识别人脸身份</string>
|
||||
<string name="home_detection_desc">检测人脸并查看原生 106 点稠密关键点</string>
|
||||
<string name="home_attribute_desc">查看口罩、年龄、质量与人脸属性</string>
|
||||
|
||||
<!-- 人脸 1:1 -->
|
||||
<string name="face_compare_title">人脸 1:1</string>
|
||||
<string name="face_compare_heading">是同一个人吗?</string>
|
||||
<string name="face_compare_hint">请在左右两侧分别添加一张清晰图片。默认选择第 1 张人脸;点击任意编号人脸框即可切换,并立即重新比对。</string>
|
||||
<string name="image_a">图像 A</string>
|
||||
<string name="image_b">图像 B</string>
|
||||
<string name="upload_image_a">添加图像 A</string>
|
||||
<string name="upload_image_b">添加图像 B</string>
|
||||
<string name="image_empty">点击选择</string>
|
||||
<string name="image_analyzing">正在检测人脸…</string>
|
||||
<string name="image_face_ready">人脸已就绪</string>
|
||||
<string name="image_no_face">未检测到人脸</string>
|
||||
<string name="image_face_selected">已选择第 %1$d / %2$d 张人脸</string>
|
||||
<string name="image_load_failed">无法读取图片</string>
|
||||
<string name="face_extract_failed">人脸特征提取失败</string>
|
||||
<string name="similarity_title">相似度</string>
|
||||
<string name="compare_selected_face_a">当前参与比对的人脸 A</string>
|
||||
<string name="compare_selected_face_b">当前参与比对的人脸 B</string>
|
||||
<string name="compare_select_two">请选择两张图片</string>
|
||||
<string name="compare_analyzing">正在分析图片…</string>
|
||||
<string name="compare_waiting_face">请添加两张有效人脸图片</string>
|
||||
<string name="compare_comparing">正在比对人脸…</string>
|
||||
<string name="compare_same_person">可能是同一个人</string>
|
||||
<string name="compare_different_person">可能不是同一个人</string>
|
||||
<string name="compare_failed">人脸比对失败</string>
|
||||
<string name="compare_engine_failed">人脸引擎不可用</string>
|
||||
<string name="compare_details">模型置信度 %1$.3f · 阈值 %2$.3f</string>
|
||||
<string name="on_device_privacy">图片仅在当前设备上处理。</string>
|
||||
|
||||
<!-- 人脸管理 -->
|
||||
<string name="face_management_title">人脸管理</string>
|
||||
<string name="face_library_heading">人脸库</string>
|
||||
<string name="face_storage_scope">%1$s 使用独立的特征数据库和人脸裁剪图目录。</string>
|
||||
<string name="search_faces">按姓名或 ID 搜索</string>
|
||||
<string name="add_face">新增</string>
|
||||
<string name="no_faces_title">还没有录入人脸</string>
|
||||
<string name="no_faces_hint">添加一张人像,在当前模型库中创建第一个身份。</string>
|
||||
<string name="stored_face_crop">已存储的人脸裁剪图</string>
|
||||
<string name="face_id_format">ID · %1$d</string>
|
||||
<string name="edit_short">编辑</string>
|
||||
<string name="delete_short">删除</string>
|
||||
<string name="add_face_title">新增人脸</string>
|
||||
<string name="edit_face_title">编辑人脸</string>
|
||||
<string name="choose_enrollment_image">使用相机录入或从相册选择</string>
|
||||
<string name="enrollment_image">人脸录入图片</string>
|
||||
<string name="no_image_selected">尚未选择图片</string>
|
||||
<string name="capture_or_replace_face">使用相机录入</string>
|
||||
<string name="choose_or_replace_image">从相册选择</string>
|
||||
<string name="tap_face_to_select">检测到多张人脸时,点击人脸框选择要录入的身份。</string>
|
||||
<string name="face_name">姓名</string>
|
||||
<string name="save">保存</string>
|
||||
<string name="cancel">取消</string>
|
||||
<string name="saving_face">正在保存…</string>
|
||||
<string name="face_saved">人脸已保存</string>
|
||||
<string name="face_save_failed">无法保存该人脸</string>
|
||||
<string name="face_library_failed">无法打开当前模型的独立人脸库</string>
|
||||
<string name="delete_face_title">删除人脸?</string>
|
||||
<string name="delete_face_message">确定从当前 %2$s 模型库删除 %1$s?对应特征和裁剪图都会被移除。</string>
|
||||
<string name="delete">删除</string>
|
||||
<string name="face_deleted">人脸已删除</string>
|
||||
<string name="face_delete_failed">无法删除该人脸</string>
|
||||
<string name="face_list_count">已存储 %1$d 个身份</string>
|
||||
|
||||
<!-- 人脸识别 -->
|
||||
<string name="face_recognition_title">人脸识别</string>
|
||||
<string name="face_recognition_heading">这是谁?</string>
|
||||
<string name="face_recognition_hint">在当前模型已录入的身份库中搜索人脸。</string>
|
||||
<string name="recognition_tab_photo">照片输入</string>
|
||||
<string name="recognition_tab_video">视频流</string>
|
||||
<string name="recognition_session_parameters">Session 参数</string>
|
||||
<string name="recognition_session_parameters_hint">应用后会重建照片 Session,并将参数保存在当前设备。</string>
|
||||
<string name="recognition_settings_expand">展开</string>
|
||||
<string name="recognition_settings_collapse">收起</string>
|
||||
<string name="recognition_input_px">检测输入 px</string>
|
||||
<string name="recognition_max_faces">最大检测人脸数</string>
|
||||
<string name="recognition_min_face_px">最小人脸 px</string>
|
||||
<string name="recognition_apply_session">应用并重建</string>
|
||||
<string name="recognition_reset_defaults">恢复默认</string>
|
||||
<string name="recognition_session_summary">Session · %1$d px · 最多 %2$d 张 · 最小 %3$d px</string>
|
||||
<string name="recognition_session_rebuilding">正在重建 Session…</string>
|
||||
<string name="recognition_session_failed">无法创建该 Session</string>
|
||||
<string name="recognition_empty_library">%1$s 模型当前还没有录入人脸,请先前往人脸管理添加身份。</string>
|
||||
<string name="recognition_photo">识别照片</string>
|
||||
<string name="recognition_selected_face_crop">当前选中人脸裁剪图</string>
|
||||
<string name="recognition_choose_photo_hint">选择照片后检测并识别人脸</string>
|
||||
<string name="recognition_choose_photo">选择照片</string>
|
||||
<string name="recognition_multi_face_hint">单张人脸会自动搜索;检测到多张人脸时,点击编号框即可切换。</string>
|
||||
<string name="recognition_searching">正在当前模型的人脸库中搜索…</string>
|
||||
<string name="recognition_match_found">识别成功</string>
|
||||
<string name="recognition_no_match">未找到匹配身份</string>
|
||||
<string name="recognition_library_empty_result">当前模型的人脸库为空</string>
|
||||
<string name="recognition_result_name_unknown">未知身份</string>
|
||||
<string name="recognition_result_details">ID · %1$d · 模型置信度 %2$.3f · 阈值 %3$.3f</string>
|
||||
<string name="recognition_no_match_details">模型置信度 %1$.3f · 阈值 %2$.3f</string>
|
||||
<string name="recognition_no_confidence">没有候选身份超过搜索阈值</string>
|
||||
<string name="recognition_video_initializing">正在启动相机…</string>
|
||||
<string name="recognition_video_no_face">请将人脸放入画面</string>
|
||||
<string name="recognition_video_hold_still">请保持稳定,正在准备识别</string>
|
||||
<string name="recognition_video_first_face_hint">只跟踪第一张人脸,稳定约 1 秒后自动识别。</string>
|
||||
|
||||
<!-- 人脸检测和跟踪 -->
|
||||
<string name="face_detection_title">人脸跟踪</string>
|
||||
<string name="face_detection_heading">找到每一张人脸</string>
|
||||
<string name="face_detection_hint">检测并选择照片中的人脸,查看 SDK 原生 106 点稠密关键点。</string>
|
||||
<string name="detection_tab_image">图像检测</string>
|
||||
<string name="detection_tab_tracking">视频跟踪</string>
|
||||
<string name="detection_show_dense_landmarks">显示 106 点稠密关键点</string>
|
||||
<string name="detection_photo">人脸检测照片</string>
|
||||
<string name="detection_choose_photo_hint">选择照片后检测人脸和关键点</string>
|
||||
<string name="detection_choose_photo">选择检测照片</string>
|
||||
<string name="detection_multi_face_hint">默认选择第 1 张人脸,点击任意编号框即可查看对应人脸。</string>
|
||||
<string name="detection_landmark_behavior_hint">大人脸直接在原图显示关键点,小人脸使用右下角放大器。</string>
|
||||
<string name="detection_face_selected">已选择第 %1$d / %2$d 张人脸 · SDK 返回 %3$d 个关键点</string>
|
||||
<string name="face_detection_failed">人脸检测失败</string>
|
||||
<string name="detection_landmarks_hidden">已隐藏 106 点关键点</string>
|
||||
<string name="detection_landmarks_failed">无法读取该人脸的 106 点关键点</string>
|
||||
<string name="detection_landmarks_on_source">大人脸 · 占原图 %1$d%% · 关键点显示在原图</string>
|
||||
<string name="detection_landmarks_in_magnifier">小人脸 · 占原图 %1$d%% · 关键点已在右下角放大</string>
|
||||
<string name="detection_landmark_magnifier">选中人脸放大图</string>
|
||||
<string name="detection_tracking_initializing">正在启动相机跟踪…</string>
|
||||
<string name="detection_tracking_restarting">正在应用配置并重启相机…</string>
|
||||
<string name="detection_tracking_stats">%1$.1f FPS · %2$d ms</string>
|
||||
<string name="detection_tracking_settings">跟踪 Session 参数</string>
|
||||
<string name="detection_tracking_settings_hint">修改后会自动保存配置并重启相机流。</string>
|
||||
<string name="detection_tracking_mode">跟踪模式</string>
|
||||
<string name="detection_tracking_mode_light">Light 轻量跟踪</string>
|
||||
<string name="detection_tracking_mode_tbd">Track-by-detection</string>
|
||||
<string name="detection_tracking_summary">%1$s · %2$d px · 最多 %3$d 张 · 最小 %4$d px</string>
|
||||
<string name="detection_tracking_session_failed">无法创建该跟踪 Session</string>
|
||||
|
||||
<!-- 人脸属性 -->
|
||||
<string name="face_attribute_title">人脸属性分析</string>
|
||||
<string name="face_attribute_heading">分析人脸属性</string>
|
||||
<string name="face_attribute_hint">选择一张照片,点击任意编号人脸即可动态查看对应属性。</string>
|
||||
<string name="attribute_photo">人脸属性照片</string>
|
||||
<string name="attribute_choose_photo_hint">选择照片后分析人脸属性</string>
|
||||
<string name="attribute_choose_photo">选择照片</string>
|
||||
<string name="attribute_multi_face_hint">默认选择第 1 张人脸;选中的小人脸会显示在右下角放大镜中。</string>
|
||||
<string name="attribute_selected_face_crop">选中人脸放大图</string>
|
||||
<string name="attribute_face_selected">已选择第 %1$d / %2$d 张人脸</string>
|
||||
<string name="attribute_result_title">选中人脸属性</string>
|
||||
<string name="attribute_result_empty">选择照片后查看属性</string>
|
||||
<string name="attribute_analyzing">正在分析人脸属性…</string>
|
||||
<string name="attribute_analysis_failed">无法分析人脸属性</string>
|
||||
<string name="attribute_no_face_result">暂无可显示的人脸属性</string>
|
||||
<string name="attribute_mask">佩戴口罩</string>
|
||||
<string name="attribute_age">年龄段</string>
|
||||
<string name="attribute_quality">人脸画面质量得分</string>
|
||||
<string name="attribute_expression">表情</string>
|
||||
<string name="attribute_race">民族</string>
|
||||
<string name="attribute_gender">性别</string>
|
||||
<string name="attribute_left_eye">左眼状态</string>
|
||||
<string name="attribute_right_eye">右眼状态</string>
|
||||
<string name="attribute_mask_yes">是</string>
|
||||
<string name="attribute_mask_no">否</string>
|
||||
<string name="attribute_eye_open">睁开</string>
|
||||
<string name="attribute_eye_closed">闭合</string>
|
||||
<string name="attribute_expression_neutral">自然</string>
|
||||
<string name="attribute_expression_mouth_open">张嘴</string>
|
||||
<string name="attribute_gender_female">女性</string>
|
||||
<string name="attribute_gender_male">男性</string>
|
||||
<string name="attribute_race_black">黑人</string>
|
||||
<string name="attribute_race_asian">亚洲人</string>
|
||||
<string name="attribute_race_latino">拉丁裔 / 西班牙裔</string>
|
||||
<string name="attribute_race_middle_eastern">中东人</string>
|
||||
<string name="attribute_race_white">白人</string>
|
||||
<string name="attribute_age_0_2">0–2 岁</string>
|
||||
<string name="attribute_age_3_9">3–9 岁</string>
|
||||
<string name="attribute_age_10_19">10–19 岁</string>
|
||||
<string name="attribute_age_20_29">20–29 岁</string>
|
||||
<string name="attribute_age_30_39">30–39 岁</string>
|
||||
<string name="attribute_age_40_49">40–49 岁</string>
|
||||
<string name="attribute_age_50_59">50–59 岁</string>
|
||||
<string name="attribute_age_60_69">60–69 岁</string>
|
||||
<string name="attribute_age_70_plus">70 岁以上</string>
|
||||
<string name="attribute_unknown">—</string>
|
||||
|
||||
<!-- 相机人脸录入 -->
|
||||
<string name="face_capture_title">相机录入</string>
|
||||
<string name="capture_permission_hint">允许相机权限后即可继续。</string>
|
||||
<string name="capture_no_face">请将人脸放入画面</string>
|
||||
<string name="capture_first_face_hint">正在跟踪检测到的第一张人脸,请保持清晰并居中。</string>
|
||||
<string name="capture_move_closer">请靠近一点</string>
|
||||
<string name="capture_hold_still">请保持不动</string>
|
||||
<string name="capture_warmup_hint">稳定保持 1 秒后将出现进度圈。</string>
|
||||
<string name="capture_keep_still">请继续保持稳定</string>
|
||||
<string name="capture_progress">稳定进度 %1$d%%</string>
|
||||
<string name="capture_complete">录入采集完成</string>
|
||||
<string name="capture_complete_hint">进度圈已满,正在准备人脸…</string>
|
||||
<string name="capture_failed">无法采集当前画面</string>
|
||||
<string name="capture_retry_hint">请保持稳定,应用会自动重试。</string>
|
||||
</resources>
|
||||
@@ -2,4 +2,30 @@
|
||||
<resources>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
|
||||
<!-- Liveness screen -->
|
||||
<color name="liveness_accent">#FF00E5A0</color>
|
||||
<color name="liveness_warn">#FFFFC400</color>
|
||||
<color name="liveness_fail">#FFFF5252</color>
|
||||
<color name="liveness_scrim">#99000000</color>
|
||||
<color name="liveness_chip_bg">#B3000000</color>
|
||||
|
||||
<!-- Home screen -->
|
||||
<color name="home_background">#FF07110F</color>
|
||||
<color name="home_surface">#FF101C19</color>
|
||||
<color name="home_stroke">#FF263A35</color>
|
||||
<color name="home_text_secondary">#FF9FB2AD</color>
|
||||
<color name="home_text_muted">#FF71847F</color>
|
||||
<color name="home_tile_green">#FF72E2B5</color>
|
||||
<color name="home_tile_blue">#FF84C8FF</color>
|
||||
<color name="home_tile_purple">#FFC6B5FF</color>
|
||||
<color name="home_tile_orange">#FFFFC988</color>
|
||||
<color name="home_tile_pink">#FFFFB8D1</color>
|
||||
<color name="home_tile_cyan">#FF79DDD9</color>
|
||||
<color name="home_tile_yellow">#FFFFE082</color>
|
||||
<color name="home_tile_lime">#FFB8E986</color>
|
||||
<color name="home_tile_text_dark">#FF07110F</color>
|
||||
<color name="home_tile_text_dark_secondary">#B307110F</color>
|
||||
<color name="compare_ring_track">#FF23332F</color>
|
||||
<color name="compare_status_scrim">#D907110F</color>
|
||||
</resources>
|
||||
|
||||
@@ -1,3 +1,294 @@
|
||||
<resources>
|
||||
<string name="app_name">InspireFace-Example</string>
|
||||
</resources>
|
||||
|
||||
<!-- Liveness screen -->
|
||||
<string name="mode_silent">Silent liveness</string>
|
||||
<string name="mode_action">Action liveness</string>
|
||||
<string name="mode_pose">Pose recognition</string>
|
||||
<string name="msg_initializing">Initializing engine…</string>
|
||||
<string name="msg_no_face">Align your face within the frame</string>
|
||||
<string name="msg_multiple_faces">Keep only one face in view</string>
|
||||
<string name="msg_face_too_small">Move closer to the camera</string>
|
||||
<string name="msg_permission_required">Camera permission is required</string>
|
||||
<string name="msg_engine_failed">Failed to initialize the face engine</string>
|
||||
<string name="msg_no_front_camera">No camera available on this device</string>
|
||||
<string name="msg_camera_unavailable">That camera is not available</string>
|
||||
<string name="flip_camera">Flip camera</string>
|
||||
<string name="back">←</string>
|
||||
<string name="current_model">Model · %1$s</string>
|
||||
<string name="camera_privacy_notice_title">Camera and face data</string>
|
||||
<string name="camera_privacy_notice_message">Camera access is used only for live demos and camera enrollment. Frames are processed on this device. Enrolled face crops and features stay in this app’s internal storage, are separated by model, excluded from device backup, and are not uploaded. Photo-only demos remain available without camera access.</string>
|
||||
<string name="camera_permission_continue">Continue</string>
|
||||
<string name="camera_permission_not_now">Not now</string>
|
||||
<string name="camera_permission_rationale_title">Allow camera access</string>
|
||||
<string name="camera_permission_rationale_message">Camera access is required only for this live feature. You can continue using photo-only features without it.</string>
|
||||
<string name="camera_permission_try_again">Try again</string>
|
||||
<string name="camera_permission_open_settings">Open settings</string>
|
||||
<string name="camera_permission_retry_hint">Camera access is off. Tap Try again to continue.</string>
|
||||
<string name="camera_permission_settings_hint">Camera access is blocked. Open system settings, allow Camera, then return here.</string>
|
||||
|
||||
<string name="silent_real">Live face</string>
|
||||
<string name="silent_fake">Spoof suspected</string>
|
||||
<string name="silent_analyzing">Analyzing…</string>
|
||||
<string name="silent_score">Liveness score %1$.2f</string>
|
||||
|
||||
<string name="action_get_ready">Face the camera and hold still</string>
|
||||
<string name="action_blink">Please blink</string>
|
||||
<string name="action_shake">Please shake your head</string>
|
||||
<string name="action_jaw_open">Please open your mouth</string>
|
||||
<string name="action_head_raise">Please raise your head</string>
|
||||
<string name="action_step">Step %1$d / %2$d</string>
|
||||
<string name="action_time_left">%1$ds left</string>
|
||||
<string name="action_passed">Liveness check passed</string>
|
||||
<string name="action_failed_timeout">Timed out, check failed</string>
|
||||
<string name="action_face_lost">Face lost, please try again</string>
|
||||
<string name="btn_restart">Restart</string>
|
||||
|
||||
<string name="pose_hint">Try an action: blink, shake, open mouth, raise head</string>
|
||||
<string name="name_blink">Blink</string>
|
||||
<string name="name_shake">Head shake</string>
|
||||
<string name="name_jaw_open">Mouth open</string>
|
||||
<string name="name_head_raise">Head raise</string>
|
||||
|
||||
<string name="perf_format">%1$.1f FPS · %2$d ms</string>
|
||||
<string name="switch_euler">Euler angles</string>
|
||||
<!-- Label of the language toggle: names the language it switches TO. -->
|
||||
<string name="lang_switch">中文</string>
|
||||
<string name="euler_format">Yaw %1$.1f° · Pitch %2$.1f° · Roll %3$.1f°</string>
|
||||
<string name="euler_no_face">—</string>
|
||||
|
||||
<!-- Home screen -->
|
||||
<string name="home_title">Explore face intelligence</string>
|
||||
<string name="home_subtitle">Choose a model, then open an InspireFace capability demo.</string>
|
||||
<string name="model_section_title">Global model</string>
|
||||
<string name="model_section_hint">The selected model is loaded when a feature opens.</string>
|
||||
<string name="home_section_analysis">Face analysis</string>
|
||||
<string name="home_section_recognition">Face recognition</string>
|
||||
<string name="home_section_anti_fraud">Anti-fraud</string>
|
||||
<string name="home_silent_desc">RGB anti-spoofing with a live confidence score</string>
|
||||
<string name="home_action_desc">Complete a randomized sequence of face actions</string>
|
||||
<string name="home_pose_desc">Recognize blink, shake, mouth and head poses</string>
|
||||
<string name="home_compare_desc">Upload two portraits and compare face similarity</string>
|
||||
<string name="home_management_desc">Manage model-specific identities, features and crops</string>
|
||||
<string name="home_recognition_desc">Identify a face against the current model library</string>
|
||||
<string name="home_detection_desc">Detect faces and inspect native 106-point landmarks</string>
|
||||
<string name="home_attribute_desc">Inspect mask, age, quality and facial attributes</string>
|
||||
<string name="model_pikachu" translatable="false">Pikachu</string>
|
||||
<string name="model_megatron" translatable="false">Megatron</string>
|
||||
<string name="menu_badge_rgb" translatable="false">RGB</string>
|
||||
<string name="menu_badge_action" translatable="false">ACT</string>
|
||||
<string name="menu_badge_pose" translatable="false">POSE</string>
|
||||
<string name="menu_badge_compare" translatable="false">1:1</string>
|
||||
<string name="menu_badge_management" translatable="false">DB</string>
|
||||
<string name="menu_badge_recognition" translatable="false">1:N</string>
|
||||
<string name="menu_badge_detection" translatable="false">TRACK</string>
|
||||
<string name="menu_badge_attribute" translatable="false">ATTR</string>
|
||||
<string name="add_symbol" translatable="false">+</string>
|
||||
|
||||
<!-- 1:1 face comparison -->
|
||||
<string name="face_compare_title">Face 1:1</string>
|
||||
<string name="face_compare_heading">Are they the same person?</string>
|
||||
<string name="face_compare_hint">Add one clear image to each side. Face 1 is selected by default; tap any numbered face box to switch, and the comparison updates immediately.</string>
|
||||
<string name="image_a">Image A</string>
|
||||
<string name="image_b">Image B</string>
|
||||
<string name="upload_image_a">Add image A</string>
|
||||
<string name="upload_image_b">Add image B</string>
|
||||
<string name="image_empty">Tap to choose</string>
|
||||
<string name="image_analyzing">Detecting face…</string>
|
||||
<string name="image_face_ready">Face ready</string>
|
||||
<string name="image_no_face">No face found</string>
|
||||
<string name="image_face_selected">Face %1$d of %2$d selected</string>
|
||||
<string name="image_load_failed">Could not load image</string>
|
||||
<string name="face_extract_failed">Feature extraction failed</string>
|
||||
<string name="similarity_title">Similarity</string>
|
||||
<string name="compare_selected_face_a">Selected face crop A</string>
|
||||
<string name="compare_selected_face_b">Selected face crop B</string>
|
||||
<string name="compare_face_a_badge" translatable="false">A</string>
|
||||
<string name="compare_face_b_badge" translatable="false">B</string>
|
||||
<string name="compare_select_two">Select two images</string>
|
||||
<string name="compare_analyzing">Analyzing image…</string>
|
||||
<string name="compare_waiting_face">Add two valid face images</string>
|
||||
<string name="compare_comparing">Comparing faces…</string>
|
||||
<string name="compare_same_person">Likely the same person</string>
|
||||
<string name="compare_different_person">Likely different people</string>
|
||||
<string name="compare_failed">Comparison failed</string>
|
||||
<string name="compare_engine_failed">Face engine unavailable</string>
|
||||
<string name="compare_details">cosine confidence %1$.3f · threshold %2$.3f</string>
|
||||
<string name="on_device_privacy">Images are processed only on this device.</string>
|
||||
|
||||
<!-- Face management -->
|
||||
<string name="face_management_title">Face management</string>
|
||||
<string name="face_library_heading">Face library</string>
|
||||
<string name="face_storage_scope">%1$s uses an isolated feature DB and crop directory.</string>
|
||||
<string name="search_faces">Search name or ID</string>
|
||||
<string name="add_face">Add</string>
|
||||
<string name="no_faces_title">No faces stored</string>
|
||||
<string name="no_faces_hint">Add a portrait to create the first identity in this model library.</string>
|
||||
<string name="stored_face_crop">Stored face crop</string>
|
||||
<string name="face_id_format">ID · %1$d</string>
|
||||
<string name="edit_short">Edit</string>
|
||||
<string name="delete_short">Del</string>
|
||||
<string name="add_face_title">Add face</string>
|
||||
<string name="edit_face_title">Edit face</string>
|
||||
<string name="choose_enrollment_image">Use the camera or choose an image</string>
|
||||
<string name="enrollment_image">Face enrollment image</string>
|
||||
<string name="no_image_selected">No image selected</string>
|
||||
<string name="capture_or_replace_face">Use camera</string>
|
||||
<string name="choose_or_replace_image">Choose from gallery</string>
|
||||
<string name="tap_face_to_select">If multiple faces are detected, tap a box to choose the identity to store.</string>
|
||||
<string name="face_name">Name</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="saving_face">Saving…</string>
|
||||
<string name="face_saved">Face saved</string>
|
||||
<string name="face_save_failed">Could not save this face</string>
|
||||
<string name="face_library_failed">Could not open the model-specific face library</string>
|
||||
<string name="delete_face_title">Delete face?</string>
|
||||
<string name="delete_face_message">Delete %1$s from the current %2$s library? This removes its feature and crop.</string>
|
||||
<string name="delete">Delete</string>
|
||||
<string name="face_deleted">Face deleted</string>
|
||||
<string name="face_delete_failed">Could not delete this face</string>
|
||||
<string name="face_list_count">%1$d stored identities</string>
|
||||
|
||||
<!-- Face recognition -->
|
||||
<string name="face_recognition_title">Face recognition</string>
|
||||
<string name="face_recognition_heading">Who is this?</string>
|
||||
<string name="face_recognition_hint">Search a face against identities enrolled for the current model.</string>
|
||||
<string name="recognition_tab_photo">Photo input</string>
|
||||
<string name="recognition_tab_video">Video stream</string>
|
||||
<string name="recognition_session_parameters">Session parameters</string>
|
||||
<string name="recognition_session_parameters_hint">Applying changes rebuilds the photo Session and saves these settings on this device.</string>
|
||||
<string name="recognition_settings_expand">Expand</string>
|
||||
<string name="recognition_settings_collapse">Collapse</string>
|
||||
<string name="recognition_input_px">Detection input px</string>
|
||||
<string name="recognition_max_faces">Maximum detected faces</string>
|
||||
<string name="recognition_min_face_px">Minimum face px</string>
|
||||
<string name="recognition_apply_session">Apply Session</string>
|
||||
<string name="recognition_reset_defaults">Reset defaults</string>
|
||||
<string name="recognition_session_summary">Session · %1$d px · max %2$d · min %3$d px</string>
|
||||
<string name="recognition_session_rebuilding">Rebuilding Session…</string>
|
||||
<string name="recognition_session_failed">Could not create this Session</string>
|
||||
<string name="recognition_empty_library">No faces are enrolled for the %1$s model. Add identities in Face management first.</string>
|
||||
<string name="recognition_photo">Recognition photo</string>
|
||||
<string name="recognition_selected_face_crop">Selected face crop</string>
|
||||
<string name="recognition_choose_photo_hint">Choose a photo to detect and identify faces</string>
|
||||
<string name="recognition_choose_photo">Choose photo</string>
|
||||
<string name="recognition_multi_face_hint">A single face is searched automatically. For multiple faces, tap a numbered box to switch.</string>
|
||||
<string name="recognition_searching">Searching the current model library…</string>
|
||||
<string name="recognition_match_found">Match found</string>
|
||||
<string name="recognition_no_match">No matching identity</string>
|
||||
<string name="recognition_library_empty_result">The current model library is empty</string>
|
||||
<string name="recognition_result_name_unknown">Unknown</string>
|
||||
<string name="recognition_result_details">ID · %1$d · cosine confidence %2$.3f · threshold %3$.3f</string>
|
||||
<string name="recognition_no_match_details">cosine confidence %1$.3f · threshold %2$.3f</string>
|
||||
<string name="recognition_no_confidence">No candidate exceeded the search threshold</string>
|
||||
<string name="recognition_video_initializing">Starting camera…</string>
|
||||
<string name="recognition_video_no_face">Place a face in the frame</string>
|
||||
<string name="recognition_video_hold_still">Hold still for recognition</string>
|
||||
<string name="recognition_video_first_face_hint">Only the first tracked face is identified after it stays stable for about 1 second.</string>
|
||||
<string name="value_1" translatable="false">1</string>
|
||||
<string name="value_3" translatable="false">3</string>
|
||||
<string name="value_5" translatable="false">5</string>
|
||||
<string name="value_10" translatable="false">10</string>
|
||||
<string name="value_24" translatable="false">24</string>
|
||||
<string name="value_48" translatable="false">48</string>
|
||||
<string name="value_64" translatable="false">64</string>
|
||||
<string name="value_128" translatable="false">128</string>
|
||||
<string name="value_320" translatable="false">320</string>
|
||||
<string name="value_640" translatable="false">640</string>
|
||||
<string name="value_1280" translatable="false">1280</string>
|
||||
|
||||
<!-- Face detection and tracking -->
|
||||
<string name="face_detection_title">Face tracking</string>
|
||||
<string name="face_detection_heading">Find every face</string>
|
||||
<string name="face_detection_hint">Detect selectable faces and inspect the SDK native 106-point dense landmarks.</string>
|
||||
<string name="detection_tab_image">Image detection</string>
|
||||
<string name="detection_tab_tracking">Video tracking</string>
|
||||
<string name="detection_show_dense_landmarks">Show 106 dense landmarks</string>
|
||||
<string name="detection_photo">Face detection photo</string>
|
||||
<string name="detection_choose_photo_hint">Choose a photo to detect faces and landmarks</string>
|
||||
<string name="detection_choose_photo">Choose detection photo</string>
|
||||
<string name="detection_multi_face_hint">Face 1 is selected by default. Tap any numbered box to inspect that face.</string>
|
||||
<string name="detection_landmark_behavior_hint">Large faces render landmarks on the source; small faces use the bottom-right magnifier.</string>
|
||||
<string name="detection_face_selected">Face %1$d of %2$d selected · %3$d landmarks returned</string>
|
||||
<string name="face_detection_failed">Face detection failed</string>
|
||||
<string name="detection_landmarks_hidden">106 landmarks are hidden</string>
|
||||
<string name="detection_landmarks_failed">Could not read 106 landmarks for this face</string>
|
||||
<string name="detection_landmarks_on_source">Large face · %1$d%% of image · landmarks shown on source</string>
|
||||
<string name="detection_landmarks_in_magnifier">Small face · %1$d%% of image · landmarks enlarged at bottom right</string>
|
||||
<string name="detection_landmark_magnifier">Selected face magnifier</string>
|
||||
<string name="detection_106_badge" translatable="false">106 PTS</string>
|
||||
<string name="detection_tracking_initializing">Starting camera tracking…</string>
|
||||
<string name="detection_tracking_restarting">Applying settings and restarting camera…</string>
|
||||
<string name="detection_tracking_stats">%1$.1f FPS · %2$d ms</string>
|
||||
<string name="detection_tracking_settings">Tracking Session parameters</string>
|
||||
<string name="detection_tracking_settings_hint">Changes are saved automatically and restart the camera stream.</string>
|
||||
<string name="detection_tracking_mode">Tracking mode</string>
|
||||
<string name="detection_tracking_mode_light">Light</string>
|
||||
<string name="detection_tracking_mode_tbd">Track-by-detection</string>
|
||||
<string name="detection_tracking_summary">%1$s · %2$d px · max %3$d · min %4$d px</string>
|
||||
<string name="detection_tracking_session_failed">Could not create this tracking Session</string>
|
||||
|
||||
<!-- Face attributes -->
|
||||
<string name="face_attribute_title">Face attributes</string>
|
||||
<string name="face_attribute_heading">Read facial attributes</string>
|
||||
<string name="face_attribute_hint">Choose a photo, then tap any numbered face to update its attributes.</string>
|
||||
<string name="attribute_photo">Face attribute photo</string>
|
||||
<string name="attribute_choose_photo_hint">Choose a photo to analyze face attributes</string>
|
||||
<string name="attribute_choose_photo">Choose photo</string>
|
||||
<string name="attribute_multi_face_hint">Face 1 is selected by default. Small selected faces appear in the bottom-right magnifier.</string>
|
||||
<string name="attribute_selected_face_crop">Selected face magnifier</string>
|
||||
<string name="attribute_face_selected">Face %1$d of %2$d selected</string>
|
||||
<string name="attribute_result_title">Selected face</string>
|
||||
<string name="attribute_result_empty">Choose a photo to view attributes</string>
|
||||
<string name="attribute_analyzing">Analyzing attributes…</string>
|
||||
<string name="attribute_analysis_failed">Could not analyze face attributes</string>
|
||||
<string name="attribute_no_face_result">No face attributes available</string>
|
||||
<string name="attribute_mask">Wearing mask</string>
|
||||
<string name="attribute_age">Age bracket</string>
|
||||
<string name="attribute_quality">Image quality score</string>
|
||||
<string name="attribute_expression">Expression</string>
|
||||
<string name="attribute_race">Ethnicity</string>
|
||||
<string name="attribute_gender">Gender</string>
|
||||
<string name="attribute_left_eye">Left eye</string>
|
||||
<string name="attribute_right_eye">Right eye</string>
|
||||
<string name="attribute_mask_yes">Yes</string>
|
||||
<string name="attribute_mask_no">No</string>
|
||||
<string name="attribute_eye_open">Open</string>
|
||||
<string name="attribute_eye_closed">Closed</string>
|
||||
<string name="attribute_expression_neutral">Neutral</string>
|
||||
<string name="attribute_expression_mouth_open">Mouth open</string>
|
||||
<string name="attribute_gender_female">Female</string>
|
||||
<string name="attribute_gender_male">Male</string>
|
||||
<string name="attribute_race_black">Black</string>
|
||||
<string name="attribute_race_asian">Asian</string>
|
||||
<string name="attribute_race_latino">Latino / Hispanic</string>
|
||||
<string name="attribute_race_middle_eastern">Middle Eastern</string>
|
||||
<string name="attribute_race_white">White</string>
|
||||
<string name="attribute_age_0_2">0–2</string>
|
||||
<string name="attribute_age_3_9">3–9</string>
|
||||
<string name="attribute_age_10_19">10–19</string>
|
||||
<string name="attribute_age_20_29">20–29</string>
|
||||
<string name="attribute_age_30_39">30–39</string>
|
||||
<string name="attribute_age_40_49">40–49</string>
|
||||
<string name="attribute_age_50_59">50–59</string>
|
||||
<string name="attribute_age_60_69">60–69</string>
|
||||
<string name="attribute_age_70_plus">70+</string>
|
||||
<string name="attribute_score_format" translatable="false">%1$.3f</string>
|
||||
<string name="attribute_unknown">—</string>
|
||||
|
||||
<!-- Camera face enrollment -->
|
||||
<string name="face_capture_title">Camera enrollment</string>
|
||||
<string name="capture_permission_hint">Allow camera access, then try again.</string>
|
||||
<string name="capture_no_face">Place a face in the frame</string>
|
||||
<string name="capture_first_face_hint">The first detected face is tracked. Keep it clear and centered.</string>
|
||||
<string name="capture_move_closer">Move closer</string>
|
||||
<string name="capture_hold_still">Hold still</string>
|
||||
<string name="capture_warmup_hint">Stay steady for 1 second to start the ring.</string>
|
||||
<string name="capture_keep_still">Keep holding still</string>
|
||||
<string name="capture_progress">Stability %1$d%%</string>
|
||||
<string name="capture_complete">Capture complete</string>
|
||||
<string name="capture_complete_hint">The ring is full. Preparing this face…</string>
|
||||
<string name="capture_failed">Could not capture this frame</string>
|
||||
<string name="capture_retry_hint">Hold still and the app will try again.</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,44 @@
|
||||
</style>
|
||||
|
||||
<style name="Theme.InspireFaceExample" parent="Base.Theme.InspireFaceExample" />
|
||||
</resources>
|
||||
|
||||
<style name="Theme.InspireFaceExample.Home">
|
||||
<item name="android:windowBackground">@color/home_background</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@color/home_background</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
|
||||
<!-- Full-bleed dark theme for the camera liveness screen -->
|
||||
<style name="Theme.InspireFaceExample.Liveness">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
|
||||
<style name="AttributeResultRow">
|
||||
<item name="android:layout_width">match_parent</item>
|
||||
<item name="android:layout_height">48dp</item>
|
||||
<item name="android:gravity">center_vertical</item>
|
||||
<item name="android:orientation">horizontal</item>
|
||||
</style>
|
||||
|
||||
<style name="AttributeResultLabel">
|
||||
<item name="android:layout_width">0dp</item>
|
||||
<item name="android:layout_height">wrap_content</item>
|
||||
<item name="android:layout_weight">1</item>
|
||||
<item name="android:textColor">@color/home_text_secondary</item>
|
||||
<item name="android:textSize">14sp</item>
|
||||
</style>
|
||||
|
||||
<style name="AttributeResultValue">
|
||||
<item name="android:layout_width">0dp</item>
|
||||
<item name="android:layout_height">wrap_content</item>
|
||||
<item name="android:layout_weight">1</item>
|
||||
<item name="android:gravity">end</item>
|
||||
<item name="android:textColor">@color/liveness_accent</item>
|
||||
<item name="android:textSize">15sp</item>
|
||||
<item name="android:textStyle">bold</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older that API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
<!-- Android 7–11 equivalent of data_extraction_rules.xml. -->
|
||||
<exclude domain="file" path="face_hub/" />
|
||||
<exclude domain="sharedpref" path="face_records_Pikachu.xml" />
|
||||
<exclude domain="sharedpref" path="face_records_Megatron.xml" />
|
||||
<exclude domain="sharedpref" path="camera_permission.xml" />
|
||||
</full-backup-content>
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
<!-- Face crops, native feature databases, identity metadata and the per-device
|
||||
permission notice state must never leave this device through backup/transfer. -->
|
||||
<cloud-backup disableIfNoEncryptionCapabilities="true">
|
||||
<exclude domain="file" path="face_hub/" />
|
||||
<exclude domain="sharedpref" path="face_records_Pikachu.xml" />
|
||||
<exclude domain="sharedpref" path="face_records_Megatron.xml" />
|
||||
<exclude domain="sharedpref" path="camera_permission.xml" />
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
<exclude domain="file" path="face_hub/" />
|
||||
<exclude domain="sharedpref" path="face_records_Pikachu.xml" />
|
||||
<exclude domain="sharedpref" path="face_records_Megatron.xml" />
|
||||
<exclude domain="sharedpref" path="camera_permission.xml" />
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
</data-extraction-rules>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="en" />
|
||||
<locale android:name="zh" />
|
||||
</locale-config>
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.inspireface_example;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class DetectorDefaultsTest {
|
||||
|
||||
@Test
|
||||
public void allSessionDefaultsUse320InputPixels() {
|
||||
assertEquals(320, DetectorDefaults.INPUT_PX);
|
||||
assertEquals(DetectorDefaults.INPUT_PX,
|
||||
StillImageSessionSettings.defaults().inputPx);
|
||||
assertEquals(DetectorDefaults.INPUT_PX,
|
||||
FaceTrackingSessionSettings.defaults().inputPx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.example.inspireface_example.permission;
|
||||
|
||||
import static com.example.inspireface_example.permission.CameraPermissionPolicy.Action.GRANTED;
|
||||
import static com.example.inspireface_example.permission.CameraPermissionPolicy.Action.OPEN_SETTINGS;
|
||||
import static com.example.inspireface_example.permission.CameraPermissionPolicy.Action.REQUEST_SYSTEM_PERMISSION;
|
||||
import static com.example.inspireface_example.permission.CameraPermissionPolicy.Action.SHOW_NOTICE;
|
||||
import static com.example.inspireface_example.permission.CameraPermissionPolicy.Action.SHOW_RATIONALE;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class CameraPermissionPolicyTest {
|
||||
|
||||
@Test
|
||||
public void firstCameraUseAlwaysShowsPrivacyNotice() {
|
||||
assertEquals(SHOW_NOTICE,
|
||||
CameraPermissionPolicy.nextAction(false, false, false, false));
|
||||
assertEquals(SHOW_NOTICE,
|
||||
CameraPermissionPolicy.nextAction(false, true, true, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptedNoticeAndGrantedPermissionStartsCamera() {
|
||||
assertEquals(GRANTED,
|
||||
CameraPermissionPolicy.nextAction(true, true, true, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void firstAndroidPermissionRequestUsesSystemDialog() {
|
||||
assertEquals(REQUEST_SYSTEM_PERMISSION,
|
||||
CameraPermissionPolicy.nextAction(true, false, false, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retryableDenialShowsRationale() {
|
||||
assertEquals(SHOW_RATIONALE,
|
||||
CameraPermissionPolicy.nextAction(true, false, true, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void permanentDenialRequiresApplicationSettings() {
|
||||
assertEquals(OPEN_SETTINGS,
|
||||
CameraPermissionPolicy.nextAction(true, false, true, false));
|
||||
}
|
||||
}
|
||||