feat:添加识别策略
This commit is contained in:
1 parent
0d0924c314
commit
1c348fc18e
20 files changed
+1500
-74
No files matched your search
+25
@@ -25,6 +25,8 @@ import io.flutter.plugin.common.MethodCall;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
import io.flutter.plugins.facediscern.session.FaceRecognitionSessionManager;
|
||||
import io.flutter.plugins.facediscern.tool.face.constants.FaceBrodConstant;
|
||||
import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfiguration;
|
||||
import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfigurationStore;
|
||||
import io.flutter.plugins.facediscern.ui.FaceDiscern;
|
||||
import io.flutter.view.TextureRegistry;
|
||||
|
||||
@@ -199,6 +201,29 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
|
||||
case "getRecognitionState":
|
||||
result.success(sessionManager.getState());
|
||||
break;
|
||||
case "getRecognizeConfiguration":
|
||||
result.success(RecognizeConfigurationStore.toMap(RecognizeConfigurationStore.load(context)));
|
||||
break;
|
||||
case "setRecognizeConfiguration":
|
||||
try {
|
||||
RecognizeConfiguration configuration = RecognizeConfigurationStore.save(context, call.arguments instanceof Map ? (Map<?, ?>) call.arguments : null);
|
||||
sessionManager.onRecognitionConfigurationChanged();
|
||||
result.success(RecognizeConfigurationStore.toMap(configuration));
|
||||
} catch (IllegalArgumentException e) {
|
||||
result.error("INVALID_RECOGNIZE_CONFIGURATION", e.getMessage(), null);
|
||||
} catch (Exception e) {
|
||||
result.error("SAVE_RECOGNIZE_CONFIGURATION_FAILED", e.getMessage(), null);
|
||||
}
|
||||
break;
|
||||
case "resetRecognizeConfiguration":
|
||||
try {
|
||||
RecognizeConfiguration configuration = RecognizeConfigurationStore.reset(context);
|
||||
sessionManager.onRecognitionConfigurationChanged();
|
||||
result.success(RecognizeConfigurationStore.toMap(configuration));
|
||||
} catch (Exception e) {
|
||||
result.error("RESET_RECOGNIZE_CONFIGURATION_FAILED", e.getMessage(), null);
|
||||
}
|
||||
break;
|
||||
case "active":
|
||||
String appId = stringArgument(call, "appId");
|
||||
String activeKey = stringArgument(call, "activeKey");
|
||||
|
||||
+57
-21
@@ -10,8 +10,6 @@ import android.hardware.Camera;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
import android.view.Surface;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
@@ -103,6 +101,7 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
private long revision;
|
||||
private long sessionGeneration;
|
||||
private long lastFaceBoxEmitAt;
|
||||
private boolean recognitionConfigurationDirty;
|
||||
private Handler warmReleaseHandler = new Handler(Looper.getMainLooper());
|
||||
private final Runnable warmReleaseTask = this::releaseWarmResources;
|
||||
|
||||
@@ -152,6 +151,7 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
|
||||
public Map<String, Object> startRecognition(String sessionId, String scene, Listener owner) throws Exception {
|
||||
if (sessionId == null || sessionId.trim().isEmpty()) throw new IllegalArgumentException("缺少人脸会话编号");
|
||||
boolean reloadConfiguration;
|
||||
synchronized (sessionLock) {
|
||||
if (activeSessionId != null && !Objects.equals(activeSessionId, sessionId)) {
|
||||
throw new SessionBusyException(activeSessionId);
|
||||
@@ -163,9 +163,12 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
activeOwner = owner;
|
||||
revision = 0;
|
||||
state = State.INITIALIZING;
|
||||
reloadConfiguration = recognitionConfigurationDirty;
|
||||
emitState("正在启动摄像头", null, null);
|
||||
}
|
||||
try {
|
||||
// 保存配置时不打断上一位顾客;下一次 Session 启动前再重建引擎并读取新值。
|
||||
if (reloadConfiguration) releaseResources(false, false);
|
||||
ensureWarmResources();
|
||||
resetProcessorForSession(sessionId);
|
||||
} catch (Exception error) {
|
||||
@@ -175,7 +178,8 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
emitState(error.getMessage() == null ? "人脸识别初始化失败" : error.getMessage(), null, "INITIALIZATION_FAILED");
|
||||
}
|
||||
}
|
||||
releaseResources(true, true);
|
||||
// 初始化失败仍保留业务 Session,用户点击重试时可以在同一 Session 内重新冷启动。
|
||||
releaseResources(false, false);
|
||||
throw error;
|
||||
}
|
||||
synchronized (sessionLock) {
|
||||
@@ -225,6 +229,11 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
processorToStop = detachActiveSession();
|
||||
}
|
||||
stopProcessor(processorToStop);
|
||||
if (isRecognitionConfigurationDirty()) {
|
||||
releaseResources(true, true);
|
||||
} else {
|
||||
emitResourceState("摄像头热保持中");
|
||||
}
|
||||
synchronized (sessionLock) {
|
||||
return snapshot();
|
||||
}
|
||||
@@ -238,6 +247,11 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
processorToStop = detachActiveSession();
|
||||
}
|
||||
stopProcessor(processorToStop);
|
||||
if (isRecognitionConfigurationDirty()) {
|
||||
releaseResources(true, true);
|
||||
} else {
|
||||
emitResourceState("摄像头热保持中");
|
||||
}
|
||||
synchronized (sessionLock) {
|
||||
return snapshot();
|
||||
}
|
||||
@@ -262,6 +276,19 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 空闲时立即释放热资源,让下次识别读取新配置;活动识别则延迟到当前顾客结束,避免中断支付。
|
||||
*/
|
||||
public void onRecognitionConfigurationChanged() {
|
||||
boolean releaseNow;
|
||||
synchronized (sessionLock) {
|
||||
recognitionConfigurationDirty = true;
|
||||
releaseNow = activeSessionId == null;
|
||||
}
|
||||
// 设置页没有预览纹理占用,空闲时立即释放可避免下一次创建纹理后再重建 Renderer。
|
||||
if (releaseNow) releaseResources(true, true);
|
||||
}
|
||||
|
||||
public void releaseForLowMemory() {
|
||||
synchronized (sessionLock) {
|
||||
if (activeSessionId != null) {
|
||||
@@ -314,7 +341,8 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
Camera.CameraInfo info = new Camera.CameraInfo();
|
||||
Camera.getCameraInfo(cameraId, info);
|
||||
mirror = info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT;
|
||||
displayOrientation = calculateDisplayOrientation(info, currentDisplayRotation());
|
||||
// OES SurfaceTexture 默认不会应用 Camera.setDisplayOrientation,Flutter 端只需补偿传感器方向。
|
||||
displayOrientation = info.orientation;
|
||||
camera = Camera.open(cameraId);
|
||||
Camera.Parameters parameters = camera.getParameters();
|
||||
previewSize = choosePreviewSize(parameters.getSupportedPreviewSizes());
|
||||
@@ -329,7 +357,9 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
}
|
||||
}
|
||||
camera.setParameters(parameters);
|
||||
camera.setDisplayOrientation(displayOrientation);
|
||||
// OES 输出保持传感器原始方向,主、副两个 Flutter Texture 统一按 rotation 字段旋转;
|
||||
// 若此处同时 setDisplayOrientation,会在部分设备上和 Flutter RotatedBox 叠加成双重旋转。
|
||||
camera.setDisplayOrientation(0);
|
||||
Camera openedCamera = camera;
|
||||
camera.setErrorCallback((error, callbackCamera) -> mainHandler.post(() -> {
|
||||
synchronized (sessionLock) {
|
||||
@@ -547,6 +577,7 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
previewRenderer = null;
|
||||
previewSize = null;
|
||||
warmResourcesReady = false;
|
||||
recognitionConfigurationDirty = false;
|
||||
if (clearActiveSession) {
|
||||
activeSessionId = null;
|
||||
activeScene = null;
|
||||
@@ -573,6 +604,8 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
}
|
||||
}
|
||||
if (rendererToRelease != null) rendererToRelease.release();
|
||||
// Camera 真正释放后再广播资源快照,避免顶栏在 20 分钟超时后仍显示为开启。
|
||||
emitResourceState(state == State.COLD ? "摄像头已关闭" : "摄像头资源已释放");
|
||||
}
|
||||
|
||||
private void ensureOwner(String sessionId) {
|
||||
@@ -581,6 +614,12 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isRecognitionConfigurationDirty() {
|
||||
synchronized (sessionLock) {
|
||||
return recognitionConfigurationDirty;
|
||||
}
|
||||
}
|
||||
|
||||
private void emitState(String message, List<Map<String, Double>> boxes, String errorCode) {
|
||||
Map<String, Object> event = snapshot();
|
||||
event.put("message", message);
|
||||
@@ -590,6 +629,19 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
for (Listener listener : listeners) listener.onSessionEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源状态不增加业务 revision,活动 Session 会忽略它,常驻设备状态仍能获知 Camera 开关变化。
|
||||
*/
|
||||
private void emitResourceState(String message) {
|
||||
Map<String, Object> event;
|
||||
synchronized (sessionLock) {
|
||||
event = snapshot();
|
||||
event.put("message", message);
|
||||
event.put("faceBoxes", new ArrayList<>());
|
||||
}
|
||||
for (Listener listener : listeners) listener.onSessionEvent(event);
|
||||
}
|
||||
|
||||
private Map<String, Object> snapshot() {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("sessionId", activeSessionId == null ? "" : activeSessionId);
|
||||
@@ -635,22 +687,6 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba
|
||||
return best;
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private int calculateDisplayOrientation(Camera.CameraInfo info, int rotation) {
|
||||
int degrees = rotation * 90;
|
||||
if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
|
||||
int result = (info.orientation + degrees) % 360;
|
||||
return (360 - result) % 360;
|
||||
}
|
||||
return (info.orientation - degrees + 360) % 360;
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private int currentDisplayRotation() {
|
||||
WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
|
||||
return windowManager == null ? Surface.ROTATION_0 : windowManager.getDefaultDisplay().getRotation();
|
||||
}
|
||||
|
||||
public static final class SessionBusyException extends Exception {
|
||||
private final String activeSessionId;
|
||||
|
||||
|
||||
+25
-12
@@ -6,6 +6,19 @@ import com.arcsoft.face.LivenessParam;
|
||||
* 识别相关的配置项
|
||||
*/
|
||||
public class RecognizeConfiguration {
|
||||
public static final int DEFAULT_EXTRACT_RETRY_COUNT = 3;
|
||||
public static final int DEFAULT_LIVENESS_RETRY_COUNT = 3;
|
||||
public static final int DEFAULT_MAX_DETECT_FACES = 1;
|
||||
public static final int DEFAULT_RECOGNIZE_FAILED_RETRY_INTERVAL = 0;
|
||||
public static final int DEFAULT_LIVENESS_FAILED_RETRY_INTERVAL = 0;
|
||||
public static final float DEFAULT_SIMILAR_THRESHOLD = 0.8F;
|
||||
public static final float DEFAULT_IMAGE_QUALITY_NO_MASK_THRESHOLD = 0.49F;
|
||||
public static final float DEFAULT_IMAGE_QUALITY_MASK_THRESHOLD = 0.29F;
|
||||
public static final float DEFAULT_RGB_LIVENESS_THRESHOLD = 0.5F;
|
||||
public static final boolean DEFAULT_ENABLE_LIVENESS = false;
|
||||
public static final boolean DEFAULT_ENABLE_IMAGE_QUALITY = true;
|
||||
public static final boolean DEFAULT_KEEP_MAX_FACE = true;
|
||||
|
||||
/**
|
||||
* 产生特征提取失败示语的特征提取次数(小于该值不提示)
|
||||
*/
|
||||
@@ -100,23 +113,23 @@ public class RecognizeConfiguration {
|
||||
|
||||
//TODO: demo不实现所有配置,若以下项也需要进行自定义配置,可参考其他配置项实现
|
||||
public static class Builder {
|
||||
private int extractRetryCount = 3;
|
||||
private int livenessRetryCount = 3;
|
||||
private int maxDetectFaces = 3;
|
||||
private int recognizeFailedRetryInterval = 0;
|
||||
private int livenessFailedRetryInterval = 0;
|
||||
private float similarThreshold = 0.8f;
|
||||
private float imageQualityNoMaskRecognizeThreshold = 0.49f;
|
||||
private float imageQualityMaskRecognizeThreshold = 0.29f;
|
||||
private boolean enableLiveness = false;
|
||||
private int extractRetryCount = DEFAULT_EXTRACT_RETRY_COUNT;
|
||||
private int livenessRetryCount = DEFAULT_LIVENESS_RETRY_COUNT;
|
||||
private int maxDetectFaces = DEFAULT_MAX_DETECT_FACES;
|
||||
private int recognizeFailedRetryInterval = DEFAULT_RECOGNIZE_FAILED_RETRY_INTERVAL;
|
||||
private int livenessFailedRetryInterval = DEFAULT_LIVENESS_FAILED_RETRY_INTERVAL;
|
||||
private float similarThreshold = DEFAULT_SIMILAR_THRESHOLD;
|
||||
private float imageQualityNoMaskRecognizeThreshold = DEFAULT_IMAGE_QUALITY_NO_MASK_THRESHOLD;
|
||||
private float imageQualityMaskRecognizeThreshold = DEFAULT_IMAGE_QUALITY_MASK_THRESHOLD;
|
||||
private boolean enableLiveness = DEFAULT_ENABLE_LIVENESS;
|
||||
private boolean enableFaceAreaLimit = false;
|
||||
private boolean enableImageQuality = false;
|
||||
private boolean enableImageQuality = DEFAULT_ENABLE_IMAGE_QUALITY;
|
||||
private boolean enableFaceSizeLimit = false;
|
||||
private boolean enableFaceMoveLimit = false;
|
||||
private int faceSizeLimit = 0;
|
||||
private int faceMoveLimit = 0;
|
||||
private boolean keepMaxFace = false;
|
||||
private LivenessParam livenessParam;
|
||||
private boolean keepMaxFace = DEFAULT_KEEP_MAX_FACE;
|
||||
private LivenessParam livenessParam = new LivenessParam(DEFAULT_RGB_LIVENESS_THRESHOLD, DEFAULT_RGB_LIVENESS_THRESHOLD);
|
||||
|
||||
public Builder recognizeFailedRetryInterval(int val) {
|
||||
this.recognizeFailedRetryInterval = val;
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package io.flutter.plugins.facediscern.tool.face.model;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import com.arcsoft.face.LivenessParam;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 保存终端本机的人脸识别策略,不写入业务数据库,避免配置升级影响 Floor 与人脸共享库。
|
||||
*/
|
||||
public final class RecognizeConfigurationStore {
|
||||
private static final String PREFERENCES_NAME = "face_recognize_configuration";
|
||||
|
||||
private RecognizeConfigurationStore() {
|
||||
}
|
||||
|
||||
public static RecognizeConfiguration load(Context context) {
|
||||
SharedPreferences preferences = preferences(context);
|
||||
return new RecognizeConfiguration.Builder()
|
||||
.enableLiveness(preferences.getBoolean("enableLiveness", RecognizeConfiguration.DEFAULT_ENABLE_LIVENESS))
|
||||
.similarThreshold(preferences.getFloat("similarThreshold", RecognizeConfiguration.DEFAULT_SIMILAR_THRESHOLD))
|
||||
.livenessParam(new LivenessParam(
|
||||
preferences.getFloat("rgbLivenessThreshold", RecognizeConfiguration.DEFAULT_RGB_LIVENESS_THRESHOLD),
|
||||
RecognizeConfiguration.DEFAULT_RGB_LIVENESS_THRESHOLD))
|
||||
.enableImageQuality(preferences.getBoolean("enableImageQuality", RecognizeConfiguration.DEFAULT_ENABLE_IMAGE_QUALITY))
|
||||
.imageQualityNoMaskRecognizeThreshold(preferences.getFloat("imageQualityNoMaskRecognizeThreshold", RecognizeConfiguration.DEFAULT_IMAGE_QUALITY_NO_MASK_THRESHOLD))
|
||||
.imageQualityMaskRecognizeThreshold(preferences.getFloat("imageQualityMaskRecognizeThreshold", RecognizeConfiguration.DEFAULT_IMAGE_QUALITY_MASK_THRESHOLD))
|
||||
.keepMaxFace(preferences.getBoolean("keepMaxFace", RecognizeConfiguration.DEFAULT_KEEP_MAX_FACE))
|
||||
.maxDetectFaces(preferences.getInt("maxDetectFaces", RecognizeConfiguration.DEFAULT_MAX_DETECT_FACES))
|
||||
.extractRetryCount(preferences.getInt("extractRetryCount", RecognizeConfiguration.DEFAULT_EXTRACT_RETRY_COUNT))
|
||||
.livenessRetryCount(preferences.getInt("livenessRetryCount", RecognizeConfiguration.DEFAULT_LIVENESS_RETRY_COUNT))
|
||||
.recognizeFailedRetryInterval(preferences.getInt("recognizeFailedRetryInterval", RecognizeConfiguration.DEFAULT_RECOGNIZE_FAILED_RETRY_INTERVAL))
|
||||
.livenessFailedRetryInterval(preferences.getInt("livenessFailedRetryInterval", RecognizeConfiguration.DEFAULT_LIVENESS_FAILED_RETRY_INTERVAL))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Flutter 只传允许现场调整的白名单字段,原生再次校验范围,防止异常参数拖垮识别线程。
|
||||
*/
|
||||
public static RecognizeConfiguration save(Context context, Map<?, ?> values) {
|
||||
RecognizeConfiguration current = load(context);
|
||||
boolean enableLiveness = booleanValue(values, "enableLiveness", current.isEnableLiveness());
|
||||
float similarThreshold = floatValue(values, "similarThreshold", current.getSimilarThreshold(), 0.5F, 0.99F);
|
||||
float rgbLivenessThreshold = floatValue(values, "rgbLivenessThreshold", rgbLivenessThreshold(current), 0.3F, 0.9F);
|
||||
boolean enableImageQuality = booleanValue(values, "enableImageQuality", current.isEnableImageQuality());
|
||||
float noMaskThreshold = floatValue(values, "imageQualityNoMaskRecognizeThreshold", current.getImageQualityNoMaskRecognizeThreshold(), 0F, 1F);
|
||||
float maskThreshold = floatValue(values, "imageQualityMaskRecognizeThreshold", current.getImageQualityMaskRecognizeThreshold(), 0F, 1F);
|
||||
boolean keepMaxFace = booleanValue(values, "keepMaxFace", current.isKeepMaxFace());
|
||||
int maxDetectFaces = intValue(values, "maxDetectFaces", current.getMaxDetectFaces(), 1, 3);
|
||||
int extractRetryCount = intValue(values, "extractRetryCount", current.getExtractRetryCount(), 0, 10);
|
||||
int livenessRetryCount = intValue(values, "livenessRetryCount", current.getLivenessRetryCount(), 0, 10);
|
||||
int recognizeRetryInterval = intValue(values, "recognizeFailedRetryInterval", current.getRecognizeFailedRetryInterval(), 0, 5000);
|
||||
int livenessRetryInterval = intValue(values, "livenessFailedRetryInterval", current.getLivenessFailedRetryInterval(), 0, 5000);
|
||||
|
||||
boolean saved = preferences(context).edit()
|
||||
.putBoolean("enableLiveness", enableLiveness)
|
||||
.putFloat("similarThreshold", similarThreshold)
|
||||
.putFloat("rgbLivenessThreshold", rgbLivenessThreshold)
|
||||
.putBoolean("enableImageQuality", enableImageQuality)
|
||||
.putFloat("imageQualityNoMaskRecognizeThreshold", noMaskThreshold)
|
||||
.putFloat("imageQualityMaskRecognizeThreshold", maskThreshold)
|
||||
.putBoolean("keepMaxFace", keepMaxFace)
|
||||
.putInt("maxDetectFaces", maxDetectFaces)
|
||||
.putInt("extractRetryCount", extractRetryCount)
|
||||
.putInt("livenessRetryCount", livenessRetryCount)
|
||||
.putInt("recognizeFailedRetryInterval", recognizeRetryInterval)
|
||||
.putInt("livenessFailedRetryInterval", livenessRetryInterval)
|
||||
.commit();
|
||||
if (!saved) throw new IllegalStateException("人脸识别配置保存失败");
|
||||
return load(context);
|
||||
}
|
||||
|
||||
public static RecognizeConfiguration reset(Context context) {
|
||||
if (!preferences(context).edit().clear().commit()) {
|
||||
throw new IllegalStateException("人脸识别配置恢复失败");
|
||||
}
|
||||
return load(context);
|
||||
}
|
||||
|
||||
public static Map<String, Object> toMap(RecognizeConfiguration configuration) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("enableLiveness", configuration.isEnableLiveness());
|
||||
result.put("similarThreshold", configuration.getSimilarThreshold());
|
||||
result.put("rgbLivenessThreshold", rgbLivenessThreshold(configuration));
|
||||
result.put("enableImageQuality", configuration.isEnableImageQuality());
|
||||
result.put("imageQualityNoMaskRecognizeThreshold", configuration.getImageQualityNoMaskRecognizeThreshold());
|
||||
result.put("imageQualityMaskRecognizeThreshold", configuration.getImageQualityMaskRecognizeThreshold());
|
||||
result.put("keepMaxFace", configuration.isKeepMaxFace());
|
||||
result.put("maxDetectFaces", configuration.getMaxDetectFaces());
|
||||
result.put("extractRetryCount", configuration.getExtractRetryCount());
|
||||
result.put("livenessRetryCount", configuration.getLivenessRetryCount());
|
||||
result.put("recognizeFailedRetryInterval", configuration.getRecognizeFailedRetryInterval());
|
||||
result.put("livenessFailedRetryInterval", configuration.getLivenessFailedRetryInterval());
|
||||
result.put("livenessType", "RGB");
|
||||
return result;
|
||||
}
|
||||
|
||||
private static SharedPreferences preferences(Context context) {
|
||||
return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
private static float rgbLivenessThreshold(RecognizeConfiguration configuration) {
|
||||
LivenessParam param = configuration.getLivenessParam();
|
||||
return param == null ? RecognizeConfiguration.DEFAULT_RGB_LIVENESS_THRESHOLD : param.getRgbThreshold();
|
||||
}
|
||||
|
||||
private static boolean booleanValue(Map<?, ?> values, String key, boolean fallback) {
|
||||
Object value = values == null ? null : values.get(key);
|
||||
if (value == null) return fallback;
|
||||
if (!(value instanceof Boolean)) throw new IllegalArgumentException(key + " 必须为布尔值");
|
||||
return (Boolean) value;
|
||||
}
|
||||
|
||||
private static float floatValue(Map<?, ?> values, String key, float fallback, float min, float max) {
|
||||
Object value = values == null ? null : values.get(key);
|
||||
if (value == null) return fallback;
|
||||
if (!(value instanceof Number)) throw new IllegalArgumentException(key + " 必须为数字");
|
||||
float result = ((Number) value).floatValue();
|
||||
if (Float.isNaN(result) || Float.isInfinite(result) || result < min || result > max) {
|
||||
throw new IllegalArgumentException(key + " 必须在 " + min + " 到 " + max + " 之间");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int intValue(Map<?, ?> values, String key, int fallback, int min, int max) {
|
||||
Object value = values == null ? null : values.get(key);
|
||||
if (value == null) return fallback;
|
||||
if (!(value instanceof Number)) throw new IllegalArgumentException(key + " 必须为整数");
|
||||
int result = ((Number) value).intValue();
|
||||
if (result < min || result > max) {
|
||||
throw new IllegalArgumentException(key + " 必须在 " + min + " 到 " + max + " 之间");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+17
-20
@@ -36,6 +36,7 @@ import io.flutter.plugins.facediscern.tool.face.model.CompareResult;
|
||||
import io.flutter.plugins.facediscern.tool.face.model.FaceEntity;
|
||||
import io.flutter.plugins.facediscern.tool.face.model.FacePreviewInfo;
|
||||
import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfiguration;
|
||||
import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfigurationStore;
|
||||
import io.flutter.plugins.facediscern.tool.face.widget.FaceRectView;
|
||||
import io.flutter.plugins.facediscern.ui.FaceDiscern;
|
||||
|
||||
@@ -141,37 +142,30 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback
|
||||
setContext(context);
|
||||
boolean switchCamera = false;
|
||||
previewConfig = new PreviewConfig(switchCamera ? Camera.CameraInfo.CAMERA_FACING_FRONT : Camera.CameraInfo.CAMERA_FACING_BACK, switchCamera ? Camera.CameraInfo.CAMERA_FACING_BACK : Camera.CameraInfo.CAMERA_FACING_FRONT, 0, 0);
|
||||
// 填入在设置界面设置好的配置信息
|
||||
boolean enableLive = false;
|
||||
boolean enableFaceQualityDetect = true;
|
||||
boolean enableFaceMoveLimit = false;
|
||||
boolean enableFaceSizeLimit = false;
|
||||
int faceSizeLimit = 160;
|
||||
int faceMoveLimit = 20;
|
||||
int maxDetectFaces = 1;
|
||||
boolean keepMaxFace = true;
|
||||
float similarThreshold = 0.8F;
|
||||
float imageQualityNoMaskRecognizeThreshold = 0.49F;
|
||||
float imageQualityMaskRecognizeThreshold = 0.29F;
|
||||
RecognizeConfiguration configuration = new RecognizeConfiguration.Builder().enableFaceMoveLimit(enableFaceMoveLimit).enableFaceSizeLimit(enableFaceSizeLimit).faceSizeLimit(faceSizeLimit).faceMoveLimit(faceMoveLimit).enableLiveness(enableLive).enableImageQuality(enableFaceQualityDetect).maxDetectFaces(maxDetectFaces).keepMaxFace(keepMaxFace).similarThreshold(similarThreshold).imageQualityNoMaskRecognizeThreshold(imageQualityNoMaskRecognizeThreshold).imageQualityMaskRecognizeThreshold(imageQualityMaskRecognizeThreshold).livenessParam(new LivenessParam(0.5F, 0.5F)).build();
|
||||
// 单摄像头终端仅支持 RGB 活体,配置由设置页持久化并在每次冷启动识别引擎时读取。
|
||||
livenessType = LivenessType.RGB;
|
||||
RecognizeConfiguration configuration = RecognizeConfigurationStore.load(context);
|
||||
boolean enableLive = configuration.isEnableLiveness();
|
||||
boolean enableFaceQualityDetect = configuration.isEnableImageQuality();
|
||||
int maxDetectFaces = configuration.getMaxDetectFaces();
|
||||
|
||||
ftEngine = new FaceEngine();
|
||||
ftInitCode.postValue(ftEngine.init(context, DetectMode.ASF_DETECT_MODE_VIDEO, DetectFaceOrientPriority.ASF_OP_ALL_OUT, 1, FaceEngine.ASF_FACE_DETECT));
|
||||
ftInitCode.postValue(ftEngine.init(context, DetectMode.ASF_DETECT_MODE_VIDEO, DetectFaceOrientPriority.ASF_OP_ALL_OUT, maxDetectFaces, FaceEngine.ASF_FACE_DETECT));
|
||||
maskEngine = new FaceEngine();
|
||||
ftInitCode.postValue(maskEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_ALL_OUT, 1, FaceEngine.ASF_MASK_DETECT));
|
||||
ftInitCode.postValue(maskEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_ALL_OUT, maxDetectFaces, FaceEngine.ASF_MASK_DETECT));
|
||||
|
||||
frEngine = new FaceEngine();
|
||||
int frEngineMask = FaceEngine.ASF_FACE_RECOGNITION;
|
||||
if (enableFaceQualityDetect) {
|
||||
frEngineMask |= FaceEngine.ASF_IMAGEQUALITY;
|
||||
}
|
||||
frInitCode.postValue(frEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_0_ONLY, 10, frEngineMask));
|
||||
frInitCode.postValue(frEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_0_ONLY, maxDetectFaces, frEngineMask));
|
||||
//启用活体检测时,才初始化活体引擎
|
||||
if (enableLive) {
|
||||
flEngine = new FaceEngine();
|
||||
int flEngineMask = (livenessType == LivenessType.RGB ? FaceEngine.ASF_LIVENESS : (FaceEngine.ASF_IR_LIVENESS | FaceEngine.ASF_FACE_DETECT));
|
||||
flInitCode.postValue(flEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_ALL_OUT, 10, flEngineMask));
|
||||
LivenessParam livenessParam = new LivenessParam(0.5F, 0.5F);
|
||||
flInitCode.postValue(flEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_ALL_OUT, maxDetectFaces, flEngineMask));
|
||||
LivenessParam livenessParam = configuration.getLivenessParam();
|
||||
flEngine.setLivenessParam(livenessParam);
|
||||
}
|
||||
recognizeConfiguration.setValue(configuration);
|
||||
@@ -179,8 +173,11 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback
|
||||
}
|
||||
|
||||
public boolean isEngineReady() {
|
||||
RecognizeConfiguration configuration = recognizeConfiguration.getValue();
|
||||
return Objects.equals(ftInitCode.getValue(), ErrorInfo.MOK)
|
||||
&& Objects.equals(frInitCode.getValue(), ErrorInfo.MOK);
|
||||
&& Objects.equals(frInitCode.getValue(), ErrorInfo.MOK)
|
||||
&& (configuration == null || !configuration.isEnableLiveness()
|
||||
|| Objects.equals(flInitCode.getValue(), ErrorInfo.MOK));
|
||||
}
|
||||
|
||||
private void initFaceHelper(Camera.Size lastPreviewSize) {
|
||||
@@ -193,7 +190,7 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback
|
||||
}
|
||||
int horizontalOffset = 0;
|
||||
int verticalOffset = 0;
|
||||
int maxDetectFaceNum = 1;
|
||||
int maxDetectFaceNum = recognizeConfiguration.getValue().getMaxDetectFaces();
|
||||
faceHelper = new FaceHelper.Builder().ftEngine(ftEngine).maskEngine(maskEngine).frEngine(frEngine).flEngine(flEngine).frQueueSize(maxDetectFaceNum).flQueueSize(maxDetectFaceNum).previewSize(previewSize).recognizeCallback(faceHelperCallback).recognizeConfiguration(recognizeConfiguration.getValue()).trackedFaceCount(trackedFaceCount == null ? 0 : trackedFaceCount).dualCameraFaceInfoTransformer(faceInfo -> {
|
||||
FaceInfo irFaceInfo = new FaceInfo(faceInfo);
|
||||
irFaceInfo.getRect().offset(horizontalOffset, verticalOffset);
|
||||
|
||||
@@ -14,6 +14,7 @@ import io.flutter.plugins.facediscern.flutter.FlutterFaceDiscernPlugin
|
||||
import io.flutter.plugins.facediscern.flutter.FlutterFaceSubScreenPlugin
|
||||
import io.flutter.plugins.facediscern.session.FaceRecognitionSessionManager
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import com.rex.flutter_subscreen_plugin.FlutterSubScreenProvider
|
||||
import com.rex.flutter_subscreen_plugin.FlutterSubscreenPlugin
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
@@ -69,6 +70,25 @@ class MainActivity : FlutterActivity() {
|
||||
KioskModeManager.enterIfAvailable(this)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
val subScreenProvider = FlutterSubScreenProvider.instance
|
||||
try {
|
||||
// Android 12 以下副屏使用 Overlay Presentation,任务被划掉时必须在
|
||||
// Activity 销毁前同步移除窗口并销毁独立 Engine,不能只依赖 Dart 生命周期回调。
|
||||
try {
|
||||
subScreenProvider.closeSubDisplay()
|
||||
} finally {
|
||||
try {
|
||||
subScreenProvider.flutterEngine?.destroy()
|
||||
} finally {
|
||||
subScreenProvider.onDispose()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
if (level >= TRIM_MEMORY_RUNNING_LOW) {
|
||||
|
||||
@@ -61,6 +61,185 @@ class FaceConfig {
|
||||
Map<String, dynamic> toJson() => _$FaceConfigToJson(this);
|
||||
}
|
||||
|
||||
/// 终端本机的人脸识别策略,对应 Android `RecognizeConfiguration` 的可调白名单。
|
||||
class FaceRecognitionConfiguration {
|
||||
const FaceRecognitionConfiguration({
|
||||
required this.enableLiveness,
|
||||
required this.similarThreshold,
|
||||
required this.rgbLivenessThreshold,
|
||||
required this.enableImageQuality,
|
||||
required this.imageQualityNoMaskRecognizeThreshold,
|
||||
required this.imageQualityMaskRecognizeThreshold,
|
||||
required this.keepMaxFace,
|
||||
required this.maxDetectFaces,
|
||||
required this.extractRetryCount,
|
||||
required this.livenessRetryCount,
|
||||
required this.recognizeFailedRetryInterval,
|
||||
required this.livenessFailedRetryInterval,
|
||||
required this.livenessType,
|
||||
});
|
||||
|
||||
const FaceRecognitionConfiguration.defaults()
|
||||
: enableLiveness = false,
|
||||
similarThreshold = 0.8,
|
||||
rgbLivenessThreshold = 0.5,
|
||||
enableImageQuality = true,
|
||||
imageQualityNoMaskRecognizeThreshold = 0.49,
|
||||
imageQualityMaskRecognizeThreshold = 0.29,
|
||||
keepMaxFace = true,
|
||||
maxDetectFaces = 1,
|
||||
extractRetryCount = 3,
|
||||
livenessRetryCount = 3,
|
||||
recognizeFailedRetryInterval = 0,
|
||||
livenessFailedRetryInterval = 0,
|
||||
livenessType = 'RGB';
|
||||
|
||||
final bool enableLiveness;
|
||||
final double similarThreshold;
|
||||
final double rgbLivenessThreshold;
|
||||
final bool enableImageQuality;
|
||||
final double imageQualityNoMaskRecognizeThreshold;
|
||||
final double imageQualityMaskRecognizeThreshold;
|
||||
final bool keepMaxFace;
|
||||
final int maxDetectFaces;
|
||||
final int extractRetryCount;
|
||||
final int livenessRetryCount;
|
||||
final int recognizeFailedRetryInterval;
|
||||
final int livenessFailedRetryInterval;
|
||||
final String livenessType;
|
||||
|
||||
factory FaceRecognitionConfiguration.fromMap(Map<dynamic, dynamic> map) {
|
||||
const defaults = FaceRecognitionConfiguration.defaults();
|
||||
return FaceRecognitionConfiguration(
|
||||
enableLiveness: _asBoolOr(map['enableLiveness'], defaults.enableLiveness),
|
||||
similarThreshold:
|
||||
_asDouble(map['similarThreshold'], defaults.similarThreshold),
|
||||
rgbLivenessThreshold: _asDouble(
|
||||
map['rgbLivenessThreshold'],
|
||||
defaults.rgbLivenessThreshold,
|
||||
),
|
||||
enableImageQuality:
|
||||
_asBoolOr(map['enableImageQuality'], defaults.enableImageQuality),
|
||||
imageQualityNoMaskRecognizeThreshold: _asDouble(
|
||||
map['imageQualityNoMaskRecognizeThreshold'],
|
||||
defaults.imageQualityNoMaskRecognizeThreshold,
|
||||
),
|
||||
imageQualityMaskRecognizeThreshold: _asDouble(
|
||||
map['imageQualityMaskRecognizeThreshold'],
|
||||
defaults.imageQualityMaskRecognizeThreshold,
|
||||
),
|
||||
keepMaxFace: _asBoolOr(map['keepMaxFace'], defaults.keepMaxFace),
|
||||
maxDetectFaces: _asIntOr(map['maxDetectFaces'], defaults.maxDetectFaces),
|
||||
extractRetryCount:
|
||||
_asIntOr(map['extractRetryCount'], defaults.extractRetryCount),
|
||||
livenessRetryCount:
|
||||
_asIntOr(map['livenessRetryCount'], defaults.livenessRetryCount),
|
||||
recognizeFailedRetryInterval: _asIntOr(
|
||||
map['recognizeFailedRetryInterval'],
|
||||
defaults.recognizeFailedRetryInterval,
|
||||
),
|
||||
livenessFailedRetryInterval: _asIntOr(
|
||||
map['livenessFailedRetryInterval'],
|
||||
defaults.livenessFailedRetryInterval,
|
||||
),
|
||||
livenessType: map['livenessType']?.toString() ?? defaults.livenessType,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'enableLiveness': enableLiveness,
|
||||
'similarThreshold': similarThreshold,
|
||||
'rgbLivenessThreshold': rgbLivenessThreshold,
|
||||
'enableImageQuality': enableImageQuality,
|
||||
'imageQualityNoMaskRecognizeThreshold':
|
||||
imageQualityNoMaskRecognizeThreshold,
|
||||
'imageQualityMaskRecognizeThreshold':
|
||||
imageQualityMaskRecognizeThreshold,
|
||||
'keepMaxFace': keepMaxFace,
|
||||
'maxDetectFaces': maxDetectFaces,
|
||||
'extractRetryCount': extractRetryCount,
|
||||
'livenessRetryCount': livenessRetryCount,
|
||||
'recognizeFailedRetryInterval': recognizeFailedRetryInterval,
|
||||
'livenessFailedRetryInterval': livenessFailedRetryInterval,
|
||||
};
|
||||
|
||||
bool get usesDefaults =>
|
||||
this == const FaceRecognitionConfiguration.defaults();
|
||||
|
||||
FaceRecognitionConfiguration copyWith({
|
||||
bool? enableLiveness,
|
||||
double? similarThreshold,
|
||||
double? rgbLivenessThreshold,
|
||||
bool? enableImageQuality,
|
||||
double? imageQualityNoMaskRecognizeThreshold,
|
||||
double? imageQualityMaskRecognizeThreshold,
|
||||
bool? keepMaxFace,
|
||||
int? maxDetectFaces,
|
||||
int? extractRetryCount,
|
||||
int? livenessRetryCount,
|
||||
int? recognizeFailedRetryInterval,
|
||||
int? livenessFailedRetryInterval,
|
||||
}) {
|
||||
return FaceRecognitionConfiguration(
|
||||
enableLiveness: enableLiveness ?? this.enableLiveness,
|
||||
similarThreshold: similarThreshold ?? this.similarThreshold,
|
||||
rgbLivenessThreshold: rgbLivenessThreshold ?? this.rgbLivenessThreshold,
|
||||
enableImageQuality: enableImageQuality ?? this.enableImageQuality,
|
||||
imageQualityNoMaskRecognizeThreshold:
|
||||
imageQualityNoMaskRecognizeThreshold ??
|
||||
this.imageQualityNoMaskRecognizeThreshold,
|
||||
imageQualityMaskRecognizeThreshold: imageQualityMaskRecognizeThreshold ??
|
||||
this.imageQualityMaskRecognizeThreshold,
|
||||
keepMaxFace: keepMaxFace ?? this.keepMaxFace,
|
||||
maxDetectFaces: maxDetectFaces ?? this.maxDetectFaces,
|
||||
extractRetryCount: extractRetryCount ?? this.extractRetryCount,
|
||||
livenessRetryCount: livenessRetryCount ?? this.livenessRetryCount,
|
||||
recognizeFailedRetryInterval:
|
||||
recognizeFailedRetryInterval ?? this.recognizeFailedRetryInterval,
|
||||
livenessFailedRetryInterval:
|
||||
livenessFailedRetryInterval ?? this.livenessFailedRetryInterval,
|
||||
livenessType: livenessType,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is FaceRecognitionConfiguration &&
|
||||
enableLiveness == other.enableLiveness &&
|
||||
similarThreshold == other.similarThreshold &&
|
||||
rgbLivenessThreshold == other.rgbLivenessThreshold &&
|
||||
enableImageQuality == other.enableImageQuality &&
|
||||
imageQualityNoMaskRecognizeThreshold ==
|
||||
other.imageQualityNoMaskRecognizeThreshold &&
|
||||
imageQualityMaskRecognizeThreshold ==
|
||||
other.imageQualityMaskRecognizeThreshold &&
|
||||
keepMaxFace == other.keepMaxFace &&
|
||||
maxDetectFaces == other.maxDetectFaces &&
|
||||
extractRetryCount == other.extractRetryCount &&
|
||||
livenessRetryCount == other.livenessRetryCount &&
|
||||
recognizeFailedRetryInterval == other.recognizeFailedRetryInterval &&
|
||||
livenessFailedRetryInterval == other.livenessFailedRetryInterval &&
|
||||
livenessType == other.livenessType;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
enableLiveness,
|
||||
similarThreshold,
|
||||
rgbLivenessThreshold,
|
||||
enableImageQuality,
|
||||
imageQualityNoMaskRecognizeThreshold,
|
||||
imageQualityMaskRecognizeThreshold,
|
||||
keepMaxFace,
|
||||
maxDetectFaces,
|
||||
extractRetryCount,
|
||||
livenessRetryCount,
|
||||
recognizeFailedRetryInterval,
|
||||
livenessFailedRetryInterval,
|
||||
livenessType,
|
||||
);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class RegisterAllStorageFaceRes {
|
||||
// 注册成功数量
|
||||
@@ -141,10 +320,7 @@ class FacePluginStatus {
|
||||
final FaceRegistrationResult? lastRegistrationResult;
|
||||
|
||||
bool get ready =>
|
||||
activated &&
|
||||
engineReady &&
|
||||
!registrationInProgress &&
|
||||
libraryConsistent;
|
||||
activated && engineReady && !registrationInProgress && libraryConsistent;
|
||||
|
||||
factory FacePluginStatus.fromMap(Map<dynamic, dynamic> map) {
|
||||
return FacePluginStatus(
|
||||
@@ -277,6 +453,15 @@ bool _asBool(dynamic value) => value == true;
|
||||
|
||||
int _asInt(dynamic value) => value is num ? value.toInt() : 0;
|
||||
|
||||
int _asIntOr(dynamic value, int fallback) =>
|
||||
value is num ? value.toInt() : fallback;
|
||||
|
||||
double _asDouble(dynamic value, double fallback) =>
|
||||
value is num ? value.toDouble() : fallback;
|
||||
|
||||
bool _asBoolOr(dynamic value, bool fallback) =>
|
||||
value is bool ? value : fallback;
|
||||
|
||||
String _asString(dynamic value, String fallback) {
|
||||
final result = value?.toString();
|
||||
return result == null || result.isEmpty ? fallback : result;
|
||||
|
||||
@@ -139,6 +139,11 @@ class FaceRecognitionFailure implements Exception {
|
||||
final String message;
|
||||
|
||||
bool get cancelled => code == 'CANCELLED';
|
||||
bool get terminatesSession =>
|
||||
cancelled ||
|
||||
code == 'LOW_MEMORY' ||
|
||||
code == 'SESSION_SUSPENDED' ||
|
||||
code == 'SESSION_STREAM_ERROR';
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
|
||||
@@ -26,6 +26,10 @@ class FaceRecognitionSessionHandle {
|
||||
/// Camera 与虹软热保持由 Android 负责;Dart 结束会话只关闭当前业务和 UI,
|
||||
/// 不会让连续排队的下一位顾客重新打开摄像头。
|
||||
class FaceRecognitionSessionService extends GetxService {
|
||||
final cameraOpen = false.obs;
|
||||
final cameraStatus = FaceRecognitionSessionStatus.cold.obs;
|
||||
|
||||
StreamSubscription<FaceRecognitionSessionEvent>? _cameraStateSubscription;
|
||||
StreamSubscription<FaceRecognitionSessionEvent>? _nativeStateSubscription;
|
||||
StreamSubscription<FaceStream?>? _faceSubscription;
|
||||
StreamController<FaceRecognitionSessionEvent>? _eventController;
|
||||
@@ -45,6 +49,30 @@ class FaceRecognitionSessionService extends GetxService {
|
||||
bool get isActive => _activeSessionId != null;
|
||||
String? get activeSessionId => _activeSessionId;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
// 顶栏需要跨业务 Session 展示 Camera 热状态,因此常驻订阅不能跟随弹框关闭。
|
||||
_cameraStateSubscription = FaceDiscernPlugs.sessionStream.listen(
|
||||
_trackCameraState,
|
||||
onError: (_) => cameraOpen.value = false,
|
||||
);
|
||||
unawaited(_refreshCameraState());
|
||||
}
|
||||
|
||||
Future<void> _refreshCameraState() async {
|
||||
try {
|
||||
_trackCameraState(await FaceDiscernPlugs.getRecognitionState());
|
||||
} catch (error) {
|
||||
logger.w('|人脸识别|读取摄像头状态失败:$error');
|
||||
}
|
||||
}
|
||||
|
||||
void _trackCameraState(FaceRecognitionSessionEvent event) {
|
||||
cameraOpen.value = event.warm;
|
||||
cameraStatus.value = event.status;
|
||||
}
|
||||
|
||||
bool owns(String? sessionId) =>
|
||||
sessionId != null && sessionId == _activeSessionId;
|
||||
|
||||
@@ -129,8 +157,10 @@ class FaceRecognitionSessionService extends GetxService {
|
||||
);
|
||||
if (_activeSessionId == sessionId) {
|
||||
_nativeStarted = true;
|
||||
_nativeRevision = initial.revision;
|
||||
_eventController?.add(initial);
|
||||
if (initial.revision > _nativeRevision) {
|
||||
_nativeRevision = initial.revision;
|
||||
_eventController?.add(initial);
|
||||
}
|
||||
}
|
||||
} on PlatformException catch (error) {
|
||||
if (_activeSessionId != sessionId) return;
|
||||
@@ -365,6 +395,8 @@ class FaceRecognitionSessionService extends GetxService {
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
unawaited(_cameraStateSubscription?.cancel());
|
||||
_cameraStateSubscription = null;
|
||||
unawaited(suspend());
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
@@ -93,6 +93,23 @@ abstract class FaceDiscernPlugs {
|
||||
return _invokeSession('getRecognitionState');
|
||||
}
|
||||
|
||||
static Future<FaceRecognitionConfiguration> getRecognizeConfiguration() {
|
||||
return _invokeRecognizeConfiguration('getRecognizeConfiguration');
|
||||
}
|
||||
|
||||
static Future<FaceRecognitionConfiguration> setRecognizeConfiguration(
|
||||
FaceRecognitionConfiguration configuration,
|
||||
) {
|
||||
return _invokeRecognizeConfiguration(
|
||||
'setRecognizeConfiguration',
|
||||
configuration.toMap(),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<FaceRecognitionConfiguration> resetRecognizeConfiguration() {
|
||||
return _invokeRecognizeConfiguration('resetRecognizeConfiguration');
|
||||
}
|
||||
|
||||
static Stream<FaceRecognitionSessionEvent> get sessionStream {
|
||||
_sessionStream ??= _sessionEventChannel
|
||||
.receiveBroadcastStream()
|
||||
@@ -111,6 +128,15 @@ abstract class FaceDiscernPlugs {
|
||||
return FaceRecognitionSessionEvent.fromMap(map);
|
||||
}
|
||||
|
||||
static Future<FaceRecognitionConfiguration> _invokeRecognizeConfiguration(
|
||||
String method, [
|
||||
Map<String, dynamic>? arguments,
|
||||
]) async {
|
||||
final map = await _channel.invokeMethod<dynamic>(method, arguments);
|
||||
if (map is! Map) throw StateError('原生未返回有效的人脸识别配置');
|
||||
return FaceRecognitionConfiguration.fromMap(map);
|
||||
}
|
||||
|
||||
static Future<FaceConfig?> getConfig() async {
|
||||
try {
|
||||
final Map<Object?, Object?>? map =
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'package:forui/forui.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart';
|
||||
import 'package:pj_supermarket_cloud/common/models/plugs/face_recognition_session_models.dart';
|
||||
import 'package:pj_supermarket_cloud/common/services/face_recognition_session_service.dart';
|
||||
import 'package:pj_supermarket_cloud/common/utils/app/app_device_status_tool.dart';
|
||||
|
||||
/// 顶栏统一订阅设备状态快照,避免各业务页面分别维护硬件在线标记。
|
||||
@@ -14,6 +16,12 @@ class TerminalDeviceStatus extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
final status = AppDeviceStatusTool.status.value;
|
||||
final faceService = Get.isRegistered<FaceRecognitionSessionService>()
|
||||
? Get.find<FaceRecognitionSessionService>()
|
||||
: null;
|
||||
final cameraOpen = faceService?.cameraOpen.value ?? false;
|
||||
final cameraStatus =
|
||||
faceService?.cameraStatus.value ?? FaceRecognitionSessionStatus.cold;
|
||||
return Row(
|
||||
children: [
|
||||
TerminalStatusPill(
|
||||
@@ -40,12 +48,142 @@ class TerminalDeviceStatus extends StatelessWidget {
|
||||
active: status.subScreenAvailable,
|
||||
showBorder: false,
|
||||
),
|
||||
FaceCameraStatusCard(
|
||||
cameraOpen: cameraOpen,
|
||||
status: cameraStatus,
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 摄像头既有识别中状态,也有连续客流所需的热保持状态,因此在同一行保留具体状态文字。
|
||||
class FaceCameraStatusCard extends StatelessWidget {
|
||||
const FaceCameraStatusCard({
|
||||
super.key,
|
||||
required this.cameraOpen,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
final bool cameraOpen;
|
||||
final FaceRecognitionSessionStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = FTheme.of(context);
|
||||
final sizeScale = XfjDesignTokens.buttonSizeScale;
|
||||
final itemPadding = 12.w * sizeScale;
|
||||
final visual = _FaceCameraStatusVisual.resolve(
|
||||
cameraOpen: cameraOpen,
|
||||
status: status,
|
||||
);
|
||||
return Tooltip(
|
||||
message: visual.description,
|
||||
child: SizedBox(
|
||||
key: ValueKey('terminal-camera-${visual.label}'),
|
||||
height: 36.h,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: itemPadding),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FIcon(
|
||||
cameraOpen ? FAssets.icons.camera : FAssets.icons.cameraOff,
|
||||
size: 19.w,
|
||||
color: XfjDesignTokens.ink,
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
'摄像头',
|
||||
style: theme.typography.sm.copyWith(
|
||||
color: XfjDesignTokens.ink,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Container(
|
||||
width: 8.w,
|
||||
height: 8.w,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: visual.accent,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
visual.label,
|
||||
style: theme.typography.xs.copyWith(
|
||||
color: visual.accent,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FaceCameraStatusVisual {
|
||||
const _FaceCameraStatusVisual({
|
||||
required this.label,
|
||||
required this.description,
|
||||
required this.accent,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String description;
|
||||
final Color accent;
|
||||
|
||||
static _FaceCameraStatusVisual resolve({
|
||||
required bool cameraOpen,
|
||||
required FaceRecognitionSessionStatus status,
|
||||
}) {
|
||||
if (!cameraOpen) {
|
||||
final failed = status == FaceRecognitionSessionStatus.failed;
|
||||
return _FaceCameraStatusVisual(
|
||||
label: failed ? '异常关闭' : '已关闭',
|
||||
description: failed ? '摄像头或识别引擎异常,当前已释放资源' : '摄像头当前未开启',
|
||||
accent: failed ? XfjDesignTokens.danger : XfjDesignTokens.muted,
|
||||
);
|
||||
}
|
||||
if (status == FaceRecognitionSessionStatus.initializing) {
|
||||
return const _FaceCameraStatusVisual(
|
||||
label: '启动中',
|
||||
description: '摄像头已打开,正在初始化虹软识别引擎',
|
||||
accent: XfjDesignTokens.primary,
|
||||
);
|
||||
}
|
||||
if (status == FaceRecognitionSessionStatus.detecting) {
|
||||
return const _FaceCameraStatusVisual(
|
||||
label: '识别中',
|
||||
description: '摄像头已打开,正在实时识别人脸',
|
||||
accent: XfjDesignTokens.success,
|
||||
);
|
||||
}
|
||||
if (status == FaceRecognitionSessionStatus.failed) {
|
||||
return const _FaceCameraStatusVisual(
|
||||
label: '等待重试',
|
||||
description: '摄像头仍保持开启,可直接重试当前人脸会话',
|
||||
accent: XfjDesignTokens.warning,
|
||||
);
|
||||
}
|
||||
if (status == FaceRecognitionSessionStatus.success) {
|
||||
return const _FaceCameraStatusVisual(
|
||||
label: '识别成功',
|
||||
description: '摄像头已打开,正在处理本次识别结果',
|
||||
accent: XfjDesignTokens.success,
|
||||
);
|
||||
}
|
||||
return const _FaceCameraStatusVisual(
|
||||
label: '热保持',
|
||||
description: '摄像头已打开,连续 20 分钟无新的人脸会话后自动关闭',
|
||||
accent: Color(0xFF0F766E),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TerminalStatusPill extends StatelessWidget {
|
||||
const TerminalStatusPill({
|
||||
super.key,
|
||||
|
||||
@@ -296,7 +296,7 @@ class _TerminalNetworkStatusState extends State<TerminalNetworkStatus> {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = FTheme.of(context);
|
||||
return Container(
|
||||
width: 410.w,
|
||||
width: 270.w,
|
||||
constraints: BoxConstraints(minHeight: 48.h),
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: Row(
|
||||
@@ -320,9 +320,9 @@ class _TerminalNetworkStatusState extends State<TerminalNetworkStatus> {
|
||||
),
|
||||
SizedBox(width: 7.w),
|
||||
SizedBox(
|
||||
width: 124.w,
|
||||
width: 100.w,
|
||||
child: Text(
|
||||
_connected ? '$_networkType在线' : '网络离线',
|
||||
_connected ? _networkType : '网络离线',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.typography.sm.copyWith(color: XfjDesignTokens.ink),
|
||||
@@ -331,11 +331,13 @@ class _TerminalNetworkStatusState extends State<TerminalNetworkStatus> {
|
||||
SizedBox(width: 8.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'↓$_downloadSpeed ↑$_uploadSpeed',
|
||||
maxLines: 1,
|
||||
'↓$_downloadSpeed\n↑$_uploadSpeed',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.clip,
|
||||
style: theme.typography.xs.copyWith(
|
||||
color: XfjDesignTokens.muted,
|
||||
fontSize: (theme.typography.xs.fontSize ?? 12) * 0.82,
|
||||
height: 1.05,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -95,7 +95,7 @@ class _FaceRecognitionPreviewDialogState
|
||||
Navigator.of(context).pop(result);
|
||||
} on FaceRecognitionFailure catch (failure) {
|
||||
if (!mounted || _closing) return;
|
||||
if (failure.cancelled) {
|
||||
if (failure.terminatesSession) {
|
||||
_closing = true;
|
||||
Navigator.of(context).pop();
|
||||
return;
|
||||
|
||||
@@ -114,7 +114,11 @@ class ReservationMenuQueryController extends GetxController {
|
||||
currentPage.value = 1;
|
||||
errorMessage.value = '';
|
||||
status.value = ReservationMenuQueryStatus.recognizing;
|
||||
await _syncSubScreen('loading');
|
||||
// 人脸方式由统一会话服务先把副屏切到实时识别页;预约 loading 只在识别成功、
|
||||
// 真正开始查询当前用户时发送,避免副屏在人脸画面前短暂显示查询状态。
|
||||
if (method.value == ReservationRecognitionMethod.card) {
|
||||
await _syncSubScreen('loading');
|
||||
}
|
||||
|
||||
Future<void> recognized(String value) async {
|
||||
if (_closed || generation != _requestGeneration || _recognitionLocked) {
|
||||
|
||||
@@ -178,7 +178,17 @@ class TerminalReservationRecognitionGateway
|
||||
service: service,
|
||||
handle: handle,
|
||||
);
|
||||
if (result == null || _faceSessionId != handle.sessionId) return;
|
||||
if (_faceSessionId != handle.sessionId) return;
|
||||
if (result == null) {
|
||||
final sessionStillActive = service.owns(handle.sessionId);
|
||||
onFailure(ReservationRecognitionFailure(
|
||||
sessionStillActive
|
||||
? ReservationRecognitionFailureType.device
|
||||
: ReservationRecognitionFailureType.cancelled,
|
||||
sessionStillActive ? '人脸识别已中断,请检查设备后重试' : '已取消人脸识别',
|
||||
));
|
||||
return;
|
||||
}
|
||||
// 成功提示已停留 700ms,此时先结束原生业务 Session,再进入查询流程。
|
||||
// Controller 的 recognized() 会统一 stopAll;提前清空可避免 success 被误发成 cancel。
|
||||
if (service.owns(handle.sessionId)) await service.finish();
|
||||
|
||||
@@ -21,6 +21,11 @@ class FaceSettingController extends GetxController {
|
||||
final errorMessage = ''.obs;
|
||||
final permissionCanOpenSettings = false.obs;
|
||||
final showFullParameters = false.obs;
|
||||
final recognitionConfiguration = Rxn<FaceRecognitionConfiguration>();
|
||||
final savedRecognitionConfiguration = Rxn<FaceRecognitionConfiguration>();
|
||||
final isLoadingRecognitionConfiguration = false.obs;
|
||||
final isSavingRecognitionConfiguration = false.obs;
|
||||
final showAdvancedRecognitionConfiguration = false.obs;
|
||||
|
||||
StreamSubscription<FaceRegistrationProgress>? _progressSubscription;
|
||||
|
||||
@@ -34,6 +39,7 @@ class FaceSettingController extends GetxController {
|
||||
},
|
||||
);
|
||||
refreshStatus();
|
||||
refreshRecognitionConfiguration();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -63,6 +69,96 @@ class FaceSettingController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshRecognitionConfiguration() async {
|
||||
if (isLoadingRecognitionConfiguration.value) return;
|
||||
isLoadingRecognitionConfiguration.value = true;
|
||||
try {
|
||||
final value = await FaceDiscernPlugs.getRecognizeConfiguration();
|
||||
savedRecognitionConfiguration.value = value;
|
||||
recognitionConfiguration.value = value;
|
||||
} on PlatformException catch (e, stack) {
|
||||
logger.e('|$_tag|读取识别策略失败error:|$e|stack:|$stack|');
|
||||
Toast.error(msg: e.message ?? '读取人脸识别策略失败');
|
||||
} catch (e, stack) {
|
||||
logger.e('|$_tag|读取识别策略失败error:|$e|stack:|$stack|');
|
||||
Toast.error(msg: '读取人脸识别策略失败');
|
||||
} finally {
|
||||
isLoadingRecognitionConfiguration.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
void updateRecognitionConfiguration(
|
||||
FaceRecognitionConfiguration Function(FaceRecognitionConfiguration value)
|
||||
update,
|
||||
) {
|
||||
final current = recognitionConfiguration.value;
|
||||
if (current == null || isSavingRecognitionConfiguration.value) return;
|
||||
recognitionConfiguration.value = update(current);
|
||||
}
|
||||
|
||||
Future<void> saveRecognitionConfiguration() async {
|
||||
final current = recognitionConfiguration.value;
|
||||
if (current == null || isSavingRecognitionConfiguration.value) return;
|
||||
isSavingRecognitionConfiguration.value = true;
|
||||
try {
|
||||
final saved = await FaceDiscernPlugs.setRecognizeConfiguration(current);
|
||||
savedRecognitionConfiguration.value = saved;
|
||||
recognitionConfiguration.value = saved;
|
||||
final active = Get.isRegistered<FaceRecognitionSessionService>() &&
|
||||
Get.find<FaceRecognitionSessionService>().isActive;
|
||||
Toast.success(
|
||||
msg: active ? '配置已保存,将在当前识别结束后生效' : '人脸识别配置已保存',
|
||||
);
|
||||
} on PlatformException catch (e, stack) {
|
||||
logger.e('|$_tag|保存识别策略失败error:|$e|stack:|$stack|');
|
||||
Toast.error(msg: e.message ?? '保存人脸识别配置失败');
|
||||
} catch (e, stack) {
|
||||
logger.e('|$_tag|保存识别策略失败error:|$e|stack:|$stack|');
|
||||
Toast.error(msg: '保存人脸识别配置失败');
|
||||
} finally {
|
||||
isSavingRecognitionConfiguration.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> resetRecognitionConfiguration() async {
|
||||
if (isSavingRecognitionConfiguration.value) return;
|
||||
final confirmed = await appMessageDialog(
|
||||
Get.context!,
|
||||
'是否恢复默认人脸识别策略?活体检测将恢复为关闭,相似度阈值恢复为 80%。',
|
||||
);
|
||||
if (!confirmed) return;
|
||||
isSavingRecognitionConfiguration.value = true;
|
||||
try {
|
||||
final restored = await FaceDiscernPlugs.resetRecognizeConfiguration();
|
||||
savedRecognitionConfiguration.value = restored;
|
||||
recognitionConfiguration.value = restored;
|
||||
final active = Get.isRegistered<FaceRecognitionSessionService>() &&
|
||||
Get.find<FaceRecognitionSessionService>().isActive;
|
||||
Toast.success(
|
||||
msg: active ? '已恢复默认配置,将在当前识别结束后生效' : '已恢复默认人脸识别配置',
|
||||
);
|
||||
} on PlatformException catch (e, stack) {
|
||||
logger.e('|$_tag|恢复识别策略失败error:|$e|stack:|$stack|');
|
||||
Toast.error(msg: e.message ?? '恢复默认配置失败');
|
||||
} catch (e, stack) {
|
||||
logger.e('|$_tag|恢复识别策略失败error:|$e|stack:|$stack|');
|
||||
Toast.error(msg: '恢复默认配置失败');
|
||||
} finally {
|
||||
isSavingRecognitionConfiguration.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
void discardRecognitionConfigurationChanges() {
|
||||
recognitionConfiguration.value = savedRecognitionConfiguration.value;
|
||||
}
|
||||
|
||||
void toggleAdvancedRecognitionConfiguration() {
|
||||
showAdvancedRecognitionConfiguration.toggle();
|
||||
}
|
||||
|
||||
bool get recognitionConfigurationChanged =>
|
||||
recognitionConfiguration.value != savedRecognitionConfiguration.value;
|
||||
|
||||
Future<void> syncConfig() async {
|
||||
if (isBusy) return;
|
||||
if (!AppNetworkTool.isAutoNetworkMsg()) return;
|
||||
@@ -172,7 +268,8 @@ class FaceSettingController extends GetxController {
|
||||
isLoading.value ||
|
||||
isActivating.value ||
|
||||
isRegistering.value ||
|
||||
isSyncingConfig.value;
|
||||
isSyncingConfig.value ||
|
||||
isSavingRecognitionConfiguration.value;
|
||||
|
||||
void _handleProgress(FaceRegistrationProgress value) {
|
||||
progress.value = value;
|
||||
|
||||
@@ -22,6 +22,8 @@ Widget settingFacePlugin(BuildContext context) {
|
||||
children: [
|
||||
_FaceStatusGroup(controller: controller),
|
||||
SizedBox(height: XfjDesignTokens.cardGap.h),
|
||||
_RecognitionConfigurationGroup(controller: controller),
|
||||
SizedBox(height: XfjDesignTokens.cardGap.h),
|
||||
_ActivationParameterGroup(controller: controller),
|
||||
if ((controller.progress.value != null &&
|
||||
controller.progress.value!.phase != 'idle') ||
|
||||
@@ -40,6 +42,390 @@ Widget settingFacePlugin(BuildContext context) {
|
||||
});
|
||||
}
|
||||
|
||||
/// 现场只开放能明确解释、且原生已做范围校验的识别策略;保存不会打断正在支付的顾客。
|
||||
class _RecognitionConfigurationGroup extends StatelessWidget {
|
||||
const _RecognitionConfigurationGroup({required this.controller});
|
||||
|
||||
final FaceSettingController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 子组件的 build 晚于外层 Obx 执行,必须在这里读取 Rx,才能让开关、滑块和展开项即时刷新。
|
||||
return Obx(() {
|
||||
final configuration = controller.recognitionConfiguration.value;
|
||||
if (configuration == null) {
|
||||
return SettingGroup(
|
||||
title: '识别策略',
|
||||
child: SizedBox(
|
||||
height: 100.h,
|
||||
child: controller.isLoadingRecognitionConfiguration.value
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SettingInfoRow(
|
||||
icon: FAssets.icons.triangleAlert,
|
||||
label: '配置读取失败',
|
||||
subtitle: '未能从 Android 人脸插件读取识别参数',
|
||||
actionLabel: '重新读取',
|
||||
onTap: controller.refreshRecognitionConfiguration,
|
||||
showDivider: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final saving = controller.isSavingRecognitionConfiguration.value;
|
||||
final changed = controller.recognitionConfigurationChanged;
|
||||
return SettingGroup(
|
||||
title: '识别策略',
|
||||
child: Column(
|
||||
children: [
|
||||
SettingInfoRow(
|
||||
icon: configuration.enableLiveness
|
||||
? FAssets.icons.shieldCheck
|
||||
: FAssets.icons.shieldOff,
|
||||
label: 'RGB 活体认证',
|
||||
subtitle: '防止照片或屏幕翻拍;当前单摄像头方案仅支持 RGB 活体',
|
||||
value: configuration.enableLiveness ? '已开启' : '已关闭',
|
||||
trailing: FSwitch(
|
||||
semanticLabel: '开启 RGB 活体认证',
|
||||
value: configuration.enableLiveness,
|
||||
enabled: !saving,
|
||||
onChange: (value) => controller.updateRecognitionConfiguration(
|
||||
(current) => current.copyWith(enableLiveness: value),
|
||||
),
|
||||
),
|
||||
),
|
||||
_RecognitionValueRow(
|
||||
icon: FAssets.icons.scanFace,
|
||||
label: '识别成功相似度',
|
||||
subtitle: '候选人员相似度高于该值才算识别成功;阈值越高越严格',
|
||||
value: configuration.similarThreshold,
|
||||
min: 0.5,
|
||||
max: 0.99,
|
||||
step: 0.01,
|
||||
formatter: _percent,
|
||||
enabled: !saving,
|
||||
onChanged: (value) => controller.updateRecognitionConfiguration(
|
||||
(current) => current.copyWith(similarThreshold: value),
|
||||
),
|
||||
),
|
||||
_RecognitionValueRow(
|
||||
icon: FAssets.icons.activity,
|
||||
label: 'RGB 活体阈值',
|
||||
subtitle: configuration.enableLiveness
|
||||
? '活体分数达到该值才继续进行人脸比对;阈值越高越严格'
|
||||
: '开启活体认证后生效',
|
||||
value: configuration.rgbLivenessThreshold,
|
||||
min: 0.3,
|
||||
max: 0.9,
|
||||
step: 0.01,
|
||||
formatter: _percent,
|
||||
enabled: configuration.enableLiveness && !saving,
|
||||
onChanged: (value) => controller.updateRecognitionConfiguration(
|
||||
(current) => current.copyWith(rgbLivenessThreshold: value),
|
||||
),
|
||||
),
|
||||
SettingInfoRow(
|
||||
icon: FAssets.icons.image,
|
||||
label: '图像质量检测',
|
||||
subtitle: '过滤过暗、模糊或质量不足的人脸,降低误识别风险',
|
||||
value: configuration.enableImageQuality ? '已开启' : '已关闭',
|
||||
trailing: FSwitch(
|
||||
semanticLabel: '开启人脸图像质量检测',
|
||||
value: configuration.enableImageQuality,
|
||||
enabled: !saving,
|
||||
onChange: (value) => controller.updateRecognitionConfiguration(
|
||||
(current) => current.copyWith(enableImageQuality: value),
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingInfoRow(
|
||||
icon: configuration.keepMaxFace
|
||||
? FAssets.icons.userRoundCheck
|
||||
: FAssets.icons.users,
|
||||
label: '仅识别最大人脸',
|
||||
subtitle: '多人进入画面时优先识别距离摄像头最近的人,适合排队支付',
|
||||
value: configuration.keepMaxFace ? '已开启' : '已关闭',
|
||||
trailing: FSwitch(
|
||||
semanticLabel: '仅识别画面中最大人脸',
|
||||
value: configuration.keepMaxFace,
|
||||
enabled: !saving,
|
||||
onChange: (value) => controller.updateRecognitionConfiguration(
|
||||
(current) => current.copyWith(keepMaxFace: value),
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingInfoRow(
|
||||
icon: FAssets.icons.slidersHorizontal,
|
||||
label: '高级参数',
|
||||
subtitle: '图像质量阈值、重试次数和失败重试间隔',
|
||||
value: controller.showAdvancedRecognitionConfiguration.value
|
||||
? '已展开'
|
||||
: '已收起',
|
||||
actionLabel: controller.showAdvancedRecognitionConfiguration.value
|
||||
? '收起'
|
||||
: '展开',
|
||||
onTap: controller.toggleAdvancedRecognitionConfiguration,
|
||||
showDivider:
|
||||
!controller.showAdvancedRecognitionConfiguration.value,
|
||||
),
|
||||
if (controller.showAdvancedRecognitionConfiguration.value) ...[
|
||||
_RecognitionValueRow(
|
||||
icon: FAssets.icons.userRound,
|
||||
label: '无口罩图像质量阈值',
|
||||
subtitle: '未佩戴口罩时,图像质量低于该值将继续等待更清晰画面',
|
||||
value: configuration.imageQualityNoMaskRecognizeThreshold,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
formatter: _decimal,
|
||||
enabled: configuration.enableImageQuality && !saving,
|
||||
onChanged: (value) => controller.updateRecognitionConfiguration(
|
||||
(current) => current.copyWith(
|
||||
imageQualityNoMaskRecognizeThreshold: value,
|
||||
),
|
||||
),
|
||||
),
|
||||
_RecognitionValueRow(
|
||||
icon: FAssets.icons.userRound,
|
||||
label: '戴口罩图像质量阈值',
|
||||
subtitle: '佩戴口罩时使用独立阈值,避免沿用无口罩标准导致持续等待',
|
||||
value: configuration.imageQualityMaskRecognizeThreshold,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
formatter: _decimal,
|
||||
enabled: configuration.enableImageQuality && !saving,
|
||||
onChanged: (value) => controller.updateRecognitionConfiguration(
|
||||
(current) => current.copyWith(
|
||||
imageQualityMaskRecognizeThreshold: value,
|
||||
),
|
||||
),
|
||||
),
|
||||
_RecognitionIntegerSelector(
|
||||
icon: FAssets.icons.scanFace,
|
||||
label: '最大检测人脸数',
|
||||
subtitle: '单识别线程最多跟踪的人脸数量;排队支付建议保持 1',
|
||||
value: configuration.maxDetectFaces,
|
||||
options: const [1, 2, 3],
|
||||
enabled: !saving,
|
||||
onChanged: (value) => controller.updateRecognitionConfiguration(
|
||||
(current) => current.copyWith(maxDetectFaces: value),
|
||||
),
|
||||
),
|
||||
_RecognitionIntegerSelector(
|
||||
icon: FAssets.icons.rotateCcw,
|
||||
label: '特征提取重试次数',
|
||||
subtitle: '提取失败后允许继续尝试的次数',
|
||||
value: configuration.extractRetryCount,
|
||||
options: const [1, 2, 3, 5, 8],
|
||||
enabled: !saving,
|
||||
onChanged: (value) => controller.updateRecognitionConfiguration(
|
||||
(current) => current.copyWith(extractRetryCount: value),
|
||||
),
|
||||
),
|
||||
_RecognitionIntegerSelector(
|
||||
icon: FAssets.icons.shieldCheck,
|
||||
label: '活体失败重试次数',
|
||||
subtitle: '活体引擎连续报错达到该次数后重置检测状态',
|
||||
value: configuration.livenessRetryCount,
|
||||
options: const [1, 2, 3, 5, 8],
|
||||
enabled: configuration.enableLiveness && !saving,
|
||||
onChanged: (value) => controller.updateRecognitionConfiguration(
|
||||
(current) => current.copyWith(livenessRetryCount: value),
|
||||
),
|
||||
),
|
||||
_RecognitionValueRow(
|
||||
icon: FAssets.icons.timerReset,
|
||||
label: '识别失败重试间隔',
|
||||
subtitle: '未注册或相似度不足后,等待多久再次尝试',
|
||||
value: configuration.recognizeFailedRetryInterval.toDouble(),
|
||||
min: 0,
|
||||
max: 5000,
|
||||
step: 100,
|
||||
formatter: _milliseconds,
|
||||
enabled: !saving,
|
||||
onChanged: (value) => controller.updateRecognitionConfiguration(
|
||||
(current) => current.copyWith(
|
||||
recognizeFailedRetryInterval: value.round(),
|
||||
),
|
||||
),
|
||||
),
|
||||
_RecognitionValueRow(
|
||||
icon: FAssets.icons.timerReset,
|
||||
label: '活体失败重试间隔',
|
||||
subtitle: '活体未通过后,等待多久重新检测',
|
||||
value: configuration.livenessFailedRetryInterval.toDouble(),
|
||||
min: 0,
|
||||
max: 5000,
|
||||
step: 100,
|
||||
formatter: _milliseconds,
|
||||
enabled: configuration.enableLiveness && !saving,
|
||||
onChanged: (value) => controller.updateRecognitionConfiguration(
|
||||
(current) => current.copyWith(
|
||||
livenessFailedRetryInterval: value.round(),
|
||||
),
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
_CompactActionBar(
|
||||
children: [
|
||||
SettingActionButton(
|
||||
label: '恢复默认',
|
||||
icon: FAssets.icons.rotateCcw,
|
||||
enabled: !saving && !configuration.usesDefaults,
|
||||
onTap: controller.resetRecognitionConfiguration,
|
||||
),
|
||||
if (changed)
|
||||
SettingActionButton(
|
||||
label: '放弃修改',
|
||||
icon: FAssets.icons.x,
|
||||
enabled: !saving,
|
||||
onTap: controller.discardRecognitionConfigurationChanges,
|
||||
),
|
||||
SettingActionButton(
|
||||
label: saving ? '保存中...' : '保存配置',
|
||||
icon: FAssets.icons.save,
|
||||
primary: true,
|
||||
enabled: changed && !saving,
|
||||
onTap: controller.saveRecognitionConfiguration,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _RecognitionValueRow extends StatelessWidget {
|
||||
const _RecognitionValueRow({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.subtitle,
|
||||
required this.value,
|
||||
required this.min,
|
||||
required this.max,
|
||||
required this.step,
|
||||
required this.formatter,
|
||||
required this.enabled,
|
||||
required this.onChanged,
|
||||
this.showDivider = true,
|
||||
});
|
||||
|
||||
final SvgAsset icon;
|
||||
final String label;
|
||||
final String subtitle;
|
||||
final double value;
|
||||
final double min;
|
||||
final double max;
|
||||
final double step;
|
||||
final String Function(double value) formatter;
|
||||
final bool enabled;
|
||||
final ValueChanged<double> onChanged;
|
||||
final bool showDivider;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final normalized = ((value - min) / (max - min)).clamp(0.0, 1.0);
|
||||
return SettingInfoRow(
|
||||
icon: icon,
|
||||
label: label,
|
||||
subtitle: subtitle,
|
||||
value: formatter(value),
|
||||
showDivider: showDivider,
|
||||
trailing: SizedBox(
|
||||
width: 300.w,
|
||||
child: Slider(
|
||||
value: normalized,
|
||||
divisions: ((max - min) / step).round(),
|
||||
label: formatter(value),
|
||||
onChanged: enabled
|
||||
? (offset) {
|
||||
final raw = min + (offset * (max - min));
|
||||
final rounded = (raw / step).round() * step;
|
||||
onChanged(rounded.clamp(min, max));
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecognitionIntegerSelector extends StatelessWidget {
|
||||
const _RecognitionIntegerSelector({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.subtitle,
|
||||
required this.value,
|
||||
required this.options,
|
||||
required this.enabled,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final SvgAsset icon;
|
||||
final String label;
|
||||
final String subtitle;
|
||||
final int value;
|
||||
final List<int> options;
|
||||
final bool enabled;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = FTheme.of(context);
|
||||
return SettingInfoRow(
|
||||
icon: icon,
|
||||
label: label,
|
||||
subtitle: subtitle,
|
||||
value: '$value',
|
||||
trailing: Wrap(
|
||||
spacing: 6.w,
|
||||
children: options.map((option) {
|
||||
final selected = option == value;
|
||||
return InkWell(
|
||||
onTap: enabled ? () => onChanged(option) : null,
|
||||
borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius),
|
||||
child: Container(
|
||||
width: 48.w,
|
||||
height: 42.h,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? XfjDesignTokens.primarySoft
|
||||
: XfjDesignTokens.surface,
|
||||
border: Border.all(
|
||||
color:
|
||||
selected ? XfjDesignTokens.primary : XfjDesignTokens.line,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
XfjDesignTokens.controlRadius,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'$option',
|
||||
style: theme.typography.sm.copyWith(
|
||||
color:
|
||||
selected ? XfjDesignTokens.primary : XfjDesignTokens.ink,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(growable: false),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _percent(double value) => '${(value * 100).round()}%';
|
||||
|
||||
String _decimal(double value) => value.toStringAsFixed(2);
|
||||
|
||||
String _milliseconds(double value) => '${value.round()} ms';
|
||||
|
||||
/// 按“基本信息”的字段行组织状态,避免大屏终端出现横向仪表盘和重复操作入口。
|
||||
class _FaceStatusGroup extends StatelessWidget {
|
||||
const _FaceStatusGroup({required this.controller});
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/face_recognition_display_models.dart';
|
||||
import '../models/reservation_query_display_models.dart';
|
||||
import 'customer_display_coordinator.dart';
|
||||
import 'face_recognition_coordinator.dart';
|
||||
import 'reservation_query_coordinator.dart';
|
||||
@@ -27,6 +29,7 @@ class SubDisplayCoordinator extends ChangeNotifier {
|
||||
|
||||
static const _reservationUpdate = 'reservationQueryUpdate';
|
||||
static const _reservationClear = 'reservationQueryClear';
|
||||
static const _faceStart = 'faceRecognitionStart';
|
||||
static const _settlementActions = {
|
||||
'updateCard',
|
||||
'jumpSubPage',
|
||||
@@ -43,6 +46,10 @@ class SubDisplayCoordinator extends ChangeNotifier {
|
||||
SubDisplayMode _mode = SubDisplayMode.settlement;
|
||||
SubDisplayMode _modeBeforeFace = SubDisplayMode.settlement;
|
||||
Timer? _faceRestoreTimer;
|
||||
String? _reservationSessionAtFaceStart;
|
||||
int _reservationRevisionAtFaceStart = -1;
|
||||
bool _reservationSuccessDelayPassed = false;
|
||||
bool _reservationResultReady = false;
|
||||
bool _disposed = false;
|
||||
|
||||
SubDisplayMode get mode => _mode;
|
||||
@@ -52,21 +59,42 @@ class SubDisplayCoordinator extends ChangeNotifier {
|
||||
if (action.startsWith('faceRecognition')) {
|
||||
final result = faceRecognitionCoordinator.handleAction(action, arguments);
|
||||
if (!result.accepted) return false;
|
||||
final faceState = faceRecognitionCoordinator.state;
|
||||
switch (result.transition) {
|
||||
case FaceRecognitionDisplayTransition.show:
|
||||
_faceRestoreTimer?.cancel();
|
||||
if (action == _faceStart) {
|
||||
_resetReservationFaceHandoff();
|
||||
if (faceState?.scene == FaceRecognitionDisplayScene.reservation) {
|
||||
_beginReservationFaceHandoff();
|
||||
}
|
||||
}
|
||||
if (_mode != SubDisplayMode.faceRecognition) _modeBeforeFace = _mode;
|
||||
_replaceMode(SubDisplayMode.faceRecognition);
|
||||
break;
|
||||
case FaceRecognitionDisplayTransition.restoreNow:
|
||||
_faceRestoreTimer?.cancel();
|
||||
_resetReservationFaceHandoff();
|
||||
_replaceMode(_modeBeforeFace);
|
||||
break;
|
||||
case FaceRecognitionDisplayTransition.restoreAfterSuccess:
|
||||
_faceRestoreTimer?.cancel();
|
||||
_faceRestoreTimer = Timer(const Duration(milliseconds: 700), () {
|
||||
if (!_disposed) _replaceMode(_modeBeforeFace);
|
||||
});
|
||||
if (faceState?.scene == FaceRecognitionDisplayScene.reservation) {
|
||||
// 预约查询必须在人脸成功提示展示满 700ms 且查询拿到最终结果后再恢复,
|
||||
// 否则慢网络会先露出中间 loading,顾客看不到“人脸 -> 本人菜单”的完整顺序。
|
||||
_reservationSuccessDelayPassed = false;
|
||||
_reservationResultReady = _hasNewReservationTerminalState();
|
||||
_faceRestoreTimer = Timer(const Duration(milliseconds: 700), () {
|
||||
if (_disposed) return;
|
||||
_reservationSuccessDelayPassed = true;
|
||||
_restoreReservationAfterFaceIfReady();
|
||||
});
|
||||
} else {
|
||||
_resetReservationFaceHandoff();
|
||||
_faceRestoreTimer = Timer(const Duration(milliseconds: 700), () {
|
||||
if (!_disposed) _replaceMode(_modeBeforeFace);
|
||||
});
|
||||
}
|
||||
break;
|
||||
case FaceRecognitionDisplayTransition.none:
|
||||
break;
|
||||
@@ -75,12 +103,18 @@ class SubDisplayCoordinator extends ChangeNotifier {
|
||||
}
|
||||
if (action == _reservationUpdate) {
|
||||
final accepted = reservationCoordinator.handleUpdate(arguments);
|
||||
if (accepted) _showOrRememberMode(SubDisplayMode.reservationQuery);
|
||||
if (accepted) {
|
||||
_showOrRememberMode(SubDisplayMode.reservationQuery);
|
||||
_handleReservationUpdateDuringFace();
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
if (action == _reservationClear) {
|
||||
final accepted = reservationCoordinator.handleClear(arguments);
|
||||
if (accepted) _showOrRememberMode(SubDisplayMode.reservationQuery);
|
||||
if (accepted) {
|
||||
_showOrRememberMode(SubDisplayMode.reservationQuery);
|
||||
_handleReservationClearDuringFace();
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
if (_settlementActions.contains(action)) {
|
||||
@@ -98,6 +132,73 @@ class SubDisplayCoordinator extends ChangeNotifier {
|
||||
return true;
|
||||
}
|
||||
|
||||
void _beginReservationFaceHandoff() {
|
||||
final reservationState = reservationCoordinator.state;
|
||||
_reservationSessionAtFaceStart = reservationState.sessionId;
|
||||
_reservationRevisionAtFaceStart = reservationState.revision;
|
||||
_reservationSuccessDelayPassed = false;
|
||||
_reservationResultReady = false;
|
||||
}
|
||||
|
||||
void _handleReservationUpdateDuringFace() {
|
||||
if (_mode != SubDisplayMode.faceRecognition) return;
|
||||
final faceState = faceRecognitionCoordinator.state;
|
||||
if (faceState?.scene != FaceRecognitionDisplayScene.reservation) return;
|
||||
if (faceState?.status != FaceRecognitionDisplayStatus.success &&
|
||||
faceState?.status != FaceRecognitionDisplayStatus.failed) {
|
||||
return;
|
||||
}
|
||||
if (!_hasNewReservationTerminalState()) return;
|
||||
_reservationResultReady = true;
|
||||
if (faceState?.status == FaceRecognitionDisplayStatus.failed) {
|
||||
// 可重试的人脸失败不会产生预约结果;只有业务已收口为 error 时才离开人脸页。
|
||||
_faceRestoreTimer?.cancel();
|
||||
_restoreReservationAfterFace();
|
||||
return;
|
||||
}
|
||||
_restoreReservationAfterFaceIfReady();
|
||||
}
|
||||
|
||||
void _handleReservationClearDuringFace() {
|
||||
if (_mode != SubDisplayMode.faceRecognition) return;
|
||||
final faceState = faceRecognitionCoordinator.state;
|
||||
if (faceState?.scene != FaceRecognitionDisplayScene.reservation) return;
|
||||
if (faceState?.status != FaceRecognitionDisplayStatus.success &&
|
||||
faceState?.status != FaceRecognitionDisplayStatus.failed) {
|
||||
return;
|
||||
}
|
||||
// 页面退出或隐私清屏不再等待查询结果,避免副屏停留在已结束的人脸成功页。
|
||||
_restoreReservationAfterFace();
|
||||
}
|
||||
|
||||
bool _hasNewReservationTerminalState() {
|
||||
final state = reservationCoordinator.state;
|
||||
final isNewer = state.sessionId != _reservationSessionAtFaceStart ||
|
||||
state.revision > _reservationRevisionAtFaceStart;
|
||||
if (!isNewer) return false;
|
||||
return state.scenario == ReservationQueryDisplayScenario.success ||
|
||||
state.scenario == ReservationQueryDisplayScenario.empty ||
|
||||
state.scenario == ReservationQueryDisplayScenario.error;
|
||||
}
|
||||
|
||||
void _restoreReservationAfterFaceIfReady() {
|
||||
if (!_reservationSuccessDelayPassed || !_reservationResultReady) return;
|
||||
_restoreReservationAfterFace();
|
||||
}
|
||||
|
||||
void _restoreReservationAfterFace() {
|
||||
_faceRestoreTimer?.cancel();
|
||||
_replaceMode(SubDisplayMode.reservationQuery);
|
||||
_resetReservationFaceHandoff();
|
||||
}
|
||||
|
||||
void _resetReservationFaceHandoff() {
|
||||
_reservationSessionAtFaceStart = null;
|
||||
_reservationRevisionAtFaceStart = -1;
|
||||
_reservationSuccessDelayPassed = false;
|
||||
_reservationResultReady = false;
|
||||
}
|
||||
|
||||
/// 人脸页显示期间,普通业务消息仍更新各自 Coordinator,但只修改待恢复页面。
|
||||
/// 这样 success 的 700ms 提示和 failed 的重试引导不会被金额或查询消息提前顶掉。
|
||||
bool _showOrRememberMode(SubDisplayMode next) {
|
||||
@@ -118,6 +219,7 @@ class SubDisplayCoordinator extends ChangeNotifier {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_faceRestoreTimer?.cancel();
|
||||
_resetReservationFaceHandoff();
|
||||
customerCoordinator.removeListener(_notifyFromChild);
|
||||
reservationCoordinator.removeListener(_notifyFromChild);
|
||||
faceRecognitionCoordinator.removeListener(_notifyFromChild);
|
||||
|
||||
@@ -122,6 +122,39 @@ void main() {
|
||||
await onlineController.clearForPrivacy();
|
||||
});
|
||||
|
||||
testWidgets('人脸方式先显示识别页,识别成功后才发送预约查询状态', (tester) async {
|
||||
final repository = _ImmediateRepository(_result('张*明', 1));
|
||||
final recognition = _FakeRecognitionGateway();
|
||||
final subScreen = _FakeSubScreenGateway();
|
||||
final controller = ReservationMenuQueryController(
|
||||
repository: repository,
|
||||
recognitionGateway: recognition,
|
||||
subScreenGateway: subScreen,
|
||||
networkAvailable: () => true,
|
||||
);
|
||||
addTearDown(controller.onClose);
|
||||
|
||||
controller.onInit();
|
||||
await tester.pump();
|
||||
subScreen.updates.clear();
|
||||
|
||||
await controller.selectMethod(ReservationRecognitionMethod.face);
|
||||
expect(recognition.faceCallbacks, hasLength(1));
|
||||
expect(
|
||||
subScreen.updates,
|
||||
isEmpty,
|
||||
reason: '人脸识别开始后由统一人脸协议接管副屏,不能提前发送预约 loading',
|
||||
);
|
||||
|
||||
await recognition.emitFaceAt(0, '1001');
|
||||
expect(repository.calls, ['1001']);
|
||||
expect(
|
||||
subScreen.updates.map((event) => event.scenario),
|
||||
['loading', 'success'],
|
||||
);
|
||||
await controller.clearForPrivacy();
|
||||
});
|
||||
|
||||
test('预约日期模型按 5、10、11 天正确分页', () {
|
||||
for (final entry in const {5: 1, 10: 1, 11: 2}.entries) {
|
||||
final display = ReservationMenuDisplayData.fromService(
|
||||
@@ -169,12 +202,18 @@ class _ImmediateRepository implements ReservationMenuRepository {
|
||||
class _FakeRecognitionGateway implements ReservationRecognitionGateway {
|
||||
final cardCallbacks = <Future<void> Function(String)>[];
|
||||
final cardFailures = <void Function(ReservationRecognitionFailure failure)>[];
|
||||
final faceCallbacks = <Future<void> Function(String)>[];
|
||||
final faceFailures = <void Function(ReservationRecognitionFailure failure)>[];
|
||||
int stopAllCalls = 0;
|
||||
|
||||
Future<void> emitCardAt(int index, String value) {
|
||||
return cardCallbacks[index](value);
|
||||
}
|
||||
|
||||
Future<void> emitFaceAt(int index, String value) {
|
||||
return faceCallbacks[index](value);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> startCard({
|
||||
required Future<void> Function(String cardNumber) onRecognized,
|
||||
@@ -188,7 +227,10 @@ class _FakeRecognitionGateway implements ReservationRecognitionGateway {
|
||||
Future<void> startFace({
|
||||
required Future<void> Function(String userId) onRecognized,
|
||||
required void Function(ReservationRecognitionFailure failure) onFailure,
|
||||
}) async {}
|
||||
}) async {
|
||||
faceCallbacks.add(onRecognized);
|
||||
faceFailures.add(onFailure);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stopAll() async {
|
||||
|
||||
@@ -128,6 +128,174 @@ void main() {
|
||||
|
||||
coordinator.dispose();
|
||||
});
|
||||
|
||||
testWidgets('预约人脸成功后等待当前用户查询结果再恢复预约菜单', (tester) async {
|
||||
final now = DateTime(2026, 7, 24, 8);
|
||||
final coordinator = SubDisplayCoordinator(
|
||||
reservationCoordinator: ReservationQueryCoordinator(now: () => now),
|
||||
);
|
||||
addTearDown(coordinator.dispose);
|
||||
|
||||
expect(
|
||||
coordinator.handleAction(
|
||||
'reservationQueryUpdate',
|
||||
const {
|
||||
'sessionId': 'query-1',
|
||||
'revision': 1,
|
||||
'scenario': 'idle',
|
||||
},
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(coordinator.mode, SubDisplayMode.reservationQuery);
|
||||
|
||||
expect(
|
||||
coordinator.handleAction(
|
||||
'faceRecognitionStart',
|
||||
const {
|
||||
'sessionId': 'face-1',
|
||||
'revision': 1,
|
||||
'scene': 'reservation',
|
||||
'message': '正在启动摄像头',
|
||||
},
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
coordinator.handleAction(
|
||||
'faceRecognitionSuccess',
|
||||
const {
|
||||
'sessionId': 'face-1',
|
||||
'revision': 2,
|
||||
'scene': 'reservation',
|
||||
'message': '识别成功,正在读取信息',
|
||||
},
|
||||
);
|
||||
expect(coordinator.mode, SubDisplayMode.faceRecognition);
|
||||
|
||||
coordinator.handleAction(
|
||||
'reservationQueryUpdate',
|
||||
const {
|
||||
'sessionId': 'query-1',
|
||||
'revision': 2,
|
||||
'scenario': 'loading',
|
||||
},
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
expect(
|
||||
coordinator.mode,
|
||||
SubDisplayMode.faceRecognition,
|
||||
reason: '中间 loading 不能提前覆盖人脸成功页',
|
||||
);
|
||||
|
||||
expect(
|
||||
coordinator.handleAction(
|
||||
'reservationQueryUpdate',
|
||||
_reservationEvent(
|
||||
sessionId: 'query-1',
|
||||
revision: 3,
|
||||
expiresAt: now.add(const Duration(seconds: 60)),
|
||||
),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(coordinator.mode, SubDisplayMode.reservationQuery);
|
||||
expect(
|
||||
coordinator.reservationCoordinator.state.scenario,
|
||||
ReservationQueryDisplayScenario.success,
|
||||
);
|
||||
coordinator.dispose();
|
||||
});
|
||||
|
||||
testWidgets('预约查询先返回时仍保证人脸成功提示展示 700ms', (tester) async {
|
||||
final now = DateTime(2026, 7, 24, 8);
|
||||
final coordinator = SubDisplayCoordinator(
|
||||
reservationCoordinator: ReservationQueryCoordinator(now: () => now),
|
||||
);
|
||||
addTearDown(coordinator.dispose);
|
||||
|
||||
coordinator.handleAction(
|
||||
'reservationQueryUpdate',
|
||||
const {
|
||||
'sessionId': 'query-2',
|
||||
'revision': 1,
|
||||
'scenario': 'idle',
|
||||
},
|
||||
);
|
||||
coordinator.handleAction(
|
||||
'faceRecognitionStart',
|
||||
const {
|
||||
'sessionId': 'face-2',
|
||||
'revision': 1,
|
||||
'scene': 'reservation',
|
||||
'message': '正在启动摄像头',
|
||||
},
|
||||
);
|
||||
coordinator.handleAction(
|
||||
'faceRecognitionSuccess',
|
||||
const {
|
||||
'sessionId': 'face-2',
|
||||
'revision': 2,
|
||||
'scene': 'reservation',
|
||||
'message': '识别成功,正在读取信息',
|
||||
},
|
||||
);
|
||||
coordinator.handleAction(
|
||||
'reservationQueryUpdate',
|
||||
_reservationEvent(
|
||||
sessionId: 'query-2',
|
||||
revision: 2,
|
||||
expiresAt: now.add(const Duration(seconds: 60)),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 699));
|
||||
expect(coordinator.mode, SubDisplayMode.faceRecognition);
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
expect(coordinator.mode, SubDisplayMode.reservationQuery);
|
||||
coordinator.dispose();
|
||||
});
|
||||
|
||||
test('预约查询清屏会结束已完成但仍在等待结果的人脸页', () {
|
||||
final coordinator = SubDisplayCoordinator();
|
||||
addTearDown(coordinator.dispose);
|
||||
|
||||
coordinator.handleAction(
|
||||
'reservationQueryUpdate',
|
||||
const {
|
||||
'sessionId': 'query-3',
|
||||
'revision': 1,
|
||||
'scenario': 'idle',
|
||||
},
|
||||
);
|
||||
coordinator.handleAction(
|
||||
'faceRecognitionStart',
|
||||
const {
|
||||
'sessionId': 'face-3',
|
||||
'revision': 1,
|
||||
'scene': 'reservation',
|
||||
'message': '正在启动摄像头',
|
||||
},
|
||||
);
|
||||
coordinator.handleAction(
|
||||
'faceRecognitionSuccess',
|
||||
const {
|
||||
'sessionId': 'face-3',
|
||||
'revision': 2,
|
||||
'scene': 'reservation',
|
||||
'message': '识别成功,正在读取信息',
|
||||
},
|
||||
);
|
||||
expect(coordinator.mode, SubDisplayMode.faceRecognition);
|
||||
|
||||
expect(
|
||||
coordinator.handleAction(
|
||||
'reservationQueryClear',
|
||||
const {'sessionId': 'query-3', 'revision': 2},
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(coordinator.mode, SubDisplayMode.reservationQuery);
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> _reservationEvent({
|
||||
|
||||
Reference in new issue
Block a user