diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/filter/FaceAreaRatio.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/filter/FaceAreaRatio.java new file mode 100644 index 0000000..56b33cb --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/filter/FaceAreaRatio.java @@ -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; + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java index f90b3ba..9f7c361 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java @@ -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 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 faces) { + Set 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() { @@ -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 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)); } } diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java index 96e41a4..91c9f5e 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java @@ -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" + diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStore.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStore.java index 685cd93..5c3e691 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStore.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStore.java @@ -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 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; diff --git a/android/app/src/test/java/io/flutter/plugins/facediscern/tool/face/filter/FaceAreaRatioTest.java b/android/app/src/test/java/io/flutter/plugins/facediscern/tool/face/filter/FaceAreaRatioTest.java new file mode 100644 index 0000000..3c46518 --- /dev/null +++ b/android/app/src/test/java/io/flutter/plugins/facediscern/tool/face/filter/FaceAreaRatioTest.java @@ -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)); + } +} diff --git a/android/app/src/test/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStoreTest.java b/android/app/src/test/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStoreTest.java new file mode 100644 index 0000000..6763b79 --- /dev/null +++ b/android/app/src/test/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStoreTest.java @@ -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 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 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 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 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 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 values) { + Map 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()); + }); + } +} diff --git a/lib/common/models/plugs/face_discern_models.dart b/lib/common/models/plugs/face_discern_models.dart index ffb45d6..e2045c6 100644 --- a/lib/common/models/plugs/face_discern_models.dart +++ b/lib/common/models/plugs/face_discern_models.dart @@ -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; diff --git a/lib/pages/setting/face_setting_controller.dart b/lib/pages/setting/face_setting_controller.dart index b23995e..4ef09d4 100644 --- a/lib/pages/setting/face_setting_controller.dart +++ b/lib/pages/setting/face_setting_controller.dart @@ -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; diff --git a/lib/pages/setting/widgets/setting_face_plugin.dart b/lib/pages/setting/widgets/setting_face_plugin.dart index 95af954..12092d5 100644 --- a/lib/pages/setting/widgets/setting_face_plugin.dart +++ b/lib/pages/setting/widgets/setting_face_plugin.dart @@ -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, + ), ), ), ); diff --git a/test/face_recognition_configuration_test.dart b/test/face_recognition_configuration_test.dart new file mode 100644 index 0000000..031096f --- /dev/null +++ b/test/face_recognition_configuration_test.dart @@ -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 [1, 5.5, 100]) { + final decoded = FaceRecognitionConfiguration.fromMap({ + 'minFaceAreaPercent': percent, + }); + expect(decoded.minFaceAreaPercent, percent.toDouble()); + } + for (final invalid in [ + null, + '5', + false, + 0, + -5, + 101, + double.nan, + double.infinity, + double.negativeInfinity, + ]) { + final decoded = FaceRecognitionConfiguration.fromMap({ + 'minFaceAreaPercent': invalid, + }); + expect(decoded.minFaceAreaPercent, 5, reason: '异常值 $invalid'); + } + }); +}