result = new ArrayList<>(1);
+ result.add(largestFace);
+ return result;
+ }
+
+ /**
+ * 更新当前最大脸目标,并将刚退出目标位的人脸恢复为可重试状态。
+ * 这样目标再次成为最大脸时不会停留在已经没有异步任务的 SEARCHING 状态。
+ *
+ * @param trackId 当前最大脸跟踪编号;无目标或多人识别模式时为空
+ */
+ private void updateRecognitionTarget(@Nullable Integer trackId) {
+ Integer previousTrackId = currentRecognitionTargetTrackId;
+ if (Objects.equals(previousTrackId, trackId)) {
+ return;
+ }
+ currentRecognitionTargetTrackId = trackId;
+ if (recognizeConfiguration.isKeepMaxFace() && previousTrackId != null) {
+ RecognizeInfo previousInfo = recognizeInfoMap.get(previousTrackId);
+ if (previousInfo != null) {
+ previousInfo.setRecognizeStatus(RequestFeatureStatus.TO_RETRY);
+ previousInfo.setLiveness(LivenessInfo.UNKNOWN);
+ synchronized (previousInfo.getWaitLock()) {
+ previousInfo.getWaitLock().notifyAll();
+ }
+ }
+ }
+ }
+
+ /**
+ * 判断异步任务是否仍属于当前会话且仍是允许识别的目标脸。
+ *
+ * @param trackId 异步任务对应的人脸跟踪编号
+ * @param generation 创建异步任务时的业务会话代次
+ * @return 任务结果仍可进入识别回调时返回 true
+ */
+ private boolean isCurrentRecognitionTarget(@Nullable Integer trackId, long generation) {
+ return generation == sessionGeneration
+ && (!recognizeConfiguration.isKeepMaxFace()
+ || Objects.equals(currentRecognitionTargetTrackId, trackId));
+ }
+
@Override
public void onFail(Exception e) {
Log.e(TAG, "onFail:" + e.getMessage());
@@ -492,6 +603,9 @@ public class FaceHelper implements FaceListener {
@Override
public void onFaceFeatureInfoGet(@Nullable FaceFeature faceFeature, Integer trackId, Integer errorCode) {
+ if (!isCurrentRecognitionTarget(trackId, sessionGeneration)) {
+ return;
+ }
//FR成功
RecognizeInfo recognizeInfo = getRecognizeInfo(recognizeInfoMap, trackId);
if (faceFeature != null) {
@@ -610,6 +724,9 @@ public class FaceHelper implements FaceListener {
@Override
public void onFaceLivenessInfoGet(@Nullable LivenessInfo livenessInfo, Integer trackId, Integer errorCode) {
+ if (!isCurrentRecognitionTarget(trackId, sessionGeneration)) {
+ return;
+ }
if (livenessInfo != null) {
int liveness = livenessInfo.getLiveness();
Log.i(TAG, "onFaceLivenessInfoGet liveness:" + liveness);
@@ -649,14 +766,20 @@ public class FaceHelper implements FaceListener {
}
private void searchFace(final FaceFeature faceFeature, final Integer trackId) {
+ if (!isCurrentRecognitionTarget(trackId, sessionGeneration)) {
+ return;
+ }
CompareResult compareResult = FaceServer.getInstance().searchFaceFeature(faceFeature);
if (compareResult == null || compareResult.getFaceEntity() == null) {
retryRecognizeDelayed(trackId);
return;
}
compareResult.setTrackId(trackId);
+ // 相似度属于当前跟踪人脸,先保存再判断阈值,预览框才能展示真实的最近一次比对结果。
+ setSimilarity(trackId, compareResult.getSimilar());
boolean pass = compareResult.getSimilar() > recognizeConfiguration.getSimilarThreshold();
- recognizeCallback.onRecognized(compareResult, getRecognizeInfo(recognizeInfoMap, trackId).getLiveness(), pass);
+ recognizeCallback.onRecognized(compareResult, getRecognizeInfo(recognizeInfoMap, trackId).getLiveness(), pass,
+ recognitionFrameMap.get(trackId));
if (pass) {
String msg = "识别通过";
Log.i(TAG, "用户名字:" + compareResult.getFaceEntity().getUserName() + ";人脸id:" + compareResult.getFaceEntity().getFaceId() + ";");
@@ -688,6 +811,7 @@ public class FaceHelper implements FaceListener {
private Integer trackId;
private byte[] nv21Data;
private int isMask;
+ private final long generation;
/**
* 异步特征提取任务的构造函数
@@ -699,20 +823,26 @@ public class FaceHelper implements FaceListener {
* @param format 图像格式
*/
private FaceRecognizeRunnable(byte[] nv21Data, FacePreviewInfo facePreviewInfo, int width, int height, int format) {
+ this.generation = sessionGeneration;
if (nv21Data == null) {
return;
}
- this.nv21Data = nv21Data;
+ // Camera1 使用回调缓冲区复用帧内存,异步特征提取必须持有自己的快照,
+ // 否则下一帧会覆盖当前顾客数据,连续识别时可能出现错人或提取失败。
+ this.nv21Data = Arrays.copyOf(nv21Data, nv21Data.length);
this.faceInfo = new FaceInfo(facePreviewInfo.getFaceInfoRgb());
this.width = width;
this.height = height;
this.format = format;
this.trackId = facePreviewInfo.getTrackId();
this.isMask = facePreviewInfo.getMask();
+ recognitionFrameMap.put(this.trackId, new RecognitionFrameSnapshot(this.nv21Data, this.faceInfo,
+ this.width, this.height, System.currentTimeMillis()));
}
@Override
public void run() {
+ if (!isCurrentRecognitionTarget(trackId, generation)) return;
if (nv21Data != null) {
if (frEngine != null) {
if (recognizeConfiguration.isEnableImageQuality()) {
@@ -763,14 +893,20 @@ public class FaceHelper implements FaceListener {
}
Log.i(TAG, "frTime:" + (System.currentTimeMillis() - irStartTime) + "ms");
if (frCode == ErrorInfo.MOK) {
- onFaceFeatureInfoGet(faceFeature, trackId, frCode);
+ synchronized (FaceHelper.this) {
+ if (!isCurrentRecognitionTarget(trackId, generation)) return;
+ onFaceFeatureInfoGet(faceFeature, trackId, frCode);
+ }
} else {
onFaceFail(frCode, "fr failed errorCode is " + frCode);
}
}
private void onFaceFail(int code, String errorMsg) {
- onFaceFeatureInfoGet(null, trackId, code);
+ synchronized (FaceHelper.this) {
+ if (!isCurrentRecognitionTarget(trackId, generation)) return;
+ onFaceFeatureInfoGet(null, trackId, code);
+ }
onFail(new Exception(errorMsg));
}
}
@@ -788,10 +924,13 @@ public class FaceHelper implements FaceListener {
private LivenessType livenessType;
private Object waitLock;
+ /** 创建活体任务时的会话代次,用于丢弃上一位顾客或上一目标脸的迟到结果。 */
+ private final long generation;
+
/**
* 异步活体任务的构造函数
*
- * @param nv21Data 可见光或红外图像数据
+ * @param nv21Data 可见光或红外图像数据;构造时复制,避免 Camera 复用缓冲区覆盖异步任务
* @param faceInfo 可见光人脸检测得到的人脸信息
* @param width 图像宽度
* @param height 图像高度
@@ -800,10 +939,11 @@ public class FaceHelper implements FaceListener {
* @param waitLock 活体检测通过后,调用该对象的notifyAll函数,通知识别线程活体已通过
*/
private FaceLivenessDetectRunnable(byte[] nv21Data, FacePreviewInfo faceInfo, int width, int height, int format, LivenessType livenessType, Object waitLock) {
+ this.generation = sessionGeneration;
if (nv21Data == null) {
return;
}
- this.nv21Data = nv21Data;
+ this.nv21Data = Arrays.copyOf(nv21Data, nv21Data.length);
this.faceInfo = new FaceInfo(faceInfo.getFaceInfoRgb());
this.width = width;
this.height = height;
@@ -815,6 +955,7 @@ public class FaceHelper implements FaceListener {
@Override
public void run() {
+ if (!isCurrentRecognitionTarget(trackId, generation)) return;
if (nv21Data != null) {
if (flEngine != null) {
processLiveness();
@@ -864,6 +1005,7 @@ public class FaceHelper implements FaceListener {
}
if (flCode == ErrorInfo.MOK && !livenessInfoList.isEmpty()) {
+ if (!isCurrentRecognitionTarget(trackId, generation)) return;
onFaceLivenessInfoGet(livenessInfoList.get(0), trackId, flCode);
if (livenessInfoList.get(0).getLiveness() == LivenessInfo.ALIVE) {
synchronized (waitLock) {
@@ -876,6 +1018,7 @@ public class FaceHelper implements FaceListener {
}
private void onProcessFail(int code, String msg) {
+ if (!isCurrentRecognitionTarget(trackId, generation)) return;
onFaceLivenessInfoGet(null, trackId, code);
onFail(new Exception(msg));
}
@@ -1003,6 +1146,15 @@ public class FaceHelper implements FaceListener {
return recognizeInfo == null ? null : recognizeInfo.getName();
}
+ public void setSimilarity(int trackId, float similarity) {
+ getRecognizeInfo(recognizeInfoMap, trackId).setSimilarity(similarity);
+ }
+
+ public Float getSimilarity(int trackId) {
+ RecognizeInfo recognizeInfo = recognizeInfoMap.get(trackId);
+ return recognizeInfo == null ? null : recognizeInfo.getSimilarity();
+ }
+
/**
* 设置可识别区域(相对于View)
@@ -1080,7 +1232,7 @@ public class FaceHelper implements FaceListener {
}
/**
- * 保留ftFaceList中最大的人脸
+ * 保留 ftFaceList 中面积最大的人脸。
*
* @param ftFaceList 人脸追踪时,一帧数据的人脸信息
*/
@@ -1090,7 +1242,7 @@ public class FaceHelper implements FaceListener {
}
FaceInfo maxFaceInfo = ftFaceList.get(0);
for (FaceInfo faceInfo : ftFaceList) {
- if (faceInfo.getRect().width() > maxFaceInfo.getRect().width()) {
+ if (faceArea(faceInfo.getRect()) > faceArea(maxFaceInfo.getRect())) {
maxFaceInfo = faceInfo;
}
}
@@ -1098,6 +1250,19 @@ public class FaceHelper implements FaceListener {
ftFaceList.add(maxFaceInfo);
}
+ /**
+ * 计算原始摄像头坐标中的有效矩形面积,异常或反向矩形按 0 处理。
+ *
+ * @param rect 虹软返回的人脸矩形
+ * @return 人脸矩形面积,单位为预览像素平方
+ */
+ private static long faceArea(@Nullable Rect rect) {
+ if (rect == null) {
+ return 0L;
+ }
+ return (long) Math.max(0, rect.width()) * Math.max(0, rect.height());
+ }
+
public static final class Builder {
private FaceEngine ftEngine;
diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/callback/RecognizeCallback.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/callback/RecognizeCallback.java
index ebda709..3560be1 100644
--- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/callback/RecognizeCallback.java
+++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/callback/RecognizeCallback.java
@@ -2,6 +2,7 @@ package io.flutter.plugins.facediscern.tool.face.help.callback;
import io.flutter.plugins.facediscern.tool.face.model.CompareResult;
+import io.flutter.plugins.facediscern.tool.face.model.RecognitionFrameSnapshot;
public interface RecognizeCallback {
/**
@@ -10,8 +11,10 @@ public interface RecognizeCallback {
* @param compareResult 比对结果
* @param liveness 活体值
* @param similarPass 是否通过(依据设置的阈值)
+ * @param frameSnapshot 实际提取特征的 NV21 和目标人脸框;旧 Activity 链路允许为空
*/
- void onRecognized(CompareResult compareResult, Integer liveness, boolean similarPass);
+ void onRecognized(CompareResult compareResult, Integer liveness, boolean similarPass,
+ RecognitionFrameSnapshot frameSnapshot);
/**
* 提示文字变更的回调
diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognitionFrameSnapshot.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognitionFrameSnapshot.java
new file mode 100644
index 0000000..d83d4f1
--- /dev/null
+++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognitionFrameSnapshot.java
@@ -0,0 +1,62 @@
+package io.flutter.plugins.facediscern.tool.face.model;
+
+import com.arcsoft.face.FaceInfo;
+
+import java.util.Arrays;
+
+/**
+ * 人脸特征提取使用的可见光帧快照。
+ *
+ * Camera1 会复用回调缓冲区,因此这里必须复制 NV21 和 FaceInfo,保证异步比对成功后
+ * 仍然能裁剪出同一张实际命中目标的人脸图片。
+ */
+public final class RecognitionFrameSnapshot {
+ /** 原始 NV21 帧的独立副本。 */
+ private final byte[] nv21;
+
+ /** 该帧中实际参与特征提取的人脸框。 */
+ private final FaceInfo faceInfo;
+
+ /** NV21 原始宽度,单位为像素。 */
+ private final int width;
+
+ /** NV21 原始高度,单位为像素。 */
+ private final int height;
+
+ /** 相机回调采集时间,单位为 Unix 毫秒。 */
+ private final long capturedAt;
+
+ /** 创建一份不可变的人脸成功帧快照。 */
+ public RecognitionFrameSnapshot(byte[] nv21, FaceInfo faceInfo, int width, int height, long capturedAt) {
+ this.nv21 = nv21 == null ? null : Arrays.copyOf(nv21, nv21.length);
+ this.faceInfo = faceInfo == null ? null : new FaceInfo(faceInfo);
+ this.width = width;
+ this.height = height;
+ this.capturedAt = capturedAt;
+ }
+
+ /** 返回 NV21 快照副本,避免调用方修改共享内容。 */
+ public byte[] getNv21() {
+ return nv21 == null ? null : Arrays.copyOf(nv21, nv21.length);
+ }
+
+ /** 返回实际命中人脸框的独立副本。 */
+ public FaceInfo getFaceInfo() {
+ return faceInfo == null ? null : new FaceInfo(faceInfo);
+ }
+
+ /** 返回 NV21 原始宽度。 */
+ public int getWidth() {
+ return width;
+ }
+
+ /** 返回 NV21 原始高度。 */
+ public int getHeight() {
+ return height;
+ }
+
+ /** 返回相机帧采集时间。 */
+ public long getCapturedAt() {
+ return capturedAt;
+ }
+}
diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java
index e67f62e..96e41a4 100644
--- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java
+++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java
@@ -6,6 +6,21 @@ 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_LIBRARY_FACE_QUALITY_THRESHOLD = 0.60F;
+ 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;
+
/**
* 产生特征提取失败示语的特征提取次数(小于该值不提示)
*/
@@ -30,6 +45,10 @@ public class RecognizeConfiguration {
* 图像质量检测阈值:适用于戴口罩且人脸识别场景
*/
private float imageQualityMaskRecognizeThreshold;
+ /**
+ * 在库照片质量诊断阈值,不参与实时识别和特征注册。
+ */
+ private float libraryFaceQualityThreshold;
/**
* 识别失败重试间隔
*/
@@ -86,6 +105,7 @@ public class RecognizeConfiguration {
this.similarThreshold = builder.similarThreshold;
this.imageQualityNoMaskRecognizeThreshold = builder.imageQualityNoMaskRecognizeThreshold;
this.imageQualityMaskRecognizeThreshold = builder.imageQualityMaskRecognizeThreshold;
+ this.libraryFaceQualityThreshold = builder.libraryFaceQualityThreshold;
this.enableLiveness = builder.enableLiveness;
this.enableImageQuality = builder.enableImageQuality;
this.enableFaceAreaLimit = builder.enableFaceAreaLimit;
@@ -100,23 +120,25 @@ 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;
+ /** 在库照片质量诊断阈值,允许范围为0到1。 */
+ private float libraryFaceQualityThreshold = DEFAULT_LIBRARY_FACE_QUALITY_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;
@@ -158,6 +180,17 @@ public class RecognizeConfiguration {
return this;
}
+ /**
+ * 设置在库照片质量诊断阈值。
+ *
+ * @param val 0到1之间的质量分界值
+ * @return 当前构建器
+ */
+ public Builder libraryFaceQualityThreshold(float val) {
+ this.libraryFaceQualityThreshold = val;
+ return this;
+ }
+
public Builder enableLiveness(boolean val) {
this.enableLiveness = val;
return this;
@@ -217,10 +250,51 @@ public class RecognizeConfiguration {
return imageQualityMaskRecognizeThreshold;
}
+ /**
+ * 读取在库照片质量诊断阈值。
+ *
+ * @return 0到1之间的质量分界值
+ */
+ public float getLibraryFaceQualityThreshold() {
+ return libraryFaceQualityThreshold;
+ }
+
public boolean isEnableImageQuality() {
return enableImageQuality;
}
+ /**
+ * 创建仅切换实时图像质量能力的运行时配置,其他现场参数保持不变。
+ *
+ * 该副本不会写回 SharedPreferences,专供虹软质量增值模块初始化失败时
+ * 降级本次识别会话,避免授权差异阻断原有特征提取和比对。
+ *
+ * @param enabled 本次识别会话是否执行实时图像质量检测
+ * @return 保留其余识别参数的新配置
+ */
+ public RecognizeConfiguration withImageQualityEnabled(boolean enabled) {
+ return new Builder()
+ .extractRetryCount(extractRetryCount)
+ .livenessRetryCount(livenessRetryCount)
+ .maxDetectFaces(maxDetectFaces)
+ .recognizeFailedRetryInterval(recognizeFailedRetryInterval)
+ .livenessFailedRetryInterval(livenessFailedRetryInterval)
+ .similarThreshold(similarThreshold)
+ .imageQualityNoMaskRecognizeThreshold(imageQualityNoMaskRecognizeThreshold)
+ .imageQualityMaskRecognizeThreshold(imageQualityMaskRecognizeThreshold)
+ .libraryFaceQualityThreshold(libraryFaceQualityThreshold)
+ .enableLiveness(enableLiveness)
+ .enableImageQuality(enabled)
+ .enableFaceAreaLimit(enableFaceAreaLimit)
+ .keepMaxFace(keepMaxFace)
+ .livenessParam(livenessParam)
+ .enableFaceSizeLimit(enableFaceSizeLimit)
+ .enableFaceMoveLimit(enableFaceMoveLimit)
+ .faceSizeLimit(faceSizeLimit)
+ .faceMoveLimit(faceMoveLimit)
+ .build();
+ }
+
public boolean isEnableFaceAreaLimit() {
return enableFaceAreaLimit;
}
@@ -291,6 +365,7 @@ public class RecognizeConfiguration {
"enableImageQuality: " + enableImageQuality + "\r\n" +
"imageQualityNoMaskRecognizeThreshold: " + imageQualityNoMaskRecognizeThreshold + "\r\n" +
"imageQualityMaskRecognizeThreshold: " + imageQualityMaskRecognizeThreshold + "\r\n" +
+ "libraryFaceQualityThreshold: " + libraryFaceQualityThreshold + "\r\n" +
"enableLiveness: " + enableLiveness + "\r\n" +
"livenessRetryCount: " + livenessRetryCount + "\r\n" +
diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStore.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStore.java
new file mode 100644
index 0000000..685cd93
--- /dev/null
+++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStore.java
@@ -0,0 +1,142 @@
+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))
+ .libraryFaceQualityThreshold(preferences.getFloat("libraryFaceQualityThreshold", RecognizeConfiguration.DEFAULT_LIBRARY_FACE_QUALITY_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);
+ float libraryFaceQualityThreshold = floatValue(values, "libraryFaceQualityThreshold", current.getLibraryFaceQualityThreshold(), 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)
+ .putFloat("libraryFaceQualityThreshold", libraryFaceQualityThreshold)
+ .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 toMap(RecognizeConfiguration configuration) {
+ Map 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("libraryFaceQualityThreshold", configuration.getLibraryFaceQualityThreshold());
+ 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;
+ }
+}
diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeInfo.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeInfo.java
index 9350b37..bba4d54 100644
--- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeInfo.java
+++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeInfo.java
@@ -19,6 +19,8 @@ public class RecognizeInfo {
* 用于存储活体值
*/
private int liveness = LivenessInfo.UNKNOWN;
+ /** 最近一次人脸库比对相似度;尚未完成比对时为空。 */
+ private volatile Float similarity;
/**
* 用于存储活体检测出错重试次数
*/
@@ -52,6 +54,14 @@ public class RecognizeInfo {
return liveness;
}
+ public Float getSimilarity() {
+ return similarity;
+ }
+
+ public void setSimilarity(Float similarity) {
+ this.similarity = similarity;
+ }
+
public int increaseAndGetLivenessErrorRetryCount() {
return ++livenessErrorRetryCount;
}
@@ -83,4 +93,4 @@ public class RecognizeInfo {
public int getLivenessErrorRetryCount() {
return livenessErrorRetryCount;
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java
index 04b092d..12f6dd9 100644
--- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java
+++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java
@@ -11,6 +11,7 @@ import com.arcsoft.face.FaceEngine;
import com.arcsoft.face.FaceFeature;
import com.arcsoft.face.FaceFeatureInfo;
import com.arcsoft.face.FaceInfo;
+import com.arcsoft.face.ImageQualitySimilar;
import com.arcsoft.face.MaskInfo;
import com.arcsoft.face.SearchResult;
import com.arcsoft.face.enums.DetectFaceOrientPriority;
@@ -25,8 +26,13 @@ import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Collections;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
+import java.util.Set;
+import java.util.HashSet;
import io.flutter.plugins.facediscern.tool.face.model.CompareResult;
import io.flutter.plugins.facediscern.tool.face.model.FaceEntity;
@@ -38,7 +44,10 @@ public class FaceServer {
private static final String TAG = "FaceServer";
private static FaceEngine faceEngine = null;
private static volatile FaceServer faceServer = null;
- private List faceRegisterInfoList = new ArrayList<>();
+ private final Map faceRegisterInfoMap = new HashMap<>();
+ private int engineInitCode = -99;
+ private int imageQualityInitCode = -99;
+ private boolean imageQualityReady;
private String imageRootPath;
/**
* 最大注册人脸数
@@ -48,19 +57,57 @@ public class FaceServer {
public synchronized void init(Context context) {
if (faceEngine == null && context != null) {
faceEngine = new FaceEngine();
- int engineCode = faceEngine.init(
+ int requestedCode = faceEngine.init(
context,
DetectMode.ASF_DETECT_MODE_IMAGE,
DetectFaceOrientPriority.ASF_OP_ALL_OUT,
1,
- FaceEngine.ASF_FACE_RECOGNITION | FaceEngine.ASF_FACE_DETECT | FaceEngine.ASF_MASK_DETECT
+ FaceEngine.ASF_FACE_RECOGNITION | FaceEngine.ASF_FACE_DETECT | FaceEngine.ASF_MASK_DETECT | FaceEngine.ASF_IMAGEQUALITY
);
- if (engineCode != ErrorInfo.MOK) {
- Log.e(TAG, "人脸引擎初始化失败code" + engineCode);
+ imageQualityInitCode = requestedCode;
+ if (requestedCode == ErrorInfo.MOK) {
+ engineInitCode = requestedCode;
+ imageQualityReady = true;
+ } else {
+ Log.w(TAG, "图像质量模块不可用,回退基础人脸引擎code" + requestedCode);
+ faceEngine.unInit();
+ faceEngine = null;
+ faceEngine = new FaceEngine();
+ engineInitCode = faceEngine.init(
+ context,
+ DetectMode.ASF_DETECT_MODE_IMAGE,
+ DetectFaceOrientPriority.ASF_OP_ALL_OUT,
+ 1,
+ FaceEngine.ASF_FACE_RECOGNITION | FaceEngine.ASF_FACE_DETECT | FaceEngine.ASF_MASK_DETECT
+ );
+ imageQualityReady = false;
+ if (engineInitCode != ErrorInfo.MOK) {
+ Log.e(TAG, "人脸引擎初始化失败code" + engineInitCode);
+ faceEngine.unInit();
+ faceEngine = null;
+ }
}
}
}
+ public synchronized int getEngineInitCode() {
+ return engineInitCode;
+ }
+
+ public synchronized boolean isEngineReady() {
+ return faceEngine != null && engineInitCode == ErrorInfo.MOK;
+ }
+
+ /** 返回当前授权是否包含虹软图像质量模块。 */
+ public synchronized boolean isImageQualityReady() {
+ return imageQualityReady;
+ }
+
+ /** 返回图像质量模块初始化返回码,用于终端诊断授权状态。 */
+ public synchronized int getImageQualityInitCode() {
+ return imageQualityInitCode;
+ }
+
public static FaceServer getInstance() {
if (faceServer == null) {
synchronized (FaceServer.class) {
@@ -76,58 +123,142 @@ public class FaceServer {
* 销毁
*/
public synchronized void release() {
- if (faceRegisterInfoList != null) {
- faceRegisterInfoList.clear();
- faceRegisterInfoList = null;
- }
+ faceRegisterInfoMap.clear();
if (faceEngine != null) {
synchronized (faceEngine) {
faceEngine.unInit();
}
faceEngine = null;
}
+ engineInitCode = -99;
+ imageQualityInitCode = -99;
+ imageQualityReady = false;
faceServer = null;
}
- public int registerFaceFeatureInfoListFromDb(List faceEntityList) {
+ /**
+ * 全量重建使用:先清空旧内存库,再按调用方传入的数据重新装载。
+ */
+ public synchronized int registerFaceFeatureInfoListFromDb(List faceEntityList) {
if (faceEngine == null) return -99;
+ faceEngine.removeFaceFeature(-1);
+ faceRegisterInfoMap.clear();
+ return registerFaceFeatureInfoBatchFromDb(faceEntityList);
+ }
+
+ /**
+ * 只追加当前批次,不清空其他人脸;用于分批全量装载和单用户增量替换。
+ */
+ public synchronized int registerFaceFeatureInfoBatchFromDb(List faceEntityList) {
+ if (faceEngine == null || faceEntityList == null || faceEntityList.isEmpty()) return -99;
List faceFeatureInfoList = new ArrayList<>();
for (FaceEntity faceEntity : faceEntityList) {
FaceFeatureInfo faceFeatureInfo = new FaceFeatureInfo((int) faceEntity.getFaceId(), faceEntity.getFaceModel());
faceFeatureInfoList.add(faceFeatureInfo);
}
- //首先清除FaceEngine中所有人脸数据,再添加新的人脸数据
- faceEngine.removeFaceFeature(-1);
int code = faceEngine.registerFaceFeature(faceFeatureInfoList);
if (code == ErrorInfo.MOK) {
- faceRegisterInfoList.addAll(faceEntityList);
+ for (FaceEntity faceEntity : faceEntityList) {
+ faceRegisterInfoMap.put(faceEntity.getFaceId(), faceEntity);
+ }
}
return code;
}
- // 注册单个人脸数据
- public int registerFaceFeatureInfoFromDb(FaceEntity faceEntity) {
- if (faceEntity != null && faceEngine != null) {
- FaceFeatureInfo faceFeatureInfo = new FaceFeatureInfo(faceEntity.getFaceId(), faceEntity.getFaceModel());
- int code = faceEngine.registerFaceFeature(faceFeatureInfo);
- if (code == ErrorInfo.MOK) {
- faceEntity.setFaceModel(faceFeatureInfo.getFeatureData());
- // 查询一下确保入库成功
- FaceFeatureInfo afterDetail = getFaceFeature(faceEntity.getFaceId());
- if (Objects.nonNull(afterDetail)) {
- long count = faceRegisterInfoList.stream().filter(v -> Objects.equals(v.getFaceId(), faceEntity.getFaceId())).count();
- if (count == 0) {
- faceRegisterInfoList.add(faceEntity);
- }
- return code;
- }
- }
+ /**
+ * 在同一虹软引擎互斥边界内原子替换单个搜索标识的人脸特征。
+ *
+ * 替换前复制旧特征和业务元数据;新特征注册失败或 SDK 抛异常时恢复旧特征,
+ * 避免坏图导致原本可识别的人脸永久丢失。
+ *
+ * @param faceEntity 包含搜索标识、用户元数据和新特征的目标人脸
+ * @return 虹软返回码,插件异常返回内部错误码 {@code -99}
+ */
+ public synchronized int replaceFaceFeature(FaceEntity faceEntity) {
+ if (faceEntity == null || faceEngine == null || faceEntity.getFaceId() == null || faceEntity.getFaceModel() == null) {
+ return -99;
}
- return -99;
+ int searchId = faceEntity.getFaceId();
+ FaceEntity oldEntity = copyFaceEntity(faceRegisterInfoMap.get(searchId));
+ try {
+ FaceFeatureInfo oldFeature = getFaceFeature(searchId);
+ if (oldEntity == null && oldFeature != null) {
+ oldEntity = new FaceEntity(searchId, oldFeature.getFeatureData());
+ }
+ faceEngine.removeFaceFeature(searchId);
+ faceRegisterInfoMap.remove(searchId);
+ FaceFeatureInfo newFeature = new FaceFeatureInfo(searchId, faceEntity.getFaceModel());
+ int code = faceEngine.registerFaceFeature(Collections.singletonList(newFeature));
+ if (code == ErrorInfo.MOK && getFaceFeature(searchId) != null) {
+ faceRegisterInfoMap.put(searchId, faceEntity);
+ return code;
+ }
+ restoreFaceFeature(searchId, oldEntity);
+ return code == ErrorInfo.MOK ? -99 : code;
+ } catch (Exception exception) {
+ Log.e(TAG, "替换人脸特征异常searchId:" + searchId + ";error:" + exception.getMessage(), exception);
+ restoreFaceFeature(searchId, oldEntity);
+ return -99;
+ }
+ }
+
+ /**
+ * 返回当前虹软内存库已登记的搜索标识快照,用于集合一致性判断。
+ *
+ * @return 与内部映射解耦的搜索标识集合
+ */
+ public synchronized Set getRegisteredFaceIds() {
+ return new HashSet<>(faceRegisterInfoMap.keySet());
+ }
+
+ /**
+ * 恢复单样本替换前的人脸特征;没有旧特征时保持目标搜索标识为空。
+ *
+ * @param searchId 需要恢复的虹软搜索标识
+ * @param oldEntity 替换前的特征和元数据,新增场景为空
+ */
+ private void restoreFaceFeature(int searchId, FaceEntity oldEntity) {
+ try {
+ faceEngine.removeFaceFeature(searchId);
+ faceRegisterInfoMap.remove(searchId);
+ if (oldEntity == null || oldEntity.getFaceModel() == null) return;
+ FaceFeatureInfo oldFeature = new FaceFeatureInfo(searchId, oldEntity.getFaceModel());
+ if (faceEngine.registerFaceFeature(Collections.singletonList(oldFeature)) == ErrorInfo.MOK) {
+ faceRegisterInfoMap.put(searchId, oldEntity);
+ } else {
+ Log.e(TAG, "恢复旧人脸特征失败searchId:" + searchId);
+ }
+ } catch (Exception exception) {
+ Log.e(TAG, "恢复旧人脸特征异常searchId:" + searchId + ";error:" + exception.getMessage(), exception);
+ }
+ }
+
+ /**
+ * 深拷贝内存库中的人脸业务元数据和特征,避免回滚快照被后续修改。
+ *
+ * @param source 待复制的人脸记录
+ * @return 独立副本,源记录为空时返回空
+ */
+ private FaceEntity copyFaceEntity(FaceEntity source) {
+ if (source == null) return null;
+ FaceEntity copy = new FaceEntity();
+ copy.setFaceId(source.getFaceId());
+ copy.setSampleId(source.getSampleId());
+ copy.setUserId(source.getUserId());
+ copy.setUserName(source.getUserName());
+ copy.setFaceFilePath(source.getFaceFilePath());
+ copy.setFaceModel(source.getFaceModel() == null ? null : source.getFaceModel().clone());
+ return copy;
+ }
+
+ // 注册单个人脸数据
+ public synchronized int registerFaceFeatureInfoFromDb(FaceEntity faceEntity) {
+ // 历史单条入口也复用带旧特征回滚的替换逻辑,避免 SDK 异常后丢失原可用样本。
+ return replaceFaceFeature(faceEntity);
}
// 从人脸数据库中搜索人脸
- public FaceFeatureInfo getFaceFeature(int searchId) {
+ public synchronized FaceFeatureInfo getFaceFeature(int searchId) {
FaceFeatureInfo faceFeatureInfo = null;
try {
faceFeatureInfo = faceEngine.getFaceFeature(searchId);
@@ -138,19 +269,49 @@ public class FaceServer {
}
// 获取人脸数量
- public int getTotalFaceCount() {
+ public synchronized int getTotalFaceCount() {
if (faceEngine == null) return -99;
return faceEngine.getFaceCount();
}
// 删除人脸-1删除全部
- public int removeFace(int searchId) {
+ public synchronized int removeFace(int searchId) {
if (faceEngine == null) return -99;
- return faceEngine.removeFaceFeature(searchId);
+ // MQ 和每日快照都可能重复删除同一样本;内存库已不存在时直接按幂等成功处理。
+ if (searchId != -1 && getFaceFeature(searchId) == null) {
+ faceRegisterInfoMap.remove(searchId);
+ return ErrorInfo.MOK;
+ }
+ int code = faceEngine.removeFaceFeature(searchId);
+ if (code == ErrorInfo.MOK) {
+ if (searchId == -1) {
+ faceRegisterInfoMap.clear();
+ } else {
+ faceRegisterInfoMap.remove(searchId);
+ }
+ }
+ return code;
+ }
+
+ /**
+ * 增量更新时仅移除该用户已注册的样本,避免同步单人后覆盖整个内存库。
+ */
+ public synchronized void removeFacesByUserId(String userId) {
+ if (faceEngine == null || userId == null) return;
+ List faceIds = new ArrayList<>();
+ for (Map.Entry entry : faceRegisterInfoMap.entrySet()) {
+ if (Objects.equals(userId, entry.getValue().getUserId())) {
+ faceIds.add(entry.getKey());
+ }
+ }
+ for (Integer faceId : faceIds) {
+ faceEngine.removeFaceFeature(faceId);
+ faceRegisterInfoMap.remove(faceId);
+ }
}
// 在特征库中搜索
- public CompareResult searchFaceFeature(FaceFeature faceFeature) {
+ public synchronized CompareResult searchFaceFeature(FaceFeature faceFeature) {
if (faceFeature == null) return null;
if (faceEngine == null) return null;
SearchResult searchResult = null;
@@ -160,8 +321,8 @@ public class FaceServer {
long searchCost = System.currentTimeMillis() - searchStart;
if (searchResult != null) {
FaceFeatureInfo faceFeatureInfo = searchResult.getFaceFeatureInfo();
- FaceEntity entity = faceRegisterInfoList.stream().filter(v -> Objects.equals(v.getFaceId(), faceFeatureInfo.getSearchId())).findFirst().get();
- if (Objects.isNull(entity)) {
+ FaceEntity entity = faceRegisterInfoMap.get(faceFeatureInfo.getSearchId());
+ if (entity == null) {
entity = new FaceEntity(faceFeatureInfo.getSearchId(), faceFeatureInfo.getFeatureData());
}
Log.i(TAG, "查询人脸总耗时:" + (System.currentTimeMillis() - searchStart) + "ms;username:" + entity.getUserName() + ";faceId:" + entity.getFaceId() + ";");
@@ -174,10 +335,23 @@ public class FaceServer {
}
- public byte[] registerBgr24(Context context, byte[] bgr24, int width, int height, String name) {
+ /** 保持旧调用方返回特征字节的兼容入口。 */
+ public synchronized byte[] registerBgr24(Context context, byte[] bgr24, int width, int height, String name) {
+ return registerBgr24WithQuality(context, bgr24, width, height, name).featureData;
+ }
+
+ /**
+ * 在统一解码结果上同时执行图像质量检测和虹软特征提取。
+ *
+ * 质量模块不可用或检测失败时仅记录failed状态,仍继续原有特征提取,兼容免费版授权。
+ */
+ public synchronized QualityFeatureResult registerBgr24WithQuality(Context context, byte[] bgr24, int width, int height, String name) {
+ QualityFeatureResult qualityResult = new QualityFeatureResult();
if (faceEngine == null || context == null || bgr24 == null || width % 4 != 0 || bgr24.length != width * height * 3) {
Log.e(TAG, "registerBgr24: invalid params");
- return null;
+ qualityResult.qualityStatus = "failed";
+ qualityResult.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM;
+ return qualityResult;
}
//人脸检测
List faceInfoList = new ArrayList<>();
@@ -186,6 +360,25 @@ public class FaceServer {
code = faceEngine.detectFaces(bgr24, width, height, FaceEngine.CP_PAF_BGR24, faceInfoList);
}
if (code == ErrorInfo.MOK && !faceInfoList.isEmpty()) {
+ if (imageQualityReady) {
+ ImageQualitySimilar qualitySimilar = new ImageQualitySimilar();
+ int qualityCode;
+ synchronized (faceEngine) {
+ qualityCode = faceEngine.imageQualityDetect(
+ bgr24, width, height, FaceEngine.CP_PAF_BGR24,
+ faceInfoList.get(0), MaskInfo.NOT_WORN, qualitySimilar);
+ }
+ if (qualityCode == ErrorInfo.MOK) {
+ qualityResult.qualityStatus = "detected";
+ qualityResult.qualityScore = (double) qualitySimilar.getScore();
+ } else {
+ qualityResult.qualityStatus = "failed";
+ qualityResult.qualityErrorCode = qualityCode;
+ }
+ } else {
+ qualityResult.qualityStatus = "failed";
+ qualityResult.qualityErrorCode = imageQualityInitCode;
+ }
code = faceEngine.process(bgr24, width, height, FaceEngine.CP_PAF_BGR24, faceInfoList,
FaceEngine.ASF_MASK_DETECT);
if (code == ErrorInfo.MOK) {
@@ -198,16 +391,18 @@ public class FaceServer {
* 注册照要求不戴口罩
*/
Log.e(TAG, "registerBgr24: maskInfo is worn");
- return null;
+ return qualityResult;
}
if (isMask == MaskInfo.UNKNOWN) {
Log.e(TAG, "registerBgr24: maskInfo is unknown");
- return null;
+ return qualityResult;
}
}
} else {
Log.e(TAG, "registerBgr24 mask process fail res=" + code);
- return null;
+ qualityResult.qualityStatus = "failed";
+ qualityResult.qualityErrorCode = code;
+ return qualityResult;
}
FaceFeature faceFeature = new FaceFeature();
/*
@@ -224,7 +419,9 @@ public class FaceServer {
Rect cropRect = getBestRect(width, height, faceInfoList.get(0).getRect());
if (cropRect == null) {
Log.e(TAG, "registerBgr24: cropRect is null");
- return null;
+ qualityResult.qualityStatus = "failed";
+ qualityResult.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM;
+ return qualityResult;
}
cropRect.left &= ~3;
cropRect.top &= ~3;
@@ -233,25 +430,83 @@ public class FaceServer {
String imgPath = getImagePath(userName, context);
// 创建一个头像的Bitmap,存放旋转结果图
Bitmap headBmp = getHeadImage(bgr24, width, height, faceInfoList.get(0).getOrient(), cropRect, ArcSoftImageFormat.BGR24);
- try {
- FileOutputStream fos = new FileOutputStream(imgPath);
+ try (FileOutputStream fos = new FileOutputStream(imgPath)) {
headBmp.compress(Bitmap.CompressFormat.JPEG, 100, fos);
- fos.close();
} catch (IOException e) {
- e.printStackTrace();
- return null;
+ Log.e(TAG, "registerBgr24: save head image failed", e);
+ return qualityResult;
+ } finally {
+ if (!headBmp.isRecycled()) headBmp.recycle();
}
- return faceFeature.getFeatureData();
+ qualityResult.featureData = faceFeature.getFeatureData();
+ return qualityResult;
} else {
Log.e(TAG, "registerBgr24: extract face feature failed, code is " + code);
- return null;
+ return qualityResult;
}
} else {
Log.e(TAG, "registerBgr24: no face detected, code is " + code);
- return null;
+ qualityResult.qualityStatus = "failed";
+ qualityResult.qualityErrorCode = code == ErrorInfo.MOK
+ ? ErrorInfo.MERR_FSDK_FACEFEATURE_NO_FACE
+ : code;
+ return qualityResult;
}
}
+ /**
+ * 仅检测BGR24在库照片的质量,不提取或写入人脸特征。
+ *
+ * @param bgr24 BGR24图片数据
+ * @param width 图片宽度
+ * @param height 图片高度
+ * @return detected或failed质量结果
+ */
+ public synchronized QualityFeatureResult detectBgr24Quality(byte[] bgr24, int width, int height) {
+ QualityFeatureResult result = new QualityFeatureResult();
+ if (faceEngine == null || bgr24 == null || width % 4 != 0 || bgr24.length != width * height * 3) {
+ result.qualityStatus = "failed";
+ result.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM;
+ return result;
+ }
+ List faceInfoList = new ArrayList<>();
+ int detectCode = faceEngine.detectFaces(bgr24, width, height, FaceEngine.CP_PAF_BGR24, faceInfoList);
+ if (detectCode != ErrorInfo.MOK || faceInfoList.isEmpty()) {
+ result.qualityStatus = "failed";
+ result.qualityErrorCode = detectCode == ErrorInfo.MOK ? ErrorInfo.MERR_FSDK_FACEFEATURE_NO_FACE : detectCode;
+ return result;
+ }
+ if (!imageQualityReady) {
+ result.qualityStatus = "failed";
+ result.qualityErrorCode = imageQualityInitCode;
+ return result;
+ }
+ ImageQualitySimilar qualitySimilar = new ImageQualitySimilar();
+ int qualityCode = faceEngine.imageQualityDetect(
+ bgr24, width, height, FaceEngine.CP_PAF_BGR24,
+ faceInfoList.get(0), MaskInfo.NOT_WORN, qualitySimilar);
+ if (qualityCode == ErrorInfo.MOK) {
+ result.qualityStatus = "detected";
+ result.qualityScore = (double) qualitySimilar.getScore();
+ } else {
+ result.qualityStatus = "failed";
+ result.qualityErrorCode = qualityCode;
+ }
+ return result;
+ }
+
+ /** 原生图片特征与质量诊断的组合结果。 */
+ public static class QualityFeatureResult {
+ /** 注册特征字节,提取失败时为空。 */
+ public byte[] featureData;
+ /** 质量状态,稳定值为detected或failed。 */
+ public String qualityStatus = "pending";
+ /** 检测成功时的质量分。 */
+ public Double qualityScore;
+ /** 检测失败时的错误码。 */
+ public Integer qualityErrorCode;
+ }
+
private String getImagePath(String name, Context context) {
if (imageRootPath == null) {
imageRootPath = getImageDir(context);
diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java b/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java
index 879bcdc..6817f69 100644
--- a/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java
+++ b/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java
@@ -18,6 +18,7 @@ import com.arcsoft.face.FaceInfo;
import com.arcsoft.face.GenderInfo;
import com.arcsoft.face.LivenessInfo;
import com.arcsoft.face.LivenessParam;
+import com.arcsoft.face.ErrorInfo;
import com.arcsoft.face.enums.DetectFaceOrientPriority;
import com.arcsoft.face.enums.DetectMode;
import com.google.gson.Gson;
@@ -35,6 +36,8 @@ 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.model.RecognitionFrameSnapshot;
import io.flutter.plugins.facediscern.tool.face.widget.FaceRectView;
import io.flutter.plugins.facediscern.ui.FaceDiscern;
@@ -47,6 +50,12 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback
private final String TAG = "FaceDiscernViewModel";
+ /**
+ * 仅识别最大脸时的预览检测上限。
+ * 原生配置允许的上限为 3,足以让收银员看清排队场景中的目标选择,同时只让一张脸进入识别线程。
+ */
+ private static final int KEEP_MAX_FACE_PREVIEW_LIMIT = 3;
+
// 相机预览的分辨率
private Camera.Size previewSize;
@@ -60,10 +69,12 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback
private FaceEngine maskEngine;
// IMAGE模式活体检测引擎,用于预览帧人脸活体检测
private FaceEngine flEngine;
- // 各个引擎初始化的错误码
- private MutableLiveData ftInitCode = new MutableLiveData<>();
- private MutableLiveData frInitCode = new MutableLiveData<>();
- private MutableLiveData flInitCode = new MutableLiveData<>();
+ // 各个引擎初始化的错误码。虹软 init 是同步调用,不能通过 LiveData.postValue
+ // 异步保存后立刻读取,否则主线程消息尚未分发时会把成功初始化误判为失败。
+ private int ftInitCode = Integer.MIN_VALUE;
+ private int maskInitCode = Integer.MIN_VALUE;
+ private int frInitCode = Integer.MIN_VALUE;
+ private int flInitCode = Integer.MIN_VALUE;
/// ****************** end 引擎 end ******************
@@ -101,8 +112,31 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback
private HashMap