feat:人脸识别人脸占比设置
This commit is contained in:
1 parent
382b8d660f
commit
e75b3bd58e
10 files changed
+475
-110
No files matched your search
+19
@@ -0,0 +1,19 @@
|
||||
package io.flutter.plugins.facediscern.tool.face.filter;
|
||||
|
||||
/** 按原始相机画面计算人脸面积占比,不受预览旋转、缩放和裁剪影响。 */
|
||||
public final class FaceAreaRatio {
|
||||
private FaceAreaRatio() {
|
||||
}
|
||||
|
||||
public static boolean meetsMinimum(int left, int top, int right, int bottom,
|
||||
int frameWidth, int frameHeight, double minPercent) {
|
||||
if (frameWidth <= 0 || frameHeight <= 0 || Double.isNaN(minPercent)
|
||||
|| Double.isInfinite(minPercent) || minPercent < 1.0 || minPercent > 100.0) {
|
||||
return false;
|
||||
}
|
||||
// 只计算实际落在相机画面内的面积,越界部分不应放大占比。
|
||||
long width = Math.max(0L, (long) Math.min(frameWidth, right) - Math.max(0, left));
|
||||
long height = Math.max(0L, (long) Math.min(frameHeight, bottom) - Math.max(0, top));
|
||||
return width * height * 100.0 >= (double) frameWidth * frameHeight * minPercent;
|
||||
}
|
||||
}
|
||||
+145
-92
@@ -22,6 +22,7 @@ import io.flutter.plugins.facediscern.tool.face.constants.LivenessType;
|
||||
import io.flutter.plugins.facediscern.tool.face.constants.RequestFeatureStatus;
|
||||
import io.flutter.plugins.facediscern.tool.face.constants.RequestLivenessStatus;
|
||||
import io.flutter.plugins.facediscern.tool.face.filter.FaceMoveFilter;
|
||||
import io.flutter.plugins.facediscern.tool.face.filter.FaceAreaRatio;
|
||||
import io.flutter.plugins.facediscern.tool.face.filter.FaceRecognizeAreaFilter;
|
||||
import io.flutter.plugins.facediscern.tool.face.filter.FaceSizeFilter;
|
||||
import io.flutter.plugins.facediscern.tool.face.filter.callback.FaceRecognizeFilter;
|
||||
@@ -41,9 +42,12 @@ import java.text.DecimalFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Enumeration;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
@@ -172,6 +176,8 @@ public class FaceHelper implements FaceListener {
|
||||
* 该值随每一帧的面积排序更新,用于拦截上一目标已排队但尚未完成的结果。
|
||||
*/
|
||||
private volatile Integer currentRecognitionTargetTrackId;
|
||||
/** 最近一帧达到相机面积占比的人脸;异步结果也必须仍在此集合内。 */
|
||||
private volatile Set<Integer> areaEligibleTrackIds = Collections.emptySet();
|
||||
|
||||
private Disposable timerDisposable;
|
||||
|
||||
@@ -242,6 +248,7 @@ public class FaceHelper implements FaceListener {
|
||||
* @param format 图像格式
|
||||
*/
|
||||
public void requestFaceFeature(byte[] nv21, FacePreviewInfo facePreviewInfo, int width, int height, int format) {
|
||||
if (!isCurrentRecognitionTarget(facePreviewInfo.getTrackId(), sessionGeneration)) return;
|
||||
if (frEngine != null && frThreadQueue.remainingCapacity() > 0) {
|
||||
frExecutor.execute(new FaceRecognizeRunnable(nv21, facePreviewInfo, width, height, format));
|
||||
} else {
|
||||
@@ -261,6 +268,7 @@ public class FaceHelper implements FaceListener {
|
||||
* @param waitLock
|
||||
*/
|
||||
public void requestFaceLiveness(byte[] nv21, FacePreviewInfo faceInfo, int width, int height, int format, LivenessType livenessType, Object waitLock) {
|
||||
if (!isCurrentRecognitionTarget(faceInfo.getTrackId(), sessionGeneration)) return;
|
||||
if (flEngine != null && flThreadQueue.remainingCapacity() > 0) {
|
||||
flExecutor.execute(new FaceLivenessDetectRunnable(nv21, faceInfo, width, height, format, livenessType, waitLock));
|
||||
} else {
|
||||
@@ -306,6 +314,7 @@ public class FaceHelper implements FaceListener {
|
||||
|
||||
private void clearTrackingState() {
|
||||
currentRecognitionTargetTrackId = null;
|
||||
areaEligibleTrackIds = Collections.emptySet();
|
||||
if (faceInfoList != null) {
|
||||
faceInfoList.clear();
|
||||
}
|
||||
@@ -313,7 +322,9 @@ public class FaceHelper implements FaceListener {
|
||||
maskInfoList.clear();
|
||||
}
|
||||
if (recognizeInfoMap != null) {
|
||||
recognizeInfoMap.clear();
|
||||
for (Integer trackId : recognizeInfoMap.keySet()) {
|
||||
discardRecognitionState(trackId);
|
||||
}
|
||||
}
|
||||
if (recognitionFrameMap != null) {
|
||||
recognitionFrameMap.clear();
|
||||
@@ -351,10 +362,12 @@ public class FaceHelper implements FaceListener {
|
||||
facePreviewInfoList.clear();
|
||||
int code = ftEngine.detectFaces(rgbNv21, previewSize.width, previewSize.height, FaceEngine.CP_PAF_NV21, faceInfoList);
|
||||
if (code != ErrorInfo.MOK) {
|
||||
updateAreaEligibleFaces(Collections.emptyList());
|
||||
onFail(new Exception("detectFaces failed,code is " + code));
|
||||
return facePreviewInfoList;
|
||||
}
|
||||
refreshTrackId(faceInfoList);
|
||||
updateAreaEligibleFaces(faceInfoList);
|
||||
if (faceInfoList.isEmpty()) {
|
||||
updateRecognitionTarget(null);
|
||||
clearLeftFace(facePreviewInfoList);
|
||||
@@ -379,6 +392,7 @@ public class FaceHelper implements FaceListener {
|
||||
|
||||
for (int i = 0; i < faceInfoList.size(); i++) {
|
||||
FacePreviewInfo facePreviewInfo = new FacePreviewInfo(faceInfoList.get(i), currentTrackIdList.get(i));
|
||||
facePreviewInfo.setQualityPass(areaEligibleTrackIds.contains(facePreviewInfo.getTrackId()));
|
||||
if (!maskInfoList.isEmpty()) {
|
||||
MaskInfo maskInfo = maskInfoList.get(i);
|
||||
facePreviewInfo.setMask(maskInfo.getMask());
|
||||
@@ -404,6 +418,7 @@ public class FaceHelper implements FaceListener {
|
||||
doRecognize(rgbNv21, irNv21, facePreviewInfoList);
|
||||
}
|
||||
} else {
|
||||
updateAreaEligibleFaces(Collections.emptyList());
|
||||
facePreviewInfoList.clear();
|
||||
}
|
||||
return facePreviewInfoList;
|
||||
@@ -417,6 +432,37 @@ public class FaceHelper implements FaceListener {
|
||||
this.irFaceRectTransformer = irFaceRectTransformer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在口罩、活体和特征提取前刷新原始 RGB 人脸面积门槛。
|
||||
* 移除不达标目标的状态对象使旧任务失效,同一 trackId 再次靠近时会创建全新的识别状态。
|
||||
*/
|
||||
private synchronized void updateAreaEligibleFaces(List<FaceInfo> faces) {
|
||||
Set<Integer> eligible = new HashSet<>();
|
||||
for (int i = 0; i < faces.size(); i++) {
|
||||
Rect rect = faces.get(i).getRect();
|
||||
if (rect != null && FaceAreaRatio.meetsMinimum(rect.left, rect.top, rect.right, rect.bottom,
|
||||
previewSize.width, previewSize.height, recognizeConfiguration.getMinFaceAreaPercent())) {
|
||||
eligible.add(currentTrackIdList.get(i));
|
||||
}
|
||||
}
|
||||
areaEligibleTrackIds = Collections.unmodifiableSet(eligible);
|
||||
for (Integer trackId : recognizeInfoMap.keySet()) {
|
||||
if (!eligible.contains(trackId)) {
|
||||
discardRecognitionState(trackId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void discardRecognitionState(int trackId) {
|
||||
RecognizeInfo previous = recognizeInfoMap.remove(trackId);
|
||||
recognitionFrameMap.remove(trackId);
|
||||
if (previous != null) {
|
||||
synchronized (previous.getWaitLock()) {
|
||||
previous.getWaitLock().notifyAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除已经离开的人脸
|
||||
*
|
||||
@@ -549,21 +595,14 @@ public class FaceHelper implements FaceListener {
|
||||
*
|
||||
* @param trackId 当前最大脸跟踪编号;无目标或多人识别模式时为空
|
||||
*/
|
||||
private void updateRecognitionTarget(@Nullable Integer trackId) {
|
||||
private synchronized 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();
|
||||
}
|
||||
}
|
||||
discardRecognitionState(previousTrackId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,10 +615,17 @@ public class FaceHelper implements FaceListener {
|
||||
*/
|
||||
private boolean isCurrentRecognitionTarget(@Nullable Integer trackId, long generation) {
|
||||
return generation == sessionGeneration
|
||||
&& trackId != null && areaEligibleTrackIds.contains(trackId)
|
||||
&& (!recognizeConfiguration.isKeepMaxFace()
|
||||
|| Objects.equals(currentRecognitionTargetTrackId, trackId));
|
||||
}
|
||||
|
||||
/** 状态对象同时作为目标本次达标周期的标记,避免离开门槛后旧任务借同一 trackId 回流。 */
|
||||
private boolean isCurrentRecognitionTask(Integer trackId, long generation, RecognizeInfo expectedInfo) {
|
||||
return expectedInfo != null && isCurrentRecognitionTarget(trackId, generation)
|
||||
&& recognizeInfoMap.get(trackId) == expectedInfo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFail(Exception e) {
|
||||
Log.e(TAG, "onFail:" + e.getMessage());
|
||||
@@ -603,48 +649,38 @@ 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);
|
||||
onFaceFeatureInfoGet(faceFeature, trackId, errorCode, sessionGeneration, recognizeInfoMap.get(trackId));
|
||||
}
|
||||
|
||||
private void onFaceFeatureInfoGet(@Nullable FaceFeature faceFeature, Integer trackId, Integer errorCode,
|
||||
long generation, RecognizeInfo recognizeInfo) {
|
||||
if (!isCurrentRecognitionTask(trackId, generation, recognizeInfo)) return;
|
||||
if (faceFeature != null) {
|
||||
// 人脸已离开,不用处理
|
||||
if (recognizeInfo == null) {
|
||||
return;
|
||||
}
|
||||
//不做活体检测的情况,直接搜索
|
||||
if (!recognizeConfiguration.isEnableLiveness()) {
|
||||
searchFace(faceFeature, trackId);
|
||||
}
|
||||
//活体检测通过,搜索特征
|
||||
else if (recognizeInfo.getLiveness() == LivenessInfo.ALIVE) {
|
||||
searchFace(faceFeature, trackId);
|
||||
}
|
||||
//活体检测未出结果,或者非活体,等待
|
||||
else {
|
||||
if (recognizeConfiguration.isEnableLiveness()) {
|
||||
synchronized (recognizeInfo.getWaitLock()) {
|
||||
try {
|
||||
recognizeInfo.getWaitLock().wait();
|
||||
if (recognizeInfoMap.containsKey(trackId)) {
|
||||
onFaceFeatureInfoGet(faceFeature, trackId, errorCode);
|
||||
while (isCurrentRecognitionTask(trackId, generation, recognizeInfo)
|
||||
&& recognizeInfo.getLiveness() != LivenessInfo.ALIVE) {
|
||||
recognizeInfo.getWaitLock().wait();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Log.e(TAG, "onFaceFeatureInfoGet: 等待活体结果时退出界面会执行,正常现象,可注释异常代码块");
|
||||
e.printStackTrace();
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
searchFace(faceFeature, trackId, generation, recognizeInfo);
|
||||
}
|
||||
//特征提取失败时,为了及时提示做个UI反馈,将name修改为"ExtractCode:${errorCode}",再重置状态
|
||||
else {
|
||||
if (recognizeInfo.increaseAndGetExtractErrorRetryCount() > recognizeConfiguration.getExtractRetryCount()) {
|
||||
// 在尝试最大次数后,特征提取仍然失败,则认为识别未通过
|
||||
recognizeInfo.setExtractErrorRetryCount(0);
|
||||
retryRecognizeDelayed(trackId);
|
||||
} else {
|
||||
changeRecognizeStatus(trackId, RequestFeatureStatus.TO_RETRY);
|
||||
synchronized (this) {
|
||||
if (!isCurrentRecognitionTask(trackId, generation, recognizeInfo)) return;
|
||||
if (recognizeInfo.increaseAndGetExtractErrorRetryCount() > recognizeConfiguration.getExtractRetryCount()) {
|
||||
recognizeInfo.setExtractErrorRetryCount(0);
|
||||
retryRecognizeDelayed(trackId);
|
||||
} else {
|
||||
recognizeInfo.setRecognizeStatus(RequestFeatureStatus.TO_RETRY);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -655,6 +691,8 @@ public class FaceHelper implements FaceListener {
|
||||
* @param trackId 人脸ID
|
||||
*/
|
||||
private void retryLivenessDetectDelayed(final Integer trackId) {
|
||||
final long generation = sessionGeneration;
|
||||
final RecognizeInfo expectedInfo = recognizeInfoMap.get(trackId);
|
||||
Observable.timer(recognizeConfiguration.getLivenessFailedRetryInterval(), TimeUnit.MILLISECONDS)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(new Observer<Long>() {
|
||||
@@ -678,8 +716,11 @@ public class FaceHelper implements FaceListener {
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
// 将该人脸状态置为UNKNOWN,帧回调处理时会重新进行活体检测
|
||||
changeLiveness(trackId, LivenessInfo.UNKNOWN);
|
||||
synchronized (FaceHelper.this) {
|
||||
if (isCurrentRecognitionTask(trackId, generation, expectedInfo)) {
|
||||
expectedInfo.setLiveness(LivenessInfo.UNKNOWN);
|
||||
}
|
||||
}
|
||||
delayFaceTaskCompositeDisposable.remove(disposable);
|
||||
}
|
||||
});
|
||||
@@ -691,6 +732,8 @@ public class FaceHelper implements FaceListener {
|
||||
* @param trackId 人脸ID
|
||||
*/
|
||||
private void retryRecognizeDelayed(final Integer trackId) {
|
||||
final long generation = sessionGeneration;
|
||||
final RecognizeInfo expectedInfo = recognizeInfoMap.get(trackId);
|
||||
changeRecognizeStatus(trackId, RequestFeatureStatus.FAILED);
|
||||
Observable.timer(recognizeConfiguration.getRecognizeFailedRetryInterval(), TimeUnit.MILLISECONDS)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
@@ -715,8 +758,11 @@ public class FaceHelper implements FaceListener {
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
// 将该人脸特征提取状态置为FAILED,帧回调处理时会重新进行活体检测
|
||||
changeRecognizeStatus(trackId, RequestFeatureStatus.TO_RETRY);
|
||||
synchronized (FaceHelper.this) {
|
||||
if (isCurrentRecognitionTask(trackId, generation, expectedInfo)) {
|
||||
expectedInfo.setRecognizeStatus(RequestFeatureStatus.TO_RETRY);
|
||||
}
|
||||
}
|
||||
delayFaceTaskCompositeDisposable.remove(disposable);
|
||||
}
|
||||
});
|
||||
@@ -724,9 +770,12 @@ public class FaceHelper implements FaceListener {
|
||||
|
||||
@Override
|
||||
public void onFaceLivenessInfoGet(@Nullable LivenessInfo livenessInfo, Integer trackId, Integer errorCode) {
|
||||
if (!isCurrentRecognitionTarget(trackId, sessionGeneration)) {
|
||||
return;
|
||||
}
|
||||
onFaceLivenessInfoGet(livenessInfo, trackId, errorCode, sessionGeneration, recognizeInfoMap.get(trackId));
|
||||
}
|
||||
|
||||
private synchronized void onFaceLivenessInfoGet(@Nullable LivenessInfo livenessInfo, Integer trackId,
|
||||
Integer errorCode, long generation, RecognizeInfo expectedInfo) {
|
||||
if (!isCurrentRecognitionTask(trackId, generation, expectedInfo)) return;
|
||||
if (livenessInfo != null) {
|
||||
int liveness = livenessInfo.getLiveness();
|
||||
Log.i(TAG, "onFaceLivenessInfoGet liveness:" + liveness);
|
||||
@@ -765,38 +814,40 @@ public class FaceHelper implements FaceListener {
|
||||
});
|
||||
}
|
||||
|
||||
private void searchFace(final FaceFeature faceFeature, final Integer trackId) {
|
||||
if (!isCurrentRecognitionTarget(trackId, sessionGeneration)) {
|
||||
return;
|
||||
}
|
||||
private void searchFace(final FaceFeature faceFeature, final Integer trackId,
|
||||
long generation, RecognizeInfo expectedInfo) {
|
||||
if (!isCurrentRecognitionTask(trackId, generation, expectedInfo)) 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,
|
||||
recognitionFrameMap.get(trackId));
|
||||
if (pass) {
|
||||
String msg = "识别通过";
|
||||
Log.i(TAG, "用户名字:" + compareResult.getFaceEntity().getUserName() + ";人脸id:" + compareResult.getFaceEntity().getFaceId() + ";");
|
||||
if (Objects.nonNull(compareResult.getFaceEntity())) {
|
||||
FaceEntity face = compareResult.getFaceEntity();
|
||||
if (Objects.nonNull(face.getUserName()) && Objects.nonNull(face.getFaceId())) {
|
||||
DecimalFormat df = new DecimalFormat("0.00");
|
||||
String formattedNumber = df.format(compareResult.getSimilar() * 100);
|
||||
msg = face.getUserName() + "相似度" + formattedNumber + "%";
|
||||
}
|
||||
synchronized (this) {
|
||||
if (!isCurrentRecognitionTask(trackId, generation, expectedInfo)) return;
|
||||
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,
|
||||
recognitionFrameMap.get(trackId));
|
||||
if (pass) {
|
||||
String msg = "识别通过";
|
||||
Log.i(TAG, "用户名字:" + compareResult.getFaceEntity().getUserName() + ";人脸id:" + compareResult.getFaceEntity().getFaceId() + ";");
|
||||
if (Objects.nonNull(compareResult.getFaceEntity())) {
|
||||
FaceEntity face = compareResult.getFaceEntity();
|
||||
if (Objects.nonNull(face.getUserName()) && Objects.nonNull(face.getFaceId())) {
|
||||
DecimalFormat df = new DecimalFormat("0.00");
|
||||
String formattedNumber = df.format(compareResult.getSimilar() * 100);
|
||||
msg = face.getUserName() + "相似度" + formattedNumber + "%";
|
||||
}
|
||||
}
|
||||
setName(trackId, msg);
|
||||
noticeCurrentStatus(msg);
|
||||
changeRecognizeStatus(trackId, RequestFeatureStatus.SUCCEED);
|
||||
} else {
|
||||
noticeCurrentStatus("未通过:NOT_REGISTERED");
|
||||
retryRecognizeDelayed(trackId);
|
||||
}
|
||||
setName(trackId, msg);
|
||||
noticeCurrentStatus(msg);
|
||||
changeRecognizeStatus(trackId, RequestFeatureStatus.SUCCEED);
|
||||
} else {
|
||||
noticeCurrentStatus("未通过:NOT_REGISTERED");
|
||||
retryRecognizeDelayed(trackId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -812,6 +863,7 @@ public class FaceHelper implements FaceListener {
|
||||
private byte[] nv21Data;
|
||||
private int isMask;
|
||||
private final long generation;
|
||||
private final RecognizeInfo expectedInfo;
|
||||
|
||||
/**
|
||||
* 异步特征提取任务的构造函数
|
||||
@@ -824,6 +876,7 @@ public class FaceHelper implements FaceListener {
|
||||
*/
|
||||
private FaceRecognizeRunnable(byte[] nv21Data, FacePreviewInfo facePreviewInfo, int width, int height, int format) {
|
||||
this.generation = sessionGeneration;
|
||||
this.expectedInfo = recognizeInfoMap.get(facePreviewInfo.getTrackId());
|
||||
if (nv21Data == null) {
|
||||
return;
|
||||
}
|
||||
@@ -842,7 +895,7 @@ public class FaceHelper implements FaceListener {
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
if (!isCurrentRecognitionTarget(trackId, generation)) return;
|
||||
if (!isCurrentRecognitionTask(trackId, generation, expectedInfo)) return;
|
||||
if (nv21Data != null) {
|
||||
if (frEngine != null) {
|
||||
if (recognizeConfiguration.isEnableImageQuality()) {
|
||||
@@ -853,6 +906,7 @@ public class FaceHelper implements FaceListener {
|
||||
int iqCode;
|
||||
long iqStartTime = System.currentTimeMillis();
|
||||
synchronized (frEngine) {
|
||||
if (!isCurrentRecognitionTask(trackId, generation, expectedInfo)) return;
|
||||
iqCode = frEngine.imageQualityDetect(nv21Data, width, height, format, faceInfo, isMask, qualitySimilar);
|
||||
}
|
||||
Log.i(TAG, "fr iqTime:" + (System.currentTimeMillis() - iqStartTime) + "ms" + ", mask=" + isMask);
|
||||
@@ -886,6 +940,7 @@ public class FaceHelper implements FaceListener {
|
||||
FaceFeature faceFeature = new FaceFeature();
|
||||
int frCode;
|
||||
synchronized (frEngine) {
|
||||
if (!isCurrentRecognitionTask(trackId, generation, expectedInfo)) return;
|
||||
/*
|
||||
* 该场景为识别场景,所以参数“ExtractType”值为ExtractType.RECOGNIZE,且参数“mask”值为实际检测到的值,即isMask
|
||||
*/
|
||||
@@ -893,20 +948,15 @@ public class FaceHelper implements FaceListener {
|
||||
}
|
||||
Log.i(TAG, "frTime:" + (System.currentTimeMillis() - irStartTime) + "ms");
|
||||
if (frCode == ErrorInfo.MOK) {
|
||||
synchronized (FaceHelper.this) {
|
||||
if (!isCurrentRecognitionTarget(trackId, generation)) return;
|
||||
onFaceFeatureInfoGet(faceFeature, trackId, frCode);
|
||||
}
|
||||
onFaceFeatureInfoGet(faceFeature, trackId, frCode, generation, expectedInfo);
|
||||
} else {
|
||||
onFaceFail(frCode, "fr failed errorCode is " + frCode);
|
||||
}
|
||||
}
|
||||
|
||||
private void onFaceFail(int code, String errorMsg) {
|
||||
synchronized (FaceHelper.this) {
|
||||
if (!isCurrentRecognitionTarget(trackId, generation)) return;
|
||||
onFaceFeatureInfoGet(null, trackId, code);
|
||||
}
|
||||
if (!isCurrentRecognitionTask(trackId, generation, expectedInfo)) return;
|
||||
onFaceFeatureInfoGet(null, trackId, code, generation, expectedInfo);
|
||||
onFail(new Exception(errorMsg));
|
||||
}
|
||||
}
|
||||
@@ -926,6 +976,7 @@ public class FaceHelper implements FaceListener {
|
||||
|
||||
/** 创建活体任务时的会话代次,用于丢弃上一位顾客或上一目标脸的迟到结果。 */
|
||||
private final long generation;
|
||||
private final RecognizeInfo expectedInfo;
|
||||
|
||||
/**
|
||||
* 异步活体任务的构造函数
|
||||
@@ -940,6 +991,7 @@ public class FaceHelper implements FaceListener {
|
||||
*/
|
||||
private FaceLivenessDetectRunnable(byte[] nv21Data, FacePreviewInfo faceInfo, int width, int height, int format, LivenessType livenessType, Object waitLock) {
|
||||
this.generation = sessionGeneration;
|
||||
this.expectedInfo = recognizeInfoMap.get(faceInfo.getTrackId());
|
||||
if (nv21Data == null) {
|
||||
return;
|
||||
}
|
||||
@@ -955,7 +1007,7 @@ public class FaceHelper implements FaceListener {
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
if (!isCurrentRecognitionTarget(trackId, generation)) return;
|
||||
if (!isCurrentRecognitionTask(trackId, generation, expectedInfo)) return;
|
||||
if (nv21Data != null) {
|
||||
if (flEngine != null) {
|
||||
processLiveness();
|
||||
@@ -973,6 +1025,7 @@ public class FaceHelper implements FaceListener {
|
||||
List<LivenessInfo> livenessInfoList = new ArrayList<>();
|
||||
int flCode = -1;
|
||||
synchronized (flEngine) {
|
||||
if (!isCurrentRecognitionTask(trackId, generation, expectedInfo)) return;
|
||||
long flStartTime = System.currentTimeMillis();
|
||||
if (livenessType == LivenessType.RGB) {
|
||||
// RGB活体检测
|
||||
@@ -1005,8 +1058,8 @@ public class FaceHelper implements FaceListener {
|
||||
}
|
||||
|
||||
if (flCode == ErrorInfo.MOK && !livenessInfoList.isEmpty()) {
|
||||
if (!isCurrentRecognitionTarget(trackId, generation)) return;
|
||||
onFaceLivenessInfoGet(livenessInfoList.get(0), trackId, flCode);
|
||||
if (!isCurrentRecognitionTask(trackId, generation, expectedInfo)) return;
|
||||
onFaceLivenessInfoGet(livenessInfoList.get(0), trackId, flCode, generation, expectedInfo);
|
||||
if (livenessInfoList.get(0).getLiveness() == LivenessInfo.ALIVE) {
|
||||
synchronized (waitLock) {
|
||||
waitLock.notifyAll();
|
||||
@@ -1018,8 +1071,8 @@ public class FaceHelper implements FaceListener {
|
||||
}
|
||||
|
||||
private void onProcessFail(int code, String msg) {
|
||||
if (!isCurrentRecognitionTarget(trackId, generation)) return;
|
||||
onFaceLivenessInfoGet(null, trackId, code);
|
||||
if (!isCurrentRecognitionTask(trackId, generation, expectedInfo)) return;
|
||||
onFaceLivenessInfoGet(null, trackId, code, generation, expectedInfo);
|
||||
onFail(new Exception(msg));
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -20,6 +20,8 @@ public class RecognizeConfiguration {
|
||||
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;
|
||||
/** 原始 RGB 相机画面中允许开始识别的最小人脸面积百分比。 */
|
||||
public static final double DEFAULT_MIN_FACE_AREA_PERCENT = 5.0;
|
||||
|
||||
/**
|
||||
* 产生特征提取失败示语的特征提取次数(小于该值不提示)
|
||||
@@ -73,6 +75,7 @@ public class RecognizeConfiguration {
|
||||
* 仅识别最大人脸
|
||||
*/
|
||||
private boolean keepMaxFace;
|
||||
private double minFaceAreaPercent;
|
||||
/**
|
||||
* 活体阈值设置
|
||||
*/
|
||||
@@ -110,6 +113,7 @@ public class RecognizeConfiguration {
|
||||
this.enableImageQuality = builder.enableImageQuality;
|
||||
this.enableFaceAreaLimit = builder.enableFaceAreaLimit;
|
||||
this.keepMaxFace = builder.keepMaxFace;
|
||||
this.minFaceAreaPercent = builder.minFaceAreaPercent;
|
||||
this.recognizeFailedRetryInterval = builder.recognizeFailedRetryInterval;
|
||||
this.livenessParam = builder.livenessParam;
|
||||
this.enableFaceSizeLimit = builder.enableFaceSizeLimit;
|
||||
@@ -138,6 +142,7 @@ public class RecognizeConfiguration {
|
||||
private int faceSizeLimit = 0;
|
||||
private int faceMoveLimit = 0;
|
||||
private boolean keepMaxFace = DEFAULT_KEEP_MAX_FACE;
|
||||
private double minFaceAreaPercent = DEFAULT_MIN_FACE_AREA_PERCENT;
|
||||
private LivenessParam livenessParam = new LivenessParam(DEFAULT_RGB_LIVENESS_THRESHOLD, DEFAULT_RGB_LIVENESS_THRESHOLD);
|
||||
|
||||
public Builder recognizeFailedRetryInterval(int val) {
|
||||
@@ -231,6 +236,14 @@ public class RecognizeConfiguration {
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder minFaceAreaPercent(double val) {
|
||||
if (Double.isNaN(val) || Double.isInfinite(val) || val < 1.0 || val > 100.0) {
|
||||
throw new IllegalArgumentException("minFaceAreaPercent 必须在 1 到 100 之间");
|
||||
}
|
||||
this.minFaceAreaPercent = val;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder livenessParam(LivenessParam val) {
|
||||
this.livenessParam = val;
|
||||
return this;
|
||||
@@ -287,6 +300,7 @@ public class RecognizeConfiguration {
|
||||
.enableImageQuality(enabled)
|
||||
.enableFaceAreaLimit(enableFaceAreaLimit)
|
||||
.keepMaxFace(keepMaxFace)
|
||||
.minFaceAreaPercent(minFaceAreaPercent)
|
||||
.livenessParam(livenessParam)
|
||||
.enableFaceSizeLimit(enableFaceSizeLimit)
|
||||
.enableFaceMoveLimit(enableFaceMoveLimit)
|
||||
@@ -336,6 +350,10 @@ public class RecognizeConfiguration {
|
||||
return keepMaxFace;
|
||||
}
|
||||
|
||||
public double getMinFaceAreaPercent() {
|
||||
return minFaceAreaPercent;
|
||||
}
|
||||
|
||||
public boolean isEnableFaceSizeLimit() {
|
||||
return enableFaceSizeLimit;
|
||||
}
|
||||
@@ -360,6 +378,7 @@ public class RecognizeConfiguration {
|
||||
"recognizeFailedRetryInterval: " + recognizeFailedRetryInterval + "\r\n" +
|
||||
|
||||
"keepMaxFace: " + keepMaxFace + "\r\n" +
|
||||
"minFaceAreaPercent: " + minFaceAreaPercent + "\r\n" +
|
||||
"maxDetectFaces: " + maxDetectFaces + "\r\n" +
|
||||
|
||||
"enableImageQuality: " + enableImageQuality + "\r\n" +
|
||||
|
||||
+43
-6
@@ -18,7 +18,10 @@ public final class RecognizeConfigurationStore {
|
||||
}
|
||||
|
||||
public static RecognizeConfiguration load(Context context) {
|
||||
SharedPreferences preferences = preferences(context);
|
||||
return load(preferences(context));
|
||||
}
|
||||
|
||||
static RecognizeConfiguration load(SharedPreferences preferences) {
|
||||
return new RecognizeConfiguration.Builder()
|
||||
.enableLiveness(preferences.getBoolean("enableLiveness", RecognizeConfiguration.DEFAULT_ENABLE_LIVENESS))
|
||||
.similarThreshold(preferences.getFloat("similarThreshold", RecognizeConfiguration.DEFAULT_SIMILAR_THRESHOLD))
|
||||
@@ -30,6 +33,7 @@ public final class RecognizeConfigurationStore {
|
||||
.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))
|
||||
.minFaceAreaPercent(storedMinFaceAreaPercent(preferences))
|
||||
.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))
|
||||
@@ -42,7 +46,11 @@ public final class RecognizeConfigurationStore {
|
||||
* Flutter 只传允许现场调整的白名单字段,原生再次校验范围,防止异常参数拖垮识别线程。
|
||||
*/
|
||||
public static RecognizeConfiguration save(Context context, Map<?, ?> values) {
|
||||
RecognizeConfiguration current = load(context);
|
||||
return save(preferences(context), values);
|
||||
}
|
||||
|
||||
static RecognizeConfiguration save(SharedPreferences preferences, Map<?, ?> values) {
|
||||
RecognizeConfiguration current = load(preferences);
|
||||
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);
|
||||
@@ -51,13 +59,14 @@ public final class RecognizeConfigurationStore {
|
||||
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());
|
||||
double minFaceAreaPercent = doubleValue(values, "minFaceAreaPercent", current.getMinFaceAreaPercent(), 1.0, 100.0);
|
||||
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()
|
||||
boolean saved = preferences.edit()
|
||||
.putBoolean("enableLiveness", enableLiveness)
|
||||
.putFloat("similarThreshold", similarThreshold)
|
||||
.putFloat("rgbLivenessThreshold", rgbLivenessThreshold)
|
||||
@@ -66,6 +75,7 @@ public final class RecognizeConfigurationStore {
|
||||
.putFloat("imageQualityMaskRecognizeThreshold", maskThreshold)
|
||||
.putFloat("libraryFaceQualityThreshold", libraryFaceQualityThreshold)
|
||||
.putBoolean("keepMaxFace", keepMaxFace)
|
||||
.putFloat("minFaceAreaPercent", (float) minFaceAreaPercent)
|
||||
.putInt("maxDetectFaces", maxDetectFaces)
|
||||
.putInt("extractRetryCount", extractRetryCount)
|
||||
.putInt("livenessRetryCount", livenessRetryCount)
|
||||
@@ -73,14 +83,18 @@ public final class RecognizeConfigurationStore {
|
||||
.putInt("livenessFailedRetryInterval", livenessRetryInterval)
|
||||
.commit();
|
||||
if (!saved) throw new IllegalStateException("人脸识别配置保存失败");
|
||||
return load(context);
|
||||
return load(preferences);
|
||||
}
|
||||
|
||||
public static RecognizeConfiguration reset(Context context) {
|
||||
if (!preferences(context).edit().clear().commit()) {
|
||||
return reset(preferences(context));
|
||||
}
|
||||
|
||||
static RecognizeConfiguration reset(SharedPreferences preferences) {
|
||||
if (!preferences.edit().clear().commit()) {
|
||||
throw new IllegalStateException("人脸识别配置恢复失败");
|
||||
}
|
||||
return load(context);
|
||||
return load(preferences);
|
||||
}
|
||||
|
||||
public static Map<String, Object> toMap(RecognizeConfiguration configuration) {
|
||||
@@ -93,6 +107,7 @@ public final class RecognizeConfigurationStore {
|
||||
result.put("imageQualityMaskRecognizeThreshold", configuration.getImageQualityMaskRecognizeThreshold());
|
||||
result.put("libraryFaceQualityThreshold", configuration.getLibraryFaceQualityThreshold());
|
||||
result.put("keepMaxFace", configuration.isKeepMaxFace());
|
||||
result.put("minFaceAreaPercent", configuration.getMinFaceAreaPercent());
|
||||
result.put("maxDetectFaces", configuration.getMaxDetectFaces());
|
||||
result.put("extractRetryCount", configuration.getExtractRetryCount());
|
||||
result.put("livenessRetryCount", configuration.getLivenessRetryCount());
|
||||
@@ -118,6 +133,28 @@ public final class RecognizeConfigurationStore {
|
||||
return (Boolean) value;
|
||||
}
|
||||
|
||||
private static double storedMinFaceAreaPercent(SharedPreferences preferences) {
|
||||
Object value = preferences.getAll().get("minFaceAreaPercent");
|
||||
if (value instanceof Number) {
|
||||
double percent = ((Number) value).doubleValue();
|
||||
if (!Double.isNaN(percent) && !Double.isInfinite(percent) && percent >= 1.0 && percent <= 100.0) {
|
||||
return percent;
|
||||
}
|
||||
}
|
||||
return RecognizeConfiguration.DEFAULT_MIN_FACE_AREA_PERCENT;
|
||||
}
|
||||
|
||||
private static double doubleValue(Map<?, ?> values, String key, double fallback, double min, double max) {
|
||||
Object value = values == null ? null : values.get(key);
|
||||
if (value == null) return fallback;
|
||||
if (!(value instanceof Number)) throw new IllegalArgumentException(key + " 必须为数字");
|
||||
double result = ((Number) value).doubleValue();
|
||||
if (Double.isNaN(result) || Double.isInfinite(result) || result < min || result > max) {
|
||||
throw new IllegalArgumentException(key + " 必须在 " + min + " 到 " + max + " 之间");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package io.flutter.plugins.facediscern.tool.face.filter;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class FaceAreaRatioTest {
|
||||
@Test public void defaultFivePercentIncludesBoundaryButRejectsSmallerFaces() {
|
||||
assertFalse(FaceAreaRatio.meetsMinimum(0, 0, 199, 250, 1000, 1000, 5.0));
|
||||
assertTrue(FaceAreaRatio.meetsMinimum(0, 0, 200, 250, 1000, 1000, 5.0));
|
||||
assertTrue(FaceAreaRatio.meetsMinimum(0, 0, 201, 250, 1000, 1000, 5.0));
|
||||
}
|
||||
|
||||
@Test public void usesAreaOfFullRawFrameAcrossAspectRatiosAndRotations() {
|
||||
assertTrue(FaceAreaRatio.meetsMinimum(10, 20, 170, 116, 640, 480, 5.0));
|
||||
assertTrue(FaceAreaRatio.meetsMinimum(20, 10, 116, 170, 480, 640, 5.0));
|
||||
assertFalse(FaceAreaRatio.meetsMinimum(10, 20, 170, 116, 1280, 720, 5.0));
|
||||
// A narrow and a square face with equal area meet the same threshold.
|
||||
assertTrue(FaceAreaRatio.meetsMinimum(0, 0, 50, 1000, 1000, 1000, 5.0));
|
||||
assertFalse(FaceAreaRatio.meetsMinimum(0, 0, 500, 99, 1000, 1000, 5.0));
|
||||
}
|
||||
|
||||
@Test public void configuredThresholdAppliesAtBothAllowedLimits() {
|
||||
assertTrue(FaceAreaRatio.meetsMinimum(0, 0, 100, 100, 1000, 1000, 1.0));
|
||||
assertFalse(FaceAreaRatio.meetsMinimum(0, 0, 100, 99, 1000, 1000, 1.0));
|
||||
assertTrue(FaceAreaRatio.meetsMinimum(0, 0, 1000, 1000, 1000, 1000, 100.0));
|
||||
assertFalse(FaceAreaRatio.meetsMinimum(0, 0, 1000, 999, 1000, 1000, 100.0));
|
||||
}
|
||||
|
||||
@Test public void clipsOutsidePixelsAndRejectsMalformedInputs() {
|
||||
assertFalse(FaceAreaRatio.meetsMinimum(-100, 0, 100, 250, 1000, 1000, 5.0));
|
||||
assertTrue(FaceAreaRatio.meetsMinimum(-100, 0, 200, 250, 1000, 1000, 5.0));
|
||||
assertFalse(FaceAreaRatio.meetsMinimum(200, 0, 100, 250, 1000, 1000, 5.0));
|
||||
assertFalse(FaceAreaRatio.meetsMinimum(Integer.MAX_VALUE, 0, Integer.MIN_VALUE, 250, 1000, 1000, 5.0));
|
||||
assertFalse(FaceAreaRatio.meetsMinimum(Integer.MAX_VALUE, 0, -2, 1000, 1000, 1000, 5.0));
|
||||
assertFalse(FaceAreaRatio.meetsMinimum(0, 0, 200, 250, 0, 1000, 5.0));
|
||||
for (double invalid : new double[]{Double.NaN, Double.POSITIVE_INFINITY, 0, -1, 101}) {
|
||||
assertFalse(FaceAreaRatio.meetsMinimum(0, 0, 1000, 1000, 1000, 1000, invalid));
|
||||
}
|
||||
}
|
||||
|
||||
@Test public void largeDimensionsDoNotOverflowIntegerArea() {
|
||||
assertTrue(FaceAreaRatio.meetsMinimum(0, 0, 200_000, 250_000, 1_000_000, 1_000_000, 5.0));
|
||||
assertFalse(FaceAreaRatio.meetsMinimum(0, 0, 199_999, 250_000, 1_000_000, 1_000_000, 5.0));
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package io.flutter.plugins.facediscern.tool.face.model;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
public class RecognizeConfigurationStoreTest {
|
||||
@Test public void existingSettingsWithoutNewKeyUseFivePercent() {
|
||||
Map<String, Object> values = new HashMap<>();
|
||||
values.put("keepMaxFace", false);
|
||||
values.put("similarThreshold", 0.91F);
|
||||
RecognizeConfiguration configuration = RecognizeConfigurationStore.load(preferences(values));
|
||||
assertEquals(5.0, configuration.getMinFaceAreaPercent(), 0.0);
|
||||
assertFalse(configuration.isKeepMaxFace());
|
||||
assertEquals(0.91F, configuration.getSimilarThreshold(), 0.0F);
|
||||
assertEquals(5.0, (Double) RecognizeConfigurationStore.toMap(configuration).get("minFaceAreaPercent"), 0.0);
|
||||
}
|
||||
|
||||
@Test public void saveLoadPartialUpdateAndResetPreserveContract() {
|
||||
Map<String, Object> values = new HashMap<>();
|
||||
SharedPreferences preferences = preferences(values);
|
||||
for (Number percent : new Number[]{1, 12.5, 100.0}) {
|
||||
RecognizeConfiguration saved = RecognizeConfigurationStore.save(preferences,
|
||||
Collections.singletonMap("minFaceAreaPercent", percent));
|
||||
assertEquals(percent.doubleValue(), saved.getMinFaceAreaPercent(), 0.0);
|
||||
assertEquals(percent.doubleValue(), RecognizeConfigurationStore.load(preferences).getMinFaceAreaPercent(), 0.0);
|
||||
}
|
||||
assertEquals(100.0, RecognizeConfigurationStore.save(preferences,
|
||||
Collections.singletonMap("enableLiveness", true)).getMinFaceAreaPercent(), 0.0);
|
||||
assertEquals(5.0, RecognizeConfigurationStore.reset(preferences).getMinFaceAreaPercent(), 0.0);
|
||||
assertEquals(0, values.size());
|
||||
}
|
||||
|
||||
@Test public void invalidWritesAreRejectedWithoutChangingSavedValue() {
|
||||
Map<String, Object> values = new HashMap<>();
|
||||
values.put("minFaceAreaPercent", 8F);
|
||||
SharedPreferences preferences = preferences(values);
|
||||
for (Object invalid : new Object[]{Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY,
|
||||
0.0, -1, 100.01, "5", false}) {
|
||||
try {
|
||||
RecognizeConfigurationStore.save(preferences, Collections.singletonMap("minFaceAreaPercent", invalid));
|
||||
fail("Accepted invalid minFaceAreaPercent: " + invalid);
|
||||
} catch (IllegalArgumentException expected) {
|
||||
assertEquals(8.0, RecognizeConfigurationStore.load(preferences).getMinFaceAreaPercent(), 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test public void malformedPersistedValueFallsBackToFivePercent() {
|
||||
for (Object invalid : new Object[]{Float.NaN, Float.POSITIVE_INFINITY, -1F, 0F, 101F, "5", false}) {
|
||||
Map<String, Object> values = new HashMap<>();
|
||||
values.put("minFaceAreaPercent", invalid);
|
||||
assertEquals(5.0, RecognizeConfigurationStore.load(preferences(values)).getMinFaceAreaPercent(), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test public void imageQualityFallbackCopyPreservesAreaThreshold() {
|
||||
RecognizeConfiguration original = new RecognizeConfiguration.Builder().minFaceAreaPercent(17).build();
|
||||
assertEquals(17.0, original.withImageQualityEnabled(false).getMinFaceAreaPercent(), 0.0);
|
||||
assertFalse(original.withImageQualityEnabled(false).isEnableImageQuality());
|
||||
assertEquals(5.0, new RecognizeConfiguration.Builder().build().getMinFaceAreaPercent(), 0.0);
|
||||
}
|
||||
|
||||
/** Small in-memory implementation of the Android interface, without an Android runtime. */
|
||||
private static SharedPreferences preferences(Map<String, Object> values) {
|
||||
return (SharedPreferences) Proxy.newProxyInstance(SharedPreferences.class.getClassLoader(),
|
||||
new Class<?>[]{SharedPreferences.class}, (proxy, method, args) -> {
|
||||
if (method.getName().equals("getAll")) return new HashMap<>(values);
|
||||
if (method.getName().equals("edit")) return editor(values);
|
||||
if (method.getName().startsWith("get")) return values.getOrDefault(args[0], args[1]);
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
}
|
||||
|
||||
private static SharedPreferences.Editor editor(Map<String, Object> values) {
|
||||
Map<String, Object> pending = new HashMap<>();
|
||||
boolean[] clear = new boolean[]{false};
|
||||
return (SharedPreferences.Editor) Proxy.newProxyInstance(SharedPreferences.Editor.class.getClassLoader(),
|
||||
new Class<?>[]{SharedPreferences.Editor.class}, (proxy, method, args) -> {
|
||||
if (method.getName().startsWith("put")) {
|
||||
pending.put((String) args[0], args[1]);
|
||||
return proxy;
|
||||
}
|
||||
if (method.getName().equals("clear")) {
|
||||
clear[0] = true;
|
||||
return proxy;
|
||||
}
|
||||
if (method.getName().equals("commit")) {
|
||||
if (clear[0]) values.clear();
|
||||
values.putAll(pending);
|
||||
return true;
|
||||
}
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,7 @@ class FaceRecognitionConfiguration {
|
||||
required this.recognizeFailedRetryInterval,
|
||||
required this.livenessFailedRetryInterval,
|
||||
required this.livenessType,
|
||||
this.minFaceAreaPercent = 5.0,
|
||||
});
|
||||
|
||||
const FaceRecognitionConfiguration.defaults()
|
||||
@@ -102,6 +103,7 @@ class FaceRecognitionConfiguration {
|
||||
libraryFaceQualityThreshold = 0.60,
|
||||
keepMaxFace = true,
|
||||
maxDetectFaces = 1,
|
||||
minFaceAreaPercent = 5.0,
|
||||
extractRetryCount = 3,
|
||||
livenessRetryCount = 3,
|
||||
recognizeFailedRetryInterval = 0,
|
||||
@@ -119,6 +121,9 @@ class FaceRecognitionConfiguration {
|
||||
final double libraryFaceQualityThreshold;
|
||||
final bool keepMaxFace;
|
||||
final int maxDetectFaces;
|
||||
|
||||
/// 人脸框占相机完整画面的最小面积百分比,达到该值才开始识别。
|
||||
final double minFaceAreaPercent;
|
||||
final int extractRetryCount;
|
||||
final int livenessRetryCount;
|
||||
final int recognizeFailedRetryInterval;
|
||||
@@ -151,6 +156,7 @@ class FaceRecognitionConfiguration {
|
||||
).clamp(0.0, 1.0).toDouble(),
|
||||
keepMaxFace: _asBoolOr(map['keepMaxFace'], defaults.keepMaxFace),
|
||||
maxDetectFaces: _asIntOr(map['maxDetectFaces'], defaults.maxDetectFaces),
|
||||
minFaceAreaPercent: _asFaceAreaPercent(map['minFaceAreaPercent']),
|
||||
extractRetryCount:
|
||||
_asIntOr(map['extractRetryCount'], defaults.extractRetryCount),
|
||||
livenessRetryCount:
|
||||
@@ -180,6 +186,7 @@ class FaceRecognitionConfiguration {
|
||||
libraryFaceQualityThreshold.clamp(0.0, 1.0).toDouble(),
|
||||
'keepMaxFace': keepMaxFace,
|
||||
'maxDetectFaces': maxDetectFaces,
|
||||
'minFaceAreaPercent': minFaceAreaPercent,
|
||||
'extractRetryCount': extractRetryCount,
|
||||
'livenessRetryCount': livenessRetryCount,
|
||||
'recognizeFailedRetryInterval': recognizeFailedRetryInterval,
|
||||
@@ -200,6 +207,7 @@ class FaceRecognitionConfiguration {
|
||||
double? libraryFaceQualityThreshold,
|
||||
bool? keepMaxFace,
|
||||
int? maxDetectFaces,
|
||||
double? minFaceAreaPercent,
|
||||
int? extractRetryCount,
|
||||
int? livenessRetryCount,
|
||||
int? recognizeFailedRetryInterval,
|
||||
@@ -221,6 +229,7 @@ class FaceRecognitionConfiguration {
|
||||
.toDouble(),
|
||||
keepMaxFace: keepMaxFace ?? this.keepMaxFace,
|
||||
maxDetectFaces: maxDetectFaces ?? this.maxDetectFaces,
|
||||
minFaceAreaPercent: minFaceAreaPercent ?? this.minFaceAreaPercent,
|
||||
extractRetryCount: extractRetryCount ?? this.extractRetryCount,
|
||||
livenessRetryCount: livenessRetryCount ?? this.livenessRetryCount,
|
||||
recognizeFailedRetryInterval:
|
||||
@@ -246,6 +255,7 @@ class FaceRecognitionConfiguration {
|
||||
libraryFaceQualityThreshold == other.libraryFaceQualityThreshold &&
|
||||
keepMaxFace == other.keepMaxFace &&
|
||||
maxDetectFaces == other.maxDetectFaces &&
|
||||
minFaceAreaPercent == other.minFaceAreaPercent &&
|
||||
extractRetryCount == other.extractRetryCount &&
|
||||
livenessRetryCount == other.livenessRetryCount &&
|
||||
recognizeFailedRetryInterval == other.recognizeFailedRetryInterval &&
|
||||
@@ -263,6 +273,7 @@ class FaceRecognitionConfiguration {
|
||||
libraryFaceQualityThreshold,
|
||||
keepMaxFace,
|
||||
maxDetectFaces,
|
||||
minFaceAreaPercent,
|
||||
extractRetryCount,
|
||||
livenessRetryCount,
|
||||
recognizeFailedRetryInterval,
|
||||
@@ -736,6 +747,13 @@ int _asIntOr(dynamic value, int fallback) =>
|
||||
double _asDouble(dynamic value, double fallback) =>
|
||||
value is num ? value.toDouble() : fallback;
|
||||
|
||||
double _asFaceAreaPercent(dynamic value) {
|
||||
final percent = value is num ? value.toDouble() : double.nan;
|
||||
return percent.isFinite && percent >= 1 && percent <= 100
|
||||
? percent
|
||||
: const FaceRecognitionConfiguration.defaults().minFaceAreaPercent;
|
||||
}
|
||||
|
||||
bool _asBoolOr(dynamic value, bool fallback) =>
|
||||
value is bool ? value : fallback;
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ class FaceSettingController extends GetxController {
|
||||
if (isSavingRecognitionConfiguration.value) return;
|
||||
final confirmed = await appMessageDialog(
|
||||
Get.context!,
|
||||
'是否还原为默认人脸识别策略?活体检测将关闭,相似度阈值将还原为 80%。',
|
||||
'是否还原为默认人脸识别策略?活体检测将关闭,相似度阈值将还原为 80%,最小人脸占画面比例将还原为 5%。',
|
||||
);
|
||||
if (!confirmed) return;
|
||||
isSavingRecognitionConfiguration.value = true;
|
||||
|
||||
@@ -92,6 +92,20 @@ class _RecognitionConfigurationGroup extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
_RecognitionValueRow(
|
||||
icon: FAssets.icons.scanFace,
|
||||
label: '最小人脸占画面比例',
|
||||
subtitle: '人脸框面积达到相机完整画面的设定比例才开始识别;默认 5%,越大需站得越近',
|
||||
value: configuration.minFaceAreaPercent,
|
||||
min: 1,
|
||||
max: 100,
|
||||
step: 1,
|
||||
formatter: (value) => '${value.toStringAsFixed(0)}%',
|
||||
enabled: !saving,
|
||||
onChanged: (value) => controller.updateRecognitionConfiguration(
|
||||
(current) => current.copyWith(minFaceAreaPercent: value),
|
||||
),
|
||||
),
|
||||
_RecognitionValueRow(
|
||||
icon: FAssets.icons.scanFace,
|
||||
label: '识别成功相似度',
|
||||
@@ -346,17 +360,22 @@ class _RecognitionValueRow extends StatelessWidget {
|
||||
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,
|
||||
child: Semantics(
|
||||
label: label,
|
||||
child: Slider(
|
||||
value: normalized,
|
||||
divisions: ((max - min) / step).round(),
|
||||
label: formatter(value),
|
||||
semanticFormatterCallback: (offset) =>
|
||||
formatter(min + offset * (max - min)),
|
||||
onChanged: enabled
|
||||
? (offset) {
|
||||
final raw = min + (offset * (max - min));
|
||||
final rounded = (raw / step).round() * step;
|
||||
onChanged(rounded.clamp(min, max));
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pj_supermarket_cloud/common/models/plugs/face_discern_models.dart';
|
||||
|
||||
void main() {
|
||||
const defaults = FaceRecognitionConfiguration.defaults();
|
||||
|
||||
test('默认策略和旧版缺少占比字段的策略均使用 5%', () {
|
||||
expect(defaults.minFaceAreaPercent, 5);
|
||||
final legacyMap = defaults.toMap()..remove('minFaceAreaPercent');
|
||||
expect(FaceRecognitionConfiguration.fromMap(legacyMap), defaults);
|
||||
});
|
||||
|
||||
test('占比修改参与未保存判断并通过原生配置协议完整往返', () {
|
||||
final changed = defaults.copyWith(minFaceAreaPercent: 12);
|
||||
expect(changed, isNot(defaults));
|
||||
expect(changed.usesDefaults, isFalse);
|
||||
expect({defaults, changed}, hasLength(2));
|
||||
expect(changed.toMap()['minFaceAreaPercent'], 12);
|
||||
expect(FaceRecognitionConfiguration.fromMap(changed.toMap()), changed);
|
||||
expect(changed.copyWith(enableLiveness: true).minFaceAreaPercent, 12);
|
||||
expect(changed.copyWith(minFaceAreaPercent: 5).usesDefaults, isTrue);
|
||||
});
|
||||
|
||||
test('接受百分比边界和小数,异常读取值回退 5% 避免阻断设置页', () {
|
||||
for (final percent in <num>[1, 5.5, 100]) {
|
||||
final decoded = FaceRecognitionConfiguration.fromMap({
|
||||
'minFaceAreaPercent': percent,
|
||||
});
|
||||
expect(decoded.minFaceAreaPercent, percent.toDouble());
|
||||
}
|
||||
for (final invalid in <Object?>[
|
||||
null,
|
||||
'5',
|
||||
false,
|
||||
0,
|
||||
-5,
|
||||
101,
|
||||
double.nan,
|
||||
double.infinity,
|
||||
double.negativeInfinity,
|
||||
]) {
|
||||
final decoded = FaceRecognitionConfiguration.fromMap({
|
||||
'minFaceAreaPercent': invalid,
|
||||
});
|
||||
expect(decoded.minFaceAreaPercent, 5, reason: '异常值 $invalid');
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user