11
This commit is contained in:
1 parent
f6b37348c4
commit
917f03bc58
61 files changed
+4233
-367
No files matched your search
+41
-2
@@ -16,7 +16,7 @@ public class DatabaseHelper extends SQLiteOpenHelper {
|
||||
private static final String DATABASE_PATH = "pj_cashier/database";
|
||||
private static final String DATABASE_NAME = "cash_register.db";
|
||||
/** 与 Flutter Floor 的 @Database 版本保持一致,避免原生层提前推进 user_version。 */
|
||||
private static final int DATABASE_VERSION = 11;
|
||||
private static final int DATABASE_VERSION = 12;
|
||||
|
||||
public DatabaseHelper(Context context) {
|
||||
super(context, getCustomDatabasePath(context), null, DATABASE_VERSION);
|
||||
@@ -41,6 +41,45 @@ public class DatabaseHelper extends SQLiteOpenHelper {
|
||||
|
||||
@Override
|
||||
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
|
||||
onCreate(db);
|
||||
if (oldVersion < 12) {
|
||||
migrateFaceQuality(db);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从版本11无损补齐本地人脸质量字段;重复执行安全,避免原生打开数据库时覆盖 Floor 迁移。
|
||||
*/
|
||||
private void migrateFaceQuality(SQLiteDatabase db) {
|
||||
if (!hasColumn(db, "face", "qualityScore")) {
|
||||
db.execSQL("ALTER TABLE face ADD COLUMN qualityScore REAL");
|
||||
}
|
||||
if (!hasColumn(db, "face", "qualityStatus")) {
|
||||
db.execSQL("ALTER TABLE face ADD COLUMN qualityStatus TEXT NOT NULL DEFAULT 'pending'");
|
||||
}
|
||||
if (!hasColumn(db, "face", "qualityCheckedAt")) {
|
||||
db.execSQL("ALTER TABLE face ADD COLUMN qualityCheckedAt TEXT");
|
||||
}
|
||||
if (!hasColumn(db, "face", "qualityErrorCode")) {
|
||||
db.execSQL("ALTER TABLE face ADD COLUMN qualityErrorCode INTEGER");
|
||||
}
|
||||
db.execSQL("UPDATE face SET qualityStatus = 'pending' WHERE qualityStatus IS NULL OR qualityStatus NOT IN ('pending', 'detected', 'failed')");
|
||||
// Floor 的可空时间转换器使用空字符串承载业务空值,原生兜底升级必须保持同一存储语义。
|
||||
db.execSQL("UPDATE face SET qualityCheckedAt = '' WHERE qualityCheckedAt IS NULL");
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS index_face_qualityStatus_qualityScore ON face(qualityStatus, qualityScore)");
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查表中是否已经存在目标字段。
|
||||
*/
|
||||
private boolean hasColumn(SQLiteDatabase db, String table, String column) {
|
||||
try (Cursor cursor = db.rawQuery("PRAGMA table_info(" + table + ")", null)) {
|
||||
int nameIndex = cursor.getColumnIndex("name");
|
||||
while (cursor.moveToNext()) {
|
||||
if (nameIndex >= 0 && column.equals(cursor.getString(nameIndex))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+63
@@ -40,6 +40,8 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
|
||||
private static final String EVENT_TAG = "local/face_discern_event";
|
||||
private static final String SESSION_EVENT_TAG = "local/face_discern_session_event";
|
||||
private static final String REGISTRATION_EVENT_TAG = "local/face_discern_registration_event";
|
||||
/** 在库照片质量检测进度事件通道。 */
|
||||
private static final String QUALITY_AUDIT_EVENT_TAG = "local/face_discern_quality_audit_event";
|
||||
private static final ExecutorService FACE_EXECUTOR = Executors.newSingleThreadExecutor();
|
||||
private static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper());
|
||||
|
||||
@@ -51,9 +53,13 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
|
||||
private EventChannel eventChannel;
|
||||
private EventChannel sessionEventChannel;
|
||||
private EventChannel registrationEventChannel;
|
||||
/** 当前Flutter Engine对应的质量检测进度通道。 */
|
||||
private EventChannel qualityAuditEventChannel;
|
||||
private volatile EventChannel.EventSink faceEventSink;
|
||||
private volatile EventChannel.EventSink sessionEventSink;
|
||||
private volatile EventChannel.EventSink registrationEventSink;
|
||||
/** 当前质量检测进度订阅;页面离开时置空但不终止原生任务。 */
|
||||
private volatile EventChannel.EventSink qualityAuditEventSink;
|
||||
|
||||
private MethodChannel.Result pendingResult;
|
||||
|
||||
@@ -115,6 +121,21 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
|
||||
registrationEventSink = null;
|
||||
}
|
||||
});
|
||||
qualityAuditEventChannel = new EventChannel(binding.getBinaryMessenger(), QUALITY_AUDIT_EVENT_TAG);
|
||||
qualityAuditEventChannel.setStreamHandler(new EventChannel.StreamHandler() {
|
||||
/** 订阅后立即返回最新进度,支持页面重建。 */
|
||||
@Override
|
||||
public void onListen(Object arguments, EventChannel.EventSink events) {
|
||||
qualityAuditEventSink = events;
|
||||
events.success(FlutterTool.getLatestQualityAuditProgress());
|
||||
}
|
||||
|
||||
/** 取消页面订阅,不取消仍在后台执行的批量检测。 */
|
||||
@Override
|
||||
public void onCancel(Object arguments) {
|
||||
qualityAuditEventSink = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -125,9 +146,11 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
|
||||
eventChannel.setStreamHandler(null);
|
||||
sessionEventChannel.setStreamHandler(null);
|
||||
registrationEventChannel.setStreamHandler(null);
|
||||
if (qualityAuditEventChannel != null) qualityAuditEventChannel.setStreamHandler(null);
|
||||
faceEventSink = null;
|
||||
sessionEventSink = null;
|
||||
registrationEventSink = null;
|
||||
qualityAuditEventSink = null;
|
||||
unregisterLegacyReceiver();
|
||||
if (sessionManager != null) {
|
||||
for (Long outputId : previewOutputs.values().toArray(new Long[0])) {
|
||||
@@ -295,6 +318,9 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
|
||||
case "extractFaceFeature":
|
||||
startFaceFeatureExtraction(stringArgument(call, "filePath"), result);
|
||||
break;
|
||||
case "auditFaceQuality":
|
||||
startFaceQualityAudit(call.argument("items"), result);
|
||||
break;
|
||||
case "applyFaceDelta":
|
||||
startFaceDelta(call, result);
|
||||
break;
|
||||
@@ -366,6 +392,35 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在统一人脸操作互斥锁内批量检测存量图片,并通过独立事件通道报告进度。
|
||||
*
|
||||
* @param rawItems Flutter提交的样本编号与本地路径列表
|
||||
* @param result 批量检测完成结果
|
||||
*/
|
||||
private void startFaceQualityAudit(Object rawItems, MethodChannel.Result result) {
|
||||
if (!(rawItems instanceof List)) {
|
||||
result.error("INVALID_QUALITY_AUDIT_ITEMS", "质量检测参数无效", null);
|
||||
return;
|
||||
}
|
||||
if (!FlutterTool.tryStartRegistration()) {
|
||||
result.error("REGISTRATION_IN_PROGRESS", "人脸库正在更新,请稍候", null);
|
||||
return;
|
||||
}
|
||||
FACE_EXECUTOR.execute(() -> {
|
||||
try {
|
||||
Map<String, Object> audit = FlutterTool.auditFaceQuality(
|
||||
context, (List<?>) rawItems, this::emitQualityAuditProgress);
|
||||
MAIN_HANDLER.post(() -> result.success(audit));
|
||||
} catch (Exception exception) {
|
||||
Log.e(TAG, "在库人脸质量检测失败:" + exception.getMessage(), exception);
|
||||
MAIN_HANDLER.post(() -> result.error("FACE_QUALITY_AUDIT_FAILED", "在库人脸质量检测失败", null));
|
||||
} finally {
|
||||
FlutterTool.finishRegistration();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在统一人脸注册占用标记和后台执行器中应用单样本增删。
|
||||
*
|
||||
@@ -427,6 +482,14 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me
|
||||
});
|
||||
}
|
||||
|
||||
/** 将质量检测进度安全切回主线程发送给Flutter。 */
|
||||
private void emitQualityAuditProgress(Map<String, Object> progress) {
|
||||
MAIN_HANDLER.post(() -> {
|
||||
EventChannel.EventSink sink = qualityAuditEventSink;
|
||||
if (sink != null) sink.success(progress);
|
||||
});
|
||||
}
|
||||
|
||||
private String stringArgument(MethodCall call, String key) {
|
||||
Object value = call.argument(key);
|
||||
return value == null ? "" : value.toString();
|
||||
|
||||
+189
-31
@@ -47,11 +47,18 @@ public class FlutterTool {
|
||||
private static final AtomicBoolean REGISTRATION_IN_PROGRESS = new AtomicBoolean(false);
|
||||
private static volatile Map<String, Object> latestRegistrationProgress = idleRegistrationProgress();
|
||||
private static volatile Map<String, Object> latestRegistrationResult;
|
||||
private static volatile Map<String, Object> latestQualityAuditProgress = idleQualityAuditProgress();
|
||||
|
||||
public interface RegistrationProgressListener {
|
||||
void onProgress(Map<String, Object> progress);
|
||||
}
|
||||
|
||||
/** 接收原生在库质量检测进度并转发给Flutter事件通道。 */
|
||||
public interface QualityAuditProgressListener {
|
||||
/** 发布一份不包含图片路径和人员信息的进度快照。 */
|
||||
void onProgress(Map<String, Object> progress);
|
||||
}
|
||||
|
||||
static void updateConfig(String appId, String activeKey, String sdkKey) {
|
||||
Constants.APP_ID = appId == null ? "" : appId.trim();
|
||||
Constants.ACTIVE_KEY = activeKey == null ? "" : activeKey.trim();
|
||||
@@ -152,6 +159,11 @@ public class FlutterTool {
|
||||
return new HashMap<>(latestRegistrationProgress);
|
||||
}
|
||||
|
||||
/** 返回最新在库图片质量检测进度,供新订阅者立即恢复页面状态。 */
|
||||
static Map<String, Object> getLatestQualityAuditProgress() {
|
||||
return new HashMap<>(latestQualityAuditProgress);
|
||||
}
|
||||
|
||||
|
||||
static Integer parseId(String id) {
|
||||
try {
|
||||
@@ -239,29 +251,38 @@ public class FlutterTool {
|
||||
static public Boolean registerFace(Context context, byte[] bytes, FaceEntity entity) {
|
||||
FaceServer faceServer = FaceServer.getInstance();
|
||||
faceServer.init(context);
|
||||
// 类型转换
|
||||
Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
|
||||
if (bitmap == null) return false;
|
||||
Bitmap alignedBitmap = ArcSoftImageUtil.getAlignedBitmap(bitmap, true);
|
||||
byte[] bgr24Data = ArcSoftImageUtil.createImageData(alignedBitmap.getWidth(), alignedBitmap.getHeight(), ArcSoftImageFormat.BGR24);
|
||||
int transformCode = ArcSoftImageUtil.bitmapToImageData(alignedBitmap, bgr24Data, ArcSoftImageFormat.BGR24);
|
||||
if (transformCode != ArcSoftImageUtilError.CODE_SUCCESS) {
|
||||
Log.e(TAG, "注册人脸,bitmapToImageData错误code:" + transformCode);
|
||||
if (alignedBitmap == null) {
|
||||
bitmap.recycle();
|
||||
return false;
|
||||
}
|
||||
byte[] faceBytes = faceServer.registerBgr24(
|
||||
context,
|
||||
bgr24Data,
|
||||
alignedBitmap.getWidth(),
|
||||
alignedBitmap.getHeight(),
|
||||
String.valueOf(System.currentTimeMillis())
|
||||
);
|
||||
if (faceBytes == null) {
|
||||
Log.e(TAG, "注册人脸,registerBgr24错误");
|
||||
return false;
|
||||
try {
|
||||
byte[] bgr24Data = ArcSoftImageUtil.createImageData(alignedBitmap.getWidth(), alignedBitmap.getHeight(), ArcSoftImageFormat.BGR24);
|
||||
int transformCode = ArcSoftImageUtil.bitmapToImageData(alignedBitmap, bgr24Data, ArcSoftImageFormat.BGR24);
|
||||
if (transformCode != ArcSoftImageUtilError.CODE_SUCCESS) {
|
||||
Log.e(TAG, "注册人脸,bitmapToImageData错误code:" + transformCode);
|
||||
return false;
|
||||
}
|
||||
byte[] faceBytes = faceServer.registerBgr24(
|
||||
context,
|
||||
bgr24Data,
|
||||
alignedBitmap.getWidth(),
|
||||
alignedBitmap.getHeight(),
|
||||
String.valueOf(System.currentTimeMillis())
|
||||
);
|
||||
if (faceBytes == null) {
|
||||
Log.e(TAG, "注册人脸,registerBgr24错误");
|
||||
return false;
|
||||
}
|
||||
entity.setFaceModel(faceBytes);
|
||||
int code = faceServer.registerFaceFeatureInfoFromDb(entity);
|
||||
return code == ErrorInfo.MOK;
|
||||
} finally {
|
||||
if (alignedBitmap != bitmap && !alignedBitmap.isRecycled()) alignedBitmap.recycle();
|
||||
if (!bitmap.isRecycled()) bitmap.recycle();
|
||||
}
|
||||
entity.setFaceModel(faceBytes);
|
||||
int code = faceServer.registerFaceFeatureInfoFromDb(entity);
|
||||
return code == ErrorInfo.MOK;
|
||||
}
|
||||
|
||||
|
||||
@@ -303,21 +324,72 @@ public class FlutterTool {
|
||||
static public Map<String, Object> extractFaceFeature(Context context, String filePath) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
try {
|
||||
byte[] featureData = extractFaceModelFromFile(context, filePath);
|
||||
FaceServer.QualityFeatureResult nativeResult = extractFaceResultFromFile(context, filePath);
|
||||
byte[] featureData = nativeResult.featureData;
|
||||
boolean success = featureData != null && featureData.length > 0;
|
||||
result.put("success", success);
|
||||
result.put("message", success ? "人脸特征提取成功" : "图片中未提取到可注册人脸");
|
||||
result.put("featureData", success ? featureData : new byte[0]);
|
||||
result.put("qualityStatus", nativeResult.qualityStatus);
|
||||
result.put("qualityScore", nativeResult.qualityScore);
|
||||
result.put("qualityErrorCode", nativeResult.qualityErrorCode);
|
||||
return result;
|
||||
} catch (Exception exception) {
|
||||
Log.e(TAG, "提取人脸特征异常error:" + exception.getMessage(), exception);
|
||||
result.put("success", false);
|
||||
result.put("message", "人脸特征提取失败");
|
||||
result.put("featureData", new byte[0]);
|
||||
result.put("qualityStatus", "failed");
|
||||
result.put("qualityErrorCode", -99);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量检测Flutter提交的本地人脸图片质量,逐图释放Bitmap并返回一一对应的结果。
|
||||
*/
|
||||
static public Map<String, Object> auditFaceQuality(Context context, List<?> items, QualityAuditProgressListener listener) {
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
int total = items == null ? 0 : items.size();
|
||||
int success = 0;
|
||||
int failed = 0;
|
||||
publishQualityAuditProgress(listener, 0, total, success, failed, null, true, "准备检测在库人脸");
|
||||
if (items != null) {
|
||||
for (Object rawItem : items) {
|
||||
Map<String, Object> itemResult = new HashMap<>();
|
||||
String sampleId = "";
|
||||
try {
|
||||
if (!(rawItem instanceof Map)) throw new IllegalArgumentException("检测项格式错误");
|
||||
Map<?, ?> item = (Map<?, ?>) rawItem;
|
||||
sampleId = item.get("sampleId") == null ? "" : item.get("sampleId").toString();
|
||||
String filePath = item.get("filePath") == null ? "" : item.get("filePath").toString();
|
||||
FaceServer.QualityFeatureResult quality = detectFaceQualityFromFile(context, filePath);
|
||||
itemResult.put("sampleId", sampleId);
|
||||
itemResult.put("status", quality.qualityStatus);
|
||||
itemResult.put("score", quality.qualityScore);
|
||||
itemResult.put("errorCode", quality.qualityErrorCode);
|
||||
boolean detected = "detected".equals(quality.qualityStatus) && quality.qualityScore != null;
|
||||
itemResult.put("message", detected ? "图片质量检测成功" : "图片质量检测失败");
|
||||
if (detected) success++; else failed++;
|
||||
} catch (Exception exception) {
|
||||
Log.w(TAG, "在库图片质量检测失败sampleId:" + maskSampleIdText(sampleId) + ";error:" + exception.getClass().getSimpleName());
|
||||
itemResult.put("sampleId", sampleId);
|
||||
itemResult.put("status", "failed");
|
||||
itemResult.put("errorCode", -99);
|
||||
itemResult.put("message", "图片质量检测失败");
|
||||
failed++;
|
||||
}
|
||||
results.add(itemResult);
|
||||
publishQualityAuditProgress(listener, results.size(), total, success, failed, sampleId, true, "正在检测在库人脸");
|
||||
}
|
||||
}
|
||||
publishQualityAuditProgress(listener, total, total, success, failed, null, false, "在库人脸质量检测完成");
|
||||
Map<String, Object> auditResult = new HashMap<>();
|
||||
auditResult.put("results", results);
|
||||
auditResult.put("message", "在库人脸质量检测完成");
|
||||
return auditResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在虹软内存库中应用单样本增删;替换失败由 FaceServer 恢复旧特征。
|
||||
*
|
||||
@@ -478,21 +550,107 @@ public class FlutterTool {
|
||||
* @return 虹软特征字节,图片无效或未检测到合格人脸时返回空
|
||||
*/
|
||||
private static byte[] extractFaceModelFromFile(Context context, String filePath) {
|
||||
if (filePath == null || filePath.isEmpty()) return null;
|
||||
return extractFaceResultFromFile(context, filePath).featureData;
|
||||
}
|
||||
|
||||
/** 将本地文件一次转换为BGR24并同时返回特征与质量结果。 */
|
||||
private static FaceServer.QualityFeatureResult extractFaceResultFromFile(Context context, String filePath) {
|
||||
FaceServer.QualityFeatureResult failed = new FaceServer.QualityFeatureResult();
|
||||
failed.qualityStatus = "failed";
|
||||
failed.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM;
|
||||
if (filePath == null || filePath.isEmpty()) return failed;
|
||||
byte[] bytes = FileUtil.fileToData(new File(filePath));
|
||||
if (bytes == null || bytes.length == 0) return null;
|
||||
if (bytes == null || bytes.length == 0) return failed;
|
||||
Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
|
||||
if (bitmap == null) return null;
|
||||
if (bitmap == null) return failed;
|
||||
Bitmap alignedBitmap = ArcSoftImageUtil.getAlignedBitmap(bitmap, true);
|
||||
if (alignedBitmap == null) return null;
|
||||
byte[] bgr24Data = ArcSoftImageUtil.createImageData(
|
||||
alignedBitmap.getWidth(), alignedBitmap.getHeight(), ArcSoftImageFormat.BGR24);
|
||||
int transformCode = ArcSoftImageUtil.bitmapToImageData(alignedBitmap, bgr24Data, ArcSoftImageFormat.BGR24);
|
||||
if (transformCode != ArcSoftImageUtilError.CODE_SUCCESS) return null;
|
||||
FaceServer faceServer = FaceServer.getInstance();
|
||||
faceServer.init(context);
|
||||
return faceServer.registerBgr24(context, bgr24Data, alignedBitmap.getWidth(),
|
||||
alignedBitmap.getHeight(), String.valueOf(System.currentTimeMillis()));
|
||||
if (alignedBitmap == null) {
|
||||
bitmap.recycle();
|
||||
return failed;
|
||||
}
|
||||
try {
|
||||
byte[] bgr24Data = ArcSoftImageUtil.createImageData(
|
||||
alignedBitmap.getWidth(), alignedBitmap.getHeight(), ArcSoftImageFormat.BGR24);
|
||||
int transformCode = ArcSoftImageUtil.bitmapToImageData(alignedBitmap, bgr24Data, ArcSoftImageFormat.BGR24);
|
||||
if (transformCode != ArcSoftImageUtilError.CODE_SUCCESS) {
|
||||
failed.qualityErrorCode = transformCode;
|
||||
return failed;
|
||||
}
|
||||
FaceServer faceServer = FaceServer.getInstance();
|
||||
faceServer.init(context);
|
||||
return faceServer.registerBgr24WithQuality(context, bgr24Data, alignedBitmap.getWidth(),
|
||||
alignedBitmap.getHeight(), String.valueOf(System.currentTimeMillis()));
|
||||
} finally {
|
||||
if (alignedBitmap != bitmap && !alignedBitmap.isRecycled()) alignedBitmap.recycle();
|
||||
if (!bitmap.isRecycled()) bitmap.recycle();
|
||||
}
|
||||
}
|
||||
|
||||
/** 将本地文件转换为BGR24后仅执行质量检测。 */
|
||||
private static FaceServer.QualityFeatureResult detectFaceQualityFromFile(Context context, String filePath) {
|
||||
FaceServer.QualityFeatureResult failed = new FaceServer.QualityFeatureResult();
|
||||
failed.qualityStatus = "failed";
|
||||
failed.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM;
|
||||
if (filePath == null || filePath.isEmpty()) return failed;
|
||||
byte[] bytes = FileUtil.fileToData(new File(filePath));
|
||||
if (bytes == null || bytes.length == 0) return failed;
|
||||
Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
|
||||
if (bitmap == null) return failed;
|
||||
Bitmap alignedBitmap = ArcSoftImageUtil.getAlignedBitmap(bitmap, true);
|
||||
if (alignedBitmap == null) {
|
||||
bitmap.recycle();
|
||||
return failed;
|
||||
}
|
||||
try {
|
||||
byte[] bgr24Data = ArcSoftImageUtil.createImageData(alignedBitmap.getWidth(), alignedBitmap.getHeight(), ArcSoftImageFormat.BGR24);
|
||||
int transformCode = ArcSoftImageUtil.bitmapToImageData(alignedBitmap, bgr24Data, ArcSoftImageFormat.BGR24);
|
||||
if (transformCode != ArcSoftImageUtilError.CODE_SUCCESS) {
|
||||
failed.qualityErrorCode = transformCode;
|
||||
return failed;
|
||||
}
|
||||
FaceServer faceServer = FaceServer.getInstance();
|
||||
faceServer.init(context);
|
||||
return faceServer.detectBgr24Quality(bgr24Data, alignedBitmap.getWidth(), alignedBitmap.getHeight());
|
||||
} finally {
|
||||
if (alignedBitmap != bitmap && !alignedBitmap.isRecycled()) alignedBitmap.recycle();
|
||||
if (!bitmap.isRecycled()) bitmap.recycle();
|
||||
}
|
||||
}
|
||||
|
||||
/** 发布并缓存质量检测进度,供页面中途进入时恢复。 */
|
||||
private static void publishQualityAuditProgress(QualityAuditProgressListener listener, int processed, int total,
|
||||
int success, int failed, String currentSampleId,
|
||||
boolean inProgress, String message) {
|
||||
Map<String, Object> progress = new HashMap<>();
|
||||
progress.put("processed", processed);
|
||||
progress.put("total", total);
|
||||
progress.put("successCount", success);
|
||||
progress.put("failedCount", failed);
|
||||
progress.put("currentSampleId", currentSampleId);
|
||||
progress.put("inProgress", inProgress);
|
||||
progress.put("message", message);
|
||||
latestQualityAuditProgress = progress;
|
||||
if (listener != null) listener.onProgress(new HashMap<>(progress));
|
||||
}
|
||||
|
||||
/** 创建尚未执行质量检测时的空进度快照。 */
|
||||
private static Map<String, Object> idleQualityAuditProgress() {
|
||||
Map<String, Object> progress = new HashMap<>();
|
||||
progress.put("processed", 0);
|
||||
progress.put("total", 0);
|
||||
progress.put("successCount", 0);
|
||||
progress.put("failedCount", 0);
|
||||
progress.put("currentSampleId", null);
|
||||
progress.put("inProgress", false);
|
||||
progress.put("message", "尚未执行在库人脸质量检测");
|
||||
return progress;
|
||||
}
|
||||
|
||||
/** 对质量检测日志中的样本编号做最小脱敏。 */
|
||||
private static String maskSampleIdText(String sampleId) {
|
||||
if (sampleId == null || sampleId.isEmpty()) return "-";
|
||||
if (sampleId.length() <= 4) return "***" + sampleId;
|
||||
return sampleId.substring(0, 1) + "***" + sampleId.substring(sampleId.length() - 3);
|
||||
}
|
||||
|
||||
private static boolean hasFaceModel(FaceEntity face) {
|
||||
|
||||
+30
@@ -14,6 +14,8 @@ public class RecognizeConfiguration {
|
||||
public static final float DEFAULT_SIMILAR_THRESHOLD = 0.8F;
|
||||
public static final float DEFAULT_IMAGE_QUALITY_NO_MASK_THRESHOLD = 0.49F;
|
||||
public static final float DEFAULT_IMAGE_QUALITY_MASK_THRESHOLD = 0.29F;
|
||||
/** 在库照片质量诊断默认阈值,只用于终端运维分类。 */
|
||||
public static final float DEFAULT_LIBRARY_FACE_QUALITY_THRESHOLD = 0.60F;
|
||||
public static final float DEFAULT_RGB_LIVENESS_THRESHOLD = 0.5F;
|
||||
public static final boolean DEFAULT_ENABLE_LIVENESS = false;
|
||||
public static final boolean DEFAULT_ENABLE_IMAGE_QUALITY = true;
|
||||
@@ -43,6 +45,10 @@ public class RecognizeConfiguration {
|
||||
* 图像质量检测阈值:适用于戴口罩且人脸识别场景
|
||||
*/
|
||||
private float imageQualityMaskRecognizeThreshold;
|
||||
/**
|
||||
* 在库照片质量诊断阈值,不参与实时识别和特征注册。
|
||||
*/
|
||||
private float libraryFaceQualityThreshold;
|
||||
/**
|
||||
* 识别失败重试间隔
|
||||
*/
|
||||
@@ -99,6 +105,7 @@ public class RecognizeConfiguration {
|
||||
this.similarThreshold = builder.similarThreshold;
|
||||
this.imageQualityNoMaskRecognizeThreshold = builder.imageQualityNoMaskRecognizeThreshold;
|
||||
this.imageQualityMaskRecognizeThreshold = builder.imageQualityMaskRecognizeThreshold;
|
||||
this.libraryFaceQualityThreshold = builder.libraryFaceQualityThreshold;
|
||||
this.enableLiveness = builder.enableLiveness;
|
||||
this.enableImageQuality = builder.enableImageQuality;
|
||||
this.enableFaceAreaLimit = builder.enableFaceAreaLimit;
|
||||
@@ -121,6 +128,8 @@ public class RecognizeConfiguration {
|
||||
private float similarThreshold = DEFAULT_SIMILAR_THRESHOLD;
|
||||
private float imageQualityNoMaskRecognizeThreshold = DEFAULT_IMAGE_QUALITY_NO_MASK_THRESHOLD;
|
||||
private float imageQualityMaskRecognizeThreshold = DEFAULT_IMAGE_QUALITY_MASK_THRESHOLD;
|
||||
/** 在库照片质量诊断阈值,允许范围为0到1。 */
|
||||
private float libraryFaceQualityThreshold = DEFAULT_LIBRARY_FACE_QUALITY_THRESHOLD;
|
||||
private boolean enableLiveness = DEFAULT_ENABLE_LIVENESS;
|
||||
private boolean enableFaceAreaLimit = false;
|
||||
private boolean enableImageQuality = DEFAULT_ENABLE_IMAGE_QUALITY;
|
||||
@@ -171,6 +180,17 @@ public class RecognizeConfiguration {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置在库照片质量诊断阈值。
|
||||
*
|
||||
* @param val 0到1之间的质量分界值
|
||||
* @return 当前构建器
|
||||
*/
|
||||
public Builder libraryFaceQualityThreshold(float val) {
|
||||
this.libraryFaceQualityThreshold = val;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder enableLiveness(boolean val) {
|
||||
this.enableLiveness = val;
|
||||
return this;
|
||||
@@ -230,6 +250,15 @@ public class RecognizeConfiguration {
|
||||
return imageQualityMaskRecognizeThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取在库照片质量诊断阈值。
|
||||
*
|
||||
* @return 0到1之间的质量分界值
|
||||
*/
|
||||
public float getLibraryFaceQualityThreshold() {
|
||||
return libraryFaceQualityThreshold;
|
||||
}
|
||||
|
||||
public boolean isEnableImageQuality() {
|
||||
return enableImageQuality;
|
||||
}
|
||||
@@ -304,6 +333,7 @@ public class RecognizeConfiguration {
|
||||
"enableImageQuality: " + enableImageQuality + "\r\n" +
|
||||
"imageQualityNoMaskRecognizeThreshold: " + imageQualityNoMaskRecognizeThreshold + "\r\n" +
|
||||
"imageQualityMaskRecognizeThreshold: " + imageQualityMaskRecognizeThreshold + "\r\n" +
|
||||
"libraryFaceQualityThreshold: " + libraryFaceQualityThreshold + "\r\n" +
|
||||
|
||||
"enableLiveness: " + enableLiveness + "\r\n" +
|
||||
"livenessRetryCount: " + livenessRetryCount + "\r\n" +
|
||||
|
||||
+4
@@ -28,6 +28,7 @@ public final class RecognizeConfigurationStore {
|
||||
.enableImageQuality(preferences.getBoolean("enableImageQuality", RecognizeConfiguration.DEFAULT_ENABLE_IMAGE_QUALITY))
|
||||
.imageQualityNoMaskRecognizeThreshold(preferences.getFloat("imageQualityNoMaskRecognizeThreshold", RecognizeConfiguration.DEFAULT_IMAGE_QUALITY_NO_MASK_THRESHOLD))
|
||||
.imageQualityMaskRecognizeThreshold(preferences.getFloat("imageQualityMaskRecognizeThreshold", RecognizeConfiguration.DEFAULT_IMAGE_QUALITY_MASK_THRESHOLD))
|
||||
.libraryFaceQualityThreshold(preferences.getFloat("libraryFaceQualityThreshold", RecognizeConfiguration.DEFAULT_LIBRARY_FACE_QUALITY_THRESHOLD))
|
||||
.keepMaxFace(preferences.getBoolean("keepMaxFace", RecognizeConfiguration.DEFAULT_KEEP_MAX_FACE))
|
||||
.maxDetectFaces(preferences.getInt("maxDetectFaces", RecognizeConfiguration.DEFAULT_MAX_DETECT_FACES))
|
||||
.extractRetryCount(preferences.getInt("extractRetryCount", RecognizeConfiguration.DEFAULT_EXTRACT_RETRY_COUNT))
|
||||
@@ -48,6 +49,7 @@ public final class RecognizeConfigurationStore {
|
||||
boolean enableImageQuality = booleanValue(values, "enableImageQuality", current.isEnableImageQuality());
|
||||
float noMaskThreshold = floatValue(values, "imageQualityNoMaskRecognizeThreshold", current.getImageQualityNoMaskRecognizeThreshold(), 0F, 1F);
|
||||
float maskThreshold = floatValue(values, "imageQualityMaskRecognizeThreshold", current.getImageQualityMaskRecognizeThreshold(), 0F, 1F);
|
||||
float libraryFaceQualityThreshold = floatValue(values, "libraryFaceQualityThreshold", current.getLibraryFaceQualityThreshold(), 0F, 1F);
|
||||
boolean keepMaxFace = booleanValue(values, "keepMaxFace", current.isKeepMaxFace());
|
||||
int maxDetectFaces = intValue(values, "maxDetectFaces", current.getMaxDetectFaces(), 1, 3);
|
||||
int extractRetryCount = intValue(values, "extractRetryCount", current.getExtractRetryCount(), 0, 10);
|
||||
@@ -62,6 +64,7 @@ public final class RecognizeConfigurationStore {
|
||||
.putBoolean("enableImageQuality", enableImageQuality)
|
||||
.putFloat("imageQualityNoMaskRecognizeThreshold", noMaskThreshold)
|
||||
.putFloat("imageQualityMaskRecognizeThreshold", maskThreshold)
|
||||
.putFloat("libraryFaceQualityThreshold", libraryFaceQualityThreshold)
|
||||
.putBoolean("keepMaxFace", keepMaxFace)
|
||||
.putInt("maxDetectFaces", maxDetectFaces)
|
||||
.putInt("extractRetryCount", extractRetryCount)
|
||||
@@ -88,6 +91,7 @@ public final class RecognizeConfigurationStore {
|
||||
result.put("enableImageQuality", configuration.isEnableImageQuality());
|
||||
result.put("imageQualityNoMaskRecognizeThreshold", configuration.getImageQualityNoMaskRecognizeThreshold());
|
||||
result.put("imageQualityMaskRecognizeThreshold", configuration.getImageQualityMaskRecognizeThreshold());
|
||||
result.put("libraryFaceQualityThreshold", configuration.getLibraryFaceQualityThreshold());
|
||||
result.put("keepMaxFace", configuration.isKeepMaxFace());
|
||||
result.put("maxDetectFaces", configuration.getMaxDetectFaces());
|
||||
result.put("extractRetryCount", configuration.getExtractRetryCount());
|
||||
|
||||
+144
-17
@@ -11,6 +11,7 @@ import com.arcsoft.face.FaceEngine;
|
||||
import com.arcsoft.face.FaceFeature;
|
||||
import com.arcsoft.face.FaceFeatureInfo;
|
||||
import com.arcsoft.face.FaceInfo;
|
||||
import com.arcsoft.face.ImageQualitySimilar;
|
||||
import com.arcsoft.face.MaskInfo;
|
||||
import com.arcsoft.face.SearchResult;
|
||||
import com.arcsoft.face.enums.DetectFaceOrientPriority;
|
||||
@@ -45,6 +46,8 @@ public class FaceServer {
|
||||
private static volatile FaceServer faceServer = null;
|
||||
private final Map<Integer, FaceEntity> faceRegisterInfoMap = new HashMap<>();
|
||||
private int engineInitCode = -99;
|
||||
private int imageQualityInitCode = -99;
|
||||
private boolean imageQualityReady;
|
||||
private String imageRootPath;
|
||||
/**
|
||||
* 最大注册人脸数
|
||||
@@ -54,17 +57,35 @@ public class FaceServer {
|
||||
public synchronized void init(Context context) {
|
||||
if (faceEngine == null && context != null) {
|
||||
faceEngine = new FaceEngine();
|
||||
engineInitCode = faceEngine.init(
|
||||
int requestedCode = faceEngine.init(
|
||||
context,
|
||||
DetectMode.ASF_DETECT_MODE_IMAGE,
|
||||
DetectFaceOrientPriority.ASF_OP_ALL_OUT,
|
||||
1,
|
||||
FaceEngine.ASF_FACE_RECOGNITION | FaceEngine.ASF_FACE_DETECT | FaceEngine.ASF_MASK_DETECT
|
||||
FaceEngine.ASF_FACE_RECOGNITION | FaceEngine.ASF_FACE_DETECT | FaceEngine.ASF_MASK_DETECT | FaceEngine.ASF_IMAGEQUALITY
|
||||
);
|
||||
if (engineInitCode != ErrorInfo.MOK) {
|
||||
Log.e(TAG, "人脸引擎初始化失败code" + engineInitCode);
|
||||
imageQualityInitCode = requestedCode;
|
||||
if (requestedCode == ErrorInfo.MOK) {
|
||||
engineInitCode = requestedCode;
|
||||
imageQualityReady = true;
|
||||
} else {
|
||||
Log.w(TAG, "图像质量模块不可用,回退基础人脸引擎code" + requestedCode);
|
||||
faceEngine.unInit();
|
||||
faceEngine = null;
|
||||
faceEngine = new FaceEngine();
|
||||
engineInitCode = faceEngine.init(
|
||||
context,
|
||||
DetectMode.ASF_DETECT_MODE_IMAGE,
|
||||
DetectFaceOrientPriority.ASF_OP_ALL_OUT,
|
||||
1,
|
||||
FaceEngine.ASF_FACE_RECOGNITION | FaceEngine.ASF_FACE_DETECT | FaceEngine.ASF_MASK_DETECT
|
||||
);
|
||||
imageQualityReady = false;
|
||||
if (engineInitCode != ErrorInfo.MOK) {
|
||||
Log.e(TAG, "人脸引擎初始化失败code" + engineInitCode);
|
||||
faceEngine.unInit();
|
||||
faceEngine = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +98,16 @@ public class FaceServer {
|
||||
return faceEngine != null && engineInitCode == ErrorInfo.MOK;
|
||||
}
|
||||
|
||||
/** 返回当前授权是否包含虹软图像质量模块。 */
|
||||
public synchronized boolean isImageQualityReady() {
|
||||
return imageQualityReady;
|
||||
}
|
||||
|
||||
/** 返回图像质量模块初始化返回码,用于终端诊断授权状态。 */
|
||||
public synchronized int getImageQualityInitCode() {
|
||||
return imageQualityInitCode;
|
||||
}
|
||||
|
||||
public static FaceServer getInstance() {
|
||||
if (faceServer == null) {
|
||||
synchronized (FaceServer.class) {
|
||||
@@ -100,6 +131,8 @@ public class FaceServer {
|
||||
faceEngine = null;
|
||||
}
|
||||
engineInitCode = -99;
|
||||
imageQualityInitCode = -99;
|
||||
imageQualityReady = false;
|
||||
faceServer = null;
|
||||
}
|
||||
|
||||
@@ -302,10 +335,23 @@ public class FaceServer {
|
||||
}
|
||||
|
||||
|
||||
/** 保持旧调用方返回特征字节的兼容入口。 */
|
||||
public synchronized byte[] registerBgr24(Context context, byte[] bgr24, int width, int height, String name) {
|
||||
return registerBgr24WithQuality(context, bgr24, width, height, name).featureData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在统一解码结果上同时执行图像质量检测和虹软特征提取。
|
||||
*
|
||||
* <p>质量模块不可用或检测失败时仅记录failed状态,仍继续原有特征提取,兼容免费版授权。</p>
|
||||
*/
|
||||
public synchronized QualityFeatureResult registerBgr24WithQuality(Context context, byte[] bgr24, int width, int height, String name) {
|
||||
QualityFeatureResult qualityResult = new QualityFeatureResult();
|
||||
if (faceEngine == null || context == null || bgr24 == null || width % 4 != 0 || bgr24.length != width * height * 3) {
|
||||
Log.e(TAG, "registerBgr24: invalid params");
|
||||
return null;
|
||||
qualityResult.qualityStatus = "failed";
|
||||
qualityResult.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM;
|
||||
return qualityResult;
|
||||
}
|
||||
//人脸检测
|
||||
List<FaceInfo> faceInfoList = new ArrayList<>();
|
||||
@@ -314,6 +360,25 @@ public class FaceServer {
|
||||
code = faceEngine.detectFaces(bgr24, width, height, FaceEngine.CP_PAF_BGR24, faceInfoList);
|
||||
}
|
||||
if (code == ErrorInfo.MOK && !faceInfoList.isEmpty()) {
|
||||
if (imageQualityReady) {
|
||||
ImageQualitySimilar qualitySimilar = new ImageQualitySimilar();
|
||||
int qualityCode;
|
||||
synchronized (faceEngine) {
|
||||
qualityCode = faceEngine.imageQualityDetect(
|
||||
bgr24, width, height, FaceEngine.CP_PAF_BGR24,
|
||||
faceInfoList.get(0), MaskInfo.NOT_WORN, qualitySimilar);
|
||||
}
|
||||
if (qualityCode == ErrorInfo.MOK) {
|
||||
qualityResult.qualityStatus = "detected";
|
||||
qualityResult.qualityScore = (double) qualitySimilar.getScore();
|
||||
} else {
|
||||
qualityResult.qualityStatus = "failed";
|
||||
qualityResult.qualityErrorCode = qualityCode;
|
||||
}
|
||||
} else {
|
||||
qualityResult.qualityStatus = "failed";
|
||||
qualityResult.qualityErrorCode = imageQualityInitCode;
|
||||
}
|
||||
code = faceEngine.process(bgr24, width, height, FaceEngine.CP_PAF_BGR24, faceInfoList,
|
||||
FaceEngine.ASF_MASK_DETECT);
|
||||
if (code == ErrorInfo.MOK) {
|
||||
@@ -326,16 +391,18 @@ public class FaceServer {
|
||||
* 注册照要求不戴口罩
|
||||
*/
|
||||
Log.e(TAG, "registerBgr24: maskInfo is worn");
|
||||
return null;
|
||||
return qualityResult;
|
||||
}
|
||||
if (isMask == MaskInfo.UNKNOWN) {
|
||||
Log.e(TAG, "registerBgr24: maskInfo is unknown");
|
||||
return null;
|
||||
return qualityResult;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "registerBgr24 mask process fail res=" + code);
|
||||
return null;
|
||||
qualityResult.qualityStatus = "failed";
|
||||
qualityResult.qualityErrorCode = code;
|
||||
return qualityResult;
|
||||
}
|
||||
FaceFeature faceFeature = new FaceFeature();
|
||||
/*
|
||||
@@ -352,7 +419,9 @@ public class FaceServer {
|
||||
Rect cropRect = getBestRect(width, height, faceInfoList.get(0).getRect());
|
||||
if (cropRect == null) {
|
||||
Log.e(TAG, "registerBgr24: cropRect is null");
|
||||
return null;
|
||||
qualityResult.qualityStatus = "failed";
|
||||
qualityResult.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM;
|
||||
return qualityResult;
|
||||
}
|
||||
cropRect.left &= ~3;
|
||||
cropRect.top &= ~3;
|
||||
@@ -361,25 +430,83 @@ public class FaceServer {
|
||||
String imgPath = getImagePath(userName, context);
|
||||
// 创建一个头像的Bitmap,存放旋转结果图
|
||||
Bitmap headBmp = getHeadImage(bgr24, width, height, faceInfoList.get(0).getOrient(), cropRect, ArcSoftImageFormat.BGR24);
|
||||
try {
|
||||
FileOutputStream fos = new FileOutputStream(imgPath);
|
||||
try (FileOutputStream fos = new FileOutputStream(imgPath)) {
|
||||
headBmp.compress(Bitmap.CompressFormat.JPEG, 100, fos);
|
||||
fos.close();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
return null;
|
||||
Log.e(TAG, "registerBgr24: save head image failed", e);
|
||||
return qualityResult;
|
||||
} finally {
|
||||
if (!headBmp.isRecycled()) headBmp.recycle();
|
||||
}
|
||||
return faceFeature.getFeatureData();
|
||||
qualityResult.featureData = faceFeature.getFeatureData();
|
||||
return qualityResult;
|
||||
} else {
|
||||
Log.e(TAG, "registerBgr24: extract face feature failed, code is " + code);
|
||||
return null;
|
||||
return qualityResult;
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "registerBgr24: no face detected, code is " + code);
|
||||
return null;
|
||||
qualityResult.qualityStatus = "failed";
|
||||
qualityResult.qualityErrorCode = code == ErrorInfo.MOK
|
||||
? ErrorInfo.MERR_FSDK_FACEFEATURE_NO_FACE
|
||||
: code;
|
||||
return qualityResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅检测BGR24在库照片的质量,不提取或写入人脸特征。
|
||||
*
|
||||
* @param bgr24 BGR24图片数据
|
||||
* @param width 图片宽度
|
||||
* @param height 图片高度
|
||||
* @return detected或failed质量结果
|
||||
*/
|
||||
public synchronized QualityFeatureResult detectBgr24Quality(byte[] bgr24, int width, int height) {
|
||||
QualityFeatureResult result = new QualityFeatureResult();
|
||||
if (faceEngine == null || bgr24 == null || width % 4 != 0 || bgr24.length != width * height * 3) {
|
||||
result.qualityStatus = "failed";
|
||||
result.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM;
|
||||
return result;
|
||||
}
|
||||
List<FaceInfo> faceInfoList = new ArrayList<>();
|
||||
int detectCode = faceEngine.detectFaces(bgr24, width, height, FaceEngine.CP_PAF_BGR24, faceInfoList);
|
||||
if (detectCode != ErrorInfo.MOK || faceInfoList.isEmpty()) {
|
||||
result.qualityStatus = "failed";
|
||||
result.qualityErrorCode = detectCode == ErrorInfo.MOK ? ErrorInfo.MERR_FSDK_FACEFEATURE_NO_FACE : detectCode;
|
||||
return result;
|
||||
}
|
||||
if (!imageQualityReady) {
|
||||
result.qualityStatus = "failed";
|
||||
result.qualityErrorCode = imageQualityInitCode;
|
||||
return result;
|
||||
}
|
||||
ImageQualitySimilar qualitySimilar = new ImageQualitySimilar();
|
||||
int qualityCode = faceEngine.imageQualityDetect(
|
||||
bgr24, width, height, FaceEngine.CP_PAF_BGR24,
|
||||
faceInfoList.get(0), MaskInfo.NOT_WORN, qualitySimilar);
|
||||
if (qualityCode == ErrorInfo.MOK) {
|
||||
result.qualityStatus = "detected";
|
||||
result.qualityScore = (double) qualitySimilar.getScore();
|
||||
} else {
|
||||
result.qualityStatus = "failed";
|
||||
result.qualityErrorCode = qualityCode;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 原生图片特征与质量诊断的组合结果。 */
|
||||
public static class QualityFeatureResult {
|
||||
/** 注册特征字节,提取失败时为空。 */
|
||||
public byte[] featureData;
|
||||
/** 质量状态,稳定值为detected或failed。 */
|
||||
public String qualityStatus = "pending";
|
||||
/** 检测成功时的质量分。 */
|
||||
public Double qualityScore;
|
||||
/** 检测失败时的错误码。 */
|
||||
public Integer qualityErrorCode;
|
||||
}
|
||||
|
||||
private String getImagePath(String name, Context context) {
|
||||
if (imageRootPath == null) {
|
||||
imageRootPath = getImageDir(context);
|
||||
|
||||
Reference in new issue
Block a user