feat:人脸识别人脸占比设置

This commit is contained in:
dzc committed 2026-10-23 10:13:33 +08:00
1 parent 382b8d660f
commit e75b3bd58e
10 files changed
+475 -110

No files matched your search

@@ -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;
}
}
@@ -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));
}
}
@@ -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" +
@@ -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;
@@ -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));
}
}
@@ -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');
}
});
}