diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java index 0f1f5d4..94e215a 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java @@ -250,6 +250,32 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me case "hasCamera": result.success(sessionManager.hasCamera()); break; + case "getCameraChoices": + // 探测会短暂打开每个摄像头,必须串行进入人脸执行器且不占用主线程。 + FACE_EXECUTOR.execute(() -> { + try { + Map choices = FlutterTool.getCameraChoices(context); + MAIN_HANDLER.post(() -> result.success(choices)); + } catch (Exception e) { + Log.e(TAG, "读取摄像头列表失败:" + e.getMessage(), e); + MAIN_HANDLER.post(() -> result.error("CAMERA_CHOICES_FAILED", "读取摄像头列表失败", null)); + } + }); + break; + case "setCameraSelection": + Number selectedCameraId = call.argument("cameraId"); + if (selectedCameraId == null) { + result.error("INVALID_CAMERA_SELECTION", "缺少摄像头编号", null); + break; + } + FlutterTool.setCameraSelection(context, selectedCameraId.intValue()); + // 空闲时立即释放热相机让下次识别按新编号打开;识别中延迟到会话结束。 + sessionManager.onCameraSelectionChanged(); + result.success(true); + break; + case "getCameraSelection": + result.success(FlutterTool.getCameraSelection(context)); + break; case "cleanupOrderEvidenceFiles": List rawReferencedPaths = call.argument("referencedPaths"); Number olderThan = call.argument("olderThan"); @@ -354,7 +380,10 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me String activeKey = stringArgument(call, "activeKey"); String sdkKey = stringArgument(call, "sdkKey"); if (FlutterTool.isRegistrationInProgress()) { - result.success(FlutterTool.getFaceStatusWhileRegistering(context, appId, activeKey, sdkKey)); + Log.i(TAG, "人脸状态查询遇到互斥占用owner:" + FlutterTool.getRegistrationOwner() + ";"); + Map busyStatus = FlutterTool.getFaceStatusWhileRegistering(context, appId, activeKey, sdkKey); + busyStatus.put("registrationOwner", FlutterTool.getRegistrationOwner()); + result.success(busyStatus); return; } FACE_EXECUTOR.execute(() -> { @@ -369,7 +398,7 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me } private void startRegistrationTask(String userId, MethodChannel.Result result, boolean legacyResult) { - if (!FlutterTool.tryStartRegistration()) { + if (!FlutterTool.tryStartRegistration("rebuildFaceLibrary")) { result.error("REGISTRATION_IN_PROGRESS", "人脸库正在注册,请稍候", null); return; } @@ -399,7 +428,7 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me * @param result Flutter MethodChannel 异步结果 */ private void startFaceFeatureExtraction(String filePath, MethodChannel.Result result) { - if (!FlutterTool.tryStartRegistration()) { + if (!FlutterTool.tryStartRegistration("extractFaceFeature")) { result.error("REGISTRATION_IN_PROGRESS", "人脸库正在更新,请稍候", null); return; } @@ -427,7 +456,7 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me result.error("INVALID_QUALITY_AUDIT_ITEMS", "质量检测参数无效", null); return; } - if (!FlutterTool.tryStartRegistration()) { + if (!FlutterTool.tryStartRegistration("auditFaceQuality")) { result.error("REGISTRATION_IN_PROGRESS", "人脸库正在更新,请稍候", null); return; } @@ -452,7 +481,7 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me * @param result Flutter MethodChannel 异步结果 */ private void startFaceDelta(MethodCall call, MethodChannel.Result result) { - if (!FlutterTool.tryStartRegistration()) { + if (!FlutterTool.tryStartRegistration("applyFaceDelta")) { result.error("REGISTRATION_IN_PROGRESS", "人脸库正在更新,请稍候", null); return; } diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java index d4560a3..aa892b7 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java @@ -34,6 +34,8 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import io.flutter.plugins.facediscern.flutter.config.Constants; +import io.flutter.plugins.facediscern.tool.camera.CameraColorProbe; +import io.flutter.plugins.facediscern.tool.camera.config.CameraSelectionStore; import io.flutter.plugins.facediscern.tool.face.model.FaceEntity; import io.flutter.plugins.facediscern.tool.face.servcie.FaceServer; import io.flutter.plugins.facediscern.tool.file.FileUtil; @@ -150,6 +152,50 @@ public class FlutterTool { return REGISTRATION_IN_PROGRESS.get(); } + // 枚举本机摄像头并逐一探测彩色/黑白输出,供设置页人工选择识别摄像头。 + // 只能在 FACE_EXECUTOR 上调用:探测会短暂打开摄像头,不能与人脸会话或主线程并发。 + static Map getCameraChoices(Context context) { + Map result = new HashMap<>(); + List> choices = new ArrayList<>(); + int selectedId = CameraSelectionStore.loadRgbCameraId(context); + result.put("selectedCameraId", selectedId); + try { + int cameraCount = android.hardware.Camera.getNumberOfCameras(); + android.hardware.Camera.CameraInfo info = new android.hardware.Camera.CameraInfo(); + for (int id = 0; id < cameraCount; id++) { + android.hardware.Camera.getCameraInfo(id, info); + Map choice = new HashMap<>(); + choice.put("cameraId", id); + choice.put("facing", info.facing == android.hardware.Camera.CameraInfo.CAMERA_FACING_FRONT + ? "front" : "back"); + choice.put("colorKind", CameraColorProbe.probeColorKind(id)); + choices.add(choice); + } + } catch (Exception e) { + Log.e(TAG, "枚举摄像头失败error:" + e.getMessage(), e); + } + result.put("choices", choices); + return result; + } + + // 保存人工选择的识别摄像头;负数归一化为自动。持久化由调用方通知会话管理器失效热相机。 + static void setCameraSelection(Context context, int cameraId) { + CameraSelectionStore.saveRgbCameraId(context, cameraId); + Log.i(TAG, "人工选择识别摄像头cameraId:" + cameraId + ";"); + } + + // 只读当前人工选择,不探测摄像头;供设置页轻量回显。 + static int getCameraSelection(Context context) { + return CameraSelectionStore.loadRgbCameraId(context); + } + + // 最近一次占用互斥锁的操作名,仅用于诊断日志;锁空闲时可能残留上一次的值。 + private static volatile String REGISTRATION_OWNER = ""; + + static String getRegistrationOwner() { + return REGISTRATION_OWNER; + } + // 注册/特征提取/质量审计占线期间的状态查询:只读授权文件与最新进度缓存, // 不触碰 FaceServer 锁也不查数据库,供主线程即时返回,避免在单线程执行器里 // 排队等待整库任务完成后才能应答。 @@ -182,8 +228,10 @@ public class FlutterTool { return status; } - static boolean tryStartRegistration() { - return REGISTRATION_IN_PROGRESS.compareAndSet(false, true); + static boolean tryStartRegistration(String owner) { + boolean acquired = REGISTRATION_IN_PROGRESS.compareAndSet(false, true); + if (acquired) REGISTRATION_OWNER = owner == null ? "" : owner; + return acquired; } static void finishRegistration() { diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java b/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java index 9b9e1be..9139c3b 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java @@ -35,6 +35,7 @@ import io.flutter.plugins.facediscern.tool.face.model.FacePreviewInfo; import io.flutter.plugins.facediscern.tool.face.model.RecognitionFrameSnapshot; import io.flutter.plugins.facediscern.tool.face.constants.RequestFeatureStatus; import io.flutter.plugins.facediscern.tool.face.constants.RequestLivenessStatus; +import io.flutter.plugins.facediscern.tool.camera.config.CameraSelectionStore; import io.flutter.plugins.facediscern.tool.camera.config.PreviewConfig; import io.flutter.plugins.facediscern.ui.viewmodel.FaceDiscernViewModel; import io.flutter.view.TextureRegistry; @@ -375,6 +376,17 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba if (releaseNow) releaseResources(true, true); } + /** + * 人工切换识别摄像头后让下次识别按新编号开相机;活动识别延迟到当前顾客结束。 + */ + public void onCameraSelectionChanged() { + boolean releaseNow; + synchronized (sessionLock) { + releaseNow = activeSessionId == null; + } + if (releaseNow) releaseResources(true, true); + } + public void releaseForLowMemory() { synchronized (sessionLock) { if (activeSessionId != null) { @@ -1064,8 +1076,15 @@ public final class FaceRecognitionSessionManager implements Camera.PreviewCallba @SuppressWarnings("deprecation") private int findRgbCameraId() { - int fallback = -1; int cameraCount = Camera.getNumberOfCameras(); + // 双目终端存在厂家把彩色/红外排线装反的情况,facing 枚举角色不可信; + // 设置页人工指定的摄像头编号永远优先。 + int manualId = CameraSelectionStore.loadRgbCameraId(context); + if (manualId >= 0 && manualId < cameraCount) { + Log.i(TAG, "使用人工选择的识别摄像头 cameraId=" + manualId + ";cameraCount=" + cameraCount); + return manualId; + } + int fallback = -1; Camera.CameraInfo info = new Camera.CameraInfo(); for (int i = 0; i < cameraCount; i++) { Camera.getCameraInfo(i, info); diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/CameraColorProbe.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/CameraColorProbe.java new file mode 100644 index 0000000..9ffdeec --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/CameraColorProbe.java @@ -0,0 +1,138 @@ +package io.flutter.plugins.facediscern.tool.camera; + +import android.graphics.ImageFormat; +import android.graphics.SurfaceTexture; +import android.hardware.Camera; +import android.util.Log; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/** + * 通过预览帧色度判断单目摄像头输出彩色还是黑白(红外)画面。 + * + * 元数据(facing/朝向)无法识别厂家排线装反的双目设备,唯一可靠的区分信号在 + * 像素层:红外/黑白传感器的 NV21 色度平面(U/V 交错字节)恒为中性值 128, + * 彩色传感器对任何真实场景都会出现明显色度偏移。探测结果只作为设置页的 + * 人工选择参考标签,不作为自动切换依据。 + */ +public final class CameraColorProbe { + private static final String TAG = "CameraHelper"; + + /** 判定为彩色的每字节平均色度偏移阈值;真实场景彩色画面通常远高于该值。 */ + private static final double COLOR_CHROMA_THRESHOLD = 2.0; + + /** 单摄像头最多采集的帧数,取各帧中的最大色度偏移作为判定值。 */ + private static final int MAX_FRAMES = 6; + + /** 整个探测(含开相机)的硬超时,超时按未知处理,不阻塞调用方。 */ + private static final long PROBE_TIMEOUT_MS = 2500; + + private CameraColorProbe() { + } + + public static final String KIND_COLOR = "color"; + public static final String KIND_MONO = "mono"; + public static final String KIND_UNKNOWN = "unknown"; + + /** + * 打开指定摄像头采集若干 NV21 帧并统计色度偏移。 + * + * @return color=彩色;mono=黑白/红外;unknown=相机被占用或超时无法判定 + */ + @SuppressWarnings("deprecation") + public static String probeColorKind(int cameraId) { + Camera camera = null; + try { + camera = Camera.open(cameraId); + Camera.Parameters parameters = camera.getParameters(); + parameters.setPreviewFormat(ImageFormat.NV21); + Camera.Size size = chooseSmallestSize(parameters.getSupportedPreviewSizes()); + if (size != null) { + parameters.setPreviewSize(size.width, size.height); + } + camera.setParameters(parameters); + // 无渲染目标时使用离屏纹理承载预览,仅消费回调帧。 + camera.setPreviewTexture(new SurfaceTexture(0)); + + final CountDownLatch latch = new CountDownLatch(MAX_FRAMES); + final AtomicReference maxMetric = new AtomicReference<>(0.0); + final AtomicInteger receivedFrames = new AtomicInteger(0); + camera.setPreviewCallback((nv21, callbackCamera) -> { + if (nv21 == null || nv21.length == 0) { + latch.countDown(); + return; + } + double metric = chromaDeviation(nv21); + double previous = maxMetric.get(); + if (metric > previous) maxMetric.set(metric); + receivedFrames.incrementAndGet(); + latch.countDown(); + }); + camera.startPreview(); + boolean completed = latch.await(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS); + double metric = maxMetric.get(); + int frames = receivedFrames.get(); + String kind = frames == 0 + ? KIND_UNKNOWN + : (metric >= COLOR_CHROMA_THRESHOLD ? KIND_COLOR : KIND_MONO); + Log.i(TAG, "摄像头色度探测cameraId=" + cameraId + ";kind=" + kind + + ";chroma=" + String.format("%.2f", metric) + + ";frames=" + frames + ";timeout=" + !completed); + return kind; + } catch (Throwable error) { + Log.w(TAG, "摄像头色度探测失败cameraId=" + cameraId + ";error=" + error.getMessage()); + return KIND_UNKNOWN; + } finally { + if (camera != null) { + try { + camera.setPreviewCallback(null); + camera.stopPreview(); + } catch (Exception ignore) { + } + try { + camera.release(); + } catch (Exception ignore) { + } + } + } + } + + /** NV21 色度平面采样:每个采样字节相对中性值 128 的平均偏移,单色输出恒接近 0。 */ + static double chromaDeviation(byte[] nv21) { + int lumaEnd = estimateLumaEnd(nv21.length); + int chromaLength = nv21.length - lumaEnd; + if (chromaLength <= 0) return 0; + int step = Math.max(1, chromaLength / 4096); + long total = 0; + int samples = 0; + for (int index = lumaEnd; index < nv21.length; index += step) { + total += Math.abs((nv21[index] & 0xFF) - 128); + samples++; + } + return samples == 0 ? 0 : total / (double) samples; + } + + /** + * 常见 Camera1 NV21 布局为 width*height 的 Y 平面后跟 VU 交错的色度平面, + * 总长为亮度区的 3/2;无法拿到精确分辨率时按该比例估算亮度区结束位置。 + */ + static int estimateLumaEnd(int bufferLength) { + return bufferLength * 2 / 3; + } + + @SuppressWarnings("deprecation") + private static Camera.Size chooseSmallestSize(List sizes) { + if (sizes == null || sizes.isEmpty()) return null; + Camera.Size smallest = sizes.get(0); + for (Camera.Size size : sizes) { + if (size.width * size.height < smallest.width * smallest.height) { + smallest = size; + } + } + return smallest; + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/config/CameraSelectionStore.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/config/CameraSelectionStore.java new file mode 100644 index 0000000..e51eba8 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/camera/config/CameraSelectionStore.java @@ -0,0 +1,41 @@ +package io.flutter.plugins.facediscern.tool.camera.config; + +import android.content.Context; +import android.content.SharedPreferences; + +/** + * 保存终端本机的彩色识别摄像头人工选择。 + * + * 双目人脸设备的彩色/红外摄像头只靠 facing 枚举角色区分,厂家排线装反时 + * 枚举为 BACK 的摄像头实际输出红外黑白画面,元数据层面无法判定,因此提供 + * 人工指定入口。-1 表示自动(维持按 facing 枚举选择的既有行为)。 + */ +public final class CameraSelectionStore { + private static final String PREFERENCES_NAME = "face_camera_selection"; + private static final String KEY_RGB_CAMERA_ID = "rgb_camera_id"; + public static final int AUTO_CAMERA_ID = -1; + + private CameraSelectionStore() { + } + + /** 返回人工选择的彩色摄像头编号;-1 表示未指定,按设备枚举自动选择。 */ + public static int loadRgbCameraId(Context context) { + try { + return preferences(context).getInt(KEY_RGB_CAMERA_ID, AUTO_CAMERA_ID); + } catch (Exception error) { + return AUTO_CAMERA_ID; + } + } + + /** 写入人工选择;负数一律归一化为自动。 */ + public static void saveRgbCameraId(Context context, int cameraId) { + preferences(context) + .edit() + .putInt(KEY_RGB_CAMERA_ID, cameraId < 0 ? AUTO_CAMERA_ID : cameraId) + .apply(); + } + + private static SharedPreferences preferences(Context context) { + return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE); + } +} diff --git a/lib/common/models/plugs/face_discern_models.dart b/lib/common/models/plugs/face_discern_models.dart index a2d8cac..2c63753 100644 --- a/lib/common/models/plugs/face_discern_models.dart +++ b/lib/common/models/plugs/face_discern_models.dart @@ -766,3 +766,66 @@ String? _asNullableString(dynamic value) { final result = value?.toString(); return result == null || result.isEmpty ? null : result; } + +/// 单个终端摄像头的枚举信息;[colorKind] 来自预览帧色度探测,仅作人工选择参考。 +class TerminalCameraChoice { + const TerminalCameraChoice({ + required this.cameraId, + required this.facing, + required this.colorKind, + }); + + /// Android Camera1 摄像头编号。 + final int cameraId; + + /// facing 枚举角色:front 或 back;双目设备该值不可靠。 + final String facing; + + /// color=彩色;mono=黑白/红外;unknown=相机被占用或探测超时。 + final String colorKind; + + /// 供设置页展示的探测标签。 + String get colorKindLabel => switch (colorKind) { + 'color' => '彩色', + 'mono' => '黑白(红外)', + _ => '探测不可用', + }; + + /// 供设置页展示的朝向标签。 + String get facingLabel => facing == 'front' ? '前置角色' : '后置角色'; + + factory TerminalCameraChoice.fromMap(Map map) { + return TerminalCameraChoice( + cameraId: _asInt(map['cameraId']), + facing: _asString(map['facing'], 'back'), + colorKind: _asString(map['colorKind'], 'unknown'), + ); + } +} + +/// 设置页读取的摄像头选择快照:当前人工选择(-1 为自动)加全部可选摄像头。 +class TerminalCameraSelection { + const TerminalCameraSelection({ + required this.selectedCameraId, + required this.choices, + }); + + /// 人工选择的识别摄像头编号;-1 表示按设备枚举自动选择。 + final int selectedCameraId; + + /// 全部可枚举摄像头及其色度探测结果。 + final List choices; + + factory TerminalCameraSelection.fromMap(Map map) { + final rawChoices = map['choices']; + return TerminalCameraSelection( + selectedCameraId: _asIntOr(map['selectedCameraId'], -1), + choices: rawChoices is List + ? rawChoices + .whereType() + .map(TerminalCameraChoice.fromMap) + .toList(growable: false) + : const [], + ); + } +} diff --git a/lib/common/utils/plugs/face_discern_plugs.dart b/lib/common/utils/plugs/face_discern_plugs.dart index 83cec4b..cb55c62 100644 --- a/lib/common/utils/plugs/face_discern_plugs.dart +++ b/lib/common/utils/plugs/face_discern_plugs.dart @@ -142,6 +142,25 @@ abstract class FaceDiscernPlugs { return _invokeRecognizeConfiguration('resetRecognizeConfiguration'); } + /// 枚举终端摄像头并逐一做预览帧色度探测;探测需短暂打开摄像头,耗时数秒。 + static Future getCameraChoices() async { + final map = await _channel.invokeMethod('getCameraChoices'); + if (map is! Map) throw StateError('原生未返回有效的摄像头列表'); + return TerminalCameraSelection.fromMap(map); + } + + /// 保存人工选择的识别摄像头;-1 恢复自动选择。空闲时立即生效,识别中延迟到会话结束。 + static Future setCameraSelection(int cameraId) async { + await _channel.invokeMethod('setCameraSelection', { + 'cameraId': cameraId, + }); + } + + /// 只读当前人工选择的识别摄像头编号;-1 表示自动,不触发摄像头探测。 + static Future getCameraSelection() async { + return await _channel.invokeMethod('getCameraSelection') ?? -1; + } + static Stream get sessionStream { _sessionStream ??= _sessionEventChannel .receiveBroadcastStream() diff --git a/lib/common/widgets/order/select_pay_type_dialog.dart b/lib/common/widgets/order/select_pay_type_dialog.dart index ba81ab5..a228dd4 100644 --- a/lib/common/widgets/order/select_pay_type_dialog.dart +++ b/lib/common/widgets/order/select_pay_type_dialog.dart @@ -665,10 +665,22 @@ class SelectPayTypeDialogController extends GetxController { String msg = "人脸数据库为空,请前往'设置'->'人脸管理'点击同步人脸"; throw Exception(msg); } - final status = await AppFaceTool.getStatus(); + // 注册占用通常是暂态的:整库重建或识别失效触发的增量同步都会短暂持有 + // 人脸互斥锁。直接拒绝会让收银员在连续两单之间撞上“正在注册”, + // 这里等待其结束后自动继续,只有超时才提示稍后再试。 + var status = await AppFaceTool.getStatus(); if (!_ownsFaceDispatch(requestId)) return; + var waitRound = 0; + while (status?.registrationInProgress == true && waitRound < 8) { + faceWorkspaceMessage.value = waitRound == 0 ? '人脸数据正在同步,请稍候' : '人脸数据正在同步,请稍候(${waitRound * 2}秒)'; + await Future.delayed(const Duration(seconds: 2)); + if (!_ownsFaceDispatch(requestId)) return; + status = await AppFaceTool.getStatus(); + if (!_ownsFaceDispatch(requestId)) return; + waitRound++; + } if (status?.registrationInProgress == true) { - String msg = "人脸库正在注册,请稍候再发起人脸支付"; + String msg = "人脸库正在更新,请稍候再发起人脸支付"; throw Exception(msg); } if (status?.activated != true) { diff --git a/lib/pages/setting/widgets/setting_baseInfo.dart b/lib/pages/setting/widgets/setting_baseInfo.dart index ad5ae06..807cf77 100644 --- a/lib/pages/setting/widgets/setting_baseInfo.dart +++ b/lib/pages/setting/widgets/setting_baseInfo.dart @@ -201,6 +201,7 @@ Widget settingBaseInfo(BuildContext context) { ? 'Android 虚拟机' : 'Android 真实设备', ), + const _CameraSelectionRow(), SettingInfoRow( icon: FAssets.icons.smartphone, label: 'Android 版本', @@ -533,3 +534,236 @@ String _maskIdentifier(String? value) { final suffixLength = text.length >= 16 ? 4 : 3; return '${text.substring(0, prefixLength)}••••••••${text.substring(text.length - suffixLength)}'; } + +/// 识别摄像头人工选择行:双目终端排线装反时元数据无法判定彩色/红外, +/// 行内只轻量回显当前选择,帧色度探测在选择弹窗打开时才执行。 +class _CameraSelectionRow extends StatefulWidget { + const _CameraSelectionRow(); + + @override + State<_CameraSelectionRow> createState() => _CameraSelectionRowState(); +} + +class _CameraSelectionRowState extends State<_CameraSelectionRow> { + /// 当前人工选择;null 表示尚未读取完成。 + int? _selectedCameraId; + + @override + void initState() { + super.initState(); + _loadSelection(); + } + + Future _loadSelection() async { + try { + final id = await FaceDiscernPlugs.getCameraSelection(); + if (mounted) setState(() => _selectedCameraId = id); + } catch (error, stack) { + logger.w('|基本设置|读取摄像头选择失败error:|$error|stack:|$stack|'); + } + } + + String get _valueLabel { + final id = _selectedCameraId; + if (id == null) return '读取中'; + return id < 0 ? '自动' : '摄像头 $id'; + } + + Future _openSelectionDialog() async { + await showAppDialog( + context: context, + builder: (dialogContext) => _CameraSelectionDialog( + onChanged: _loadSelection, + ), + ); + } + + @override + Widget build(BuildContext context) { + return SettingInfoRow( + icon: FAssets.icons.webcam, + label: '识别摄像头', + subtitle: '预览为黑白画面时请改选另一个摄像头', + value: _valueLabel, + actionLabel: '选择', + onTap: _openSelectionDialog, + ); + } +} + +/// 识别摄像头选择弹窗:打开时先对每个摄像头做预览帧色度探测(数秒), +/// 用"彩色/黑白(红外)"标签辅助人工选择;探测不可用时仍可手动改选。 +class _CameraSelectionDialog extends StatefulWidget { + const _CameraSelectionDialog({required this.onChanged}); + + /// 选择保存成功后通知外层行刷新回显。 + final VoidCallback onChanged; + + @override + State<_CameraSelectionDialog> createState() => _CameraSelectionDialogState(); +} + +class _CameraSelectionDialogState extends State<_CameraSelectionDialog> { + TerminalCameraSelection? _selection; + String? _error; + + @override + void initState() { + super.initState(); + _loadChoices(); + } + + Future _loadChoices() async { + try { + final selection = await FaceDiscernPlugs.getCameraChoices(); + if (!mounted) return; + setState(() { + _selection = selection; + _error = null; + }); + } catch (error, stack) { + logger.e('|基本设置|探测摄像头失败error:|$error|stack:|$stack|'); + if (!mounted) return; + setState(() => _error = '摄像头探测失败,请稍后重试'); + } + } + + Future _select(int cameraId) async { + try { + await FaceDiscernPlugs.setCameraSelection(cameraId); + widget.onChanged(); + if (!mounted) return; + Navigator.of(context).pop(); + Toast.success(msg: cameraId < 0 ? '已恢复自动选择摄像头' : '已选择摄像头 $cameraId'); + } catch (error, stack) { + logger.e('|基本设置|保存摄像头选择失败error:|$error|stack:|$stack|'); + Toast.error(msg: '保存失败,请稍后重试'); + } + } + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final selection = _selection; + return AppDialog( + title: const Text('识别摄像头'), + width: 560.w, + content: selection == null + ? ConstrainedBox( + constraints: BoxConstraints(minHeight: 180.h), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + SizedBox(height: 14.h), + Text( + _error ?? '正在逐个探测摄像头,请稍候…', + style: theme.typography.base.copyWith( + color: _error == null ? XfjDesignTokens.muted : XfjDesignTokens.danger, + ), + ), + if (_error != null) ...[ + SizedBox(height: 14.h), + FButton( + style: FButtonStyle.outline, + label: const Text('重试'), + onPress: () { + setState(() => _error = null); + _loadChoices(); + }, + ), + ], + ], + ), + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + '双目终端可能存在彩色/红外排线装反,以探测标签为准选择彩色摄像头;' + '预览出现黑白画面时改选另一个即可。', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + height: 1.5, + ), + ), + SizedBox(height: 14.h), + _buildOption( + theme: theme, + title: '自动选择', + subtitle: '按设备枚举角色选择(默认)', + selected: selection.selectedCameraId < 0, + onTap: () => _select(-1), + ), + for (final choice in selection.choices) ...[ + SizedBox(height: 8.h), + _buildOption( + theme: theme, + title: '摄像头 ${choice.cameraId}', + subtitle: '${choice.facingLabel} · ${choice.colorKindLabel}', + selected: selection.selectedCameraId == choice.cameraId, + onTap: () => _select(choice.cameraId), + ), + ], + SizedBox(height: 14.h), + FButton( + style: FButtonStyle.outline, + label: const Text('取消'), + onPress: () => Navigator.of(context).pop(), + ), + ], + ), + ); + } + + Widget _buildOption({ + required FThemeData theme, + required String title, + required String subtitle, + required bool selected, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Container( + constraints: BoxConstraints(minHeight: 64.h), + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), + decoration: BoxDecoration( + color: selected ? XfjDesignTokens.primarySoft : XfjDesignTokens.surface, + border: Border.all( + color: selected + ? XfjDesignTokens.primary.withValues(alpha: 0.4) + : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + children: [ + [ + Text( + title, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 2.h), + Text( + subtitle, + style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), + if (selected) + FIcon( + FAssets.icons.check, + size: 22.w, + color: XfjDesignTokens.primary, + ), + ], + ), + ), + ); + } +}