diff --git a/README.md b/README.md index 6160aaf..9f8cc58 100644 --- a/README.md +++ b/README.md @@ -108,12 +108,178 @@ Here is a list of properties available to customize your widget: | lensControlIcon | Widget | use this to render a custom widget for camera lens control | | flashControlBuilder | FlashControlBuilder | use this to build custom widgets for flash control based on camera flash mode | | messageBuilder | MessageBuilder | use this to build custom messages based on face position | -| indicatorShape | IndicatorShape | use this to change the shape of the face indicator | +| indicatorShape | IndicatorShape | use this to change the shape of the face indicator (defaultShape, square, circle, triangle, triangleInverted, image, fixedFrame, none) | | indicatorAssetImage | String | use this to pass an asset image when IndicatorShape is set to image | | indicatorBuilder | IndicatorBuilder | use this to build custom widgets for the face indicator | | captureControlBuilder | CaptureControlBuilder | use this to build custom widgets for capture control | | autoDisableCaptureControl | bool | set true to disable capture control widget when no face is detected | +#### Fixed Frame Mode + +The `IndicatorShape.fixedFrame` option displays a centered, fixed-size frame (70% of screen) instead of following the detected face. This provides a better user experience for face capture with real-time positioning guidance: + +**Frame Colors:** +* **White/Gray Frame**: No face detected +* **Red Frame**: Face detected but requirements not met +* **Green Frame**: Face properly positioned, countdown starts + +**Detection Constraints:** + +For the frame to turn green and trigger auto-capture, the following conditions must ALL be met: + +1. **Face Distance** + - Eye distance must be between 0.14 - 0.28 (normalized Euclidean distance) + - Too close (> 0.28) → "Move back" + - Too far (< 0.14) → "Move closer" + +2. **Landmark Positioning** + - All 6 facial landmarks (both eyes, nose, 3 mouth points) must be inside the frame bounds (15% - 85% of screen) + - Provides guidance: "Move left", "Move right", "Move up", "Move down" + +3. **Face Centering** + - Nose must be near the center of the frame (40% - 60% range) + - Ensures face is not just inside, but properly centered + +4. **Head Orientation** + - Head rotation Y (left/right): ≤ 12 degrees + - Head rotation Z (tilt): ≤ 12 degrees + - User must face forward with head straight + +**Basic Usage:** + +```dart +FaceCameraController( + autoCapture: true, + captureDelay: 3, + defaultCameraLens: CameraLens.front, + showDebugLandmarks: false, + onCapture: (File? image) { + // Handle captured image + }, +) + +SmartFaceCamera( + controller: controller, + indicatorShape: IndicatorShape.fixedFrame, + messageBuilder: (context, face) { + if (controller.value.countdown != null) { + return _buildMessage('Hold still...'); + } + + final guidance = controller.getFacePositionGuidance(); + return _buildMessage(guidance ?? 'Ready'); + }, +) + +Widget _buildMessage(String message) => Padding( + padding: EdgeInsets.symmetric(horizontal: 55, vertical: 15), + child: Text( + message, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w400), + ), +); +``` + +**Customizing Guidance Messages:** + +You can fully customize the positioning guidance messages: + +```dart +messageBuilder: (context, face) { + // Show countdown message + if (controller.value.countdown != null) { + return _buildMessage('Hold still... ${controller.value.countdown}'); + } + + // Get default guidance + final guidance = controller.getFacePositionGuidance(); + + // Customize messages + String message; + switch (guidance) { + case 'Move closer': + message = 'Come closer to the camera'; + break; + case 'Move back': + message = 'Move away from the camera'; + break; + case 'Move left': + message = 'Move to your left'; + break; + case 'Move right': + message = 'Move to your right'; + break; + case 'Move up': + message = 'Lift your phone higher'; + break; + case 'Move down': + message = 'Lower your phone'; + break; + case 'Center your face': + message = 'Almost there, center your face'; + break; + case 'Place your face in the frame': + message = 'Position your face in the frame'; + break; + default: + message = 'Perfect! Capturing...'; + } + + return _buildMessage(message); +} +``` + +**Multi-language Support:** + +```dart +messageBuilder: (context, face) { + if (controller.value.countdown != null) { + return _buildMessage(AppLocalizations.of(context).holdStill); + } + + final guidance = controller.getFacePositionGuidance(); + final translations = { + 'Move closer': AppLocalizations.of(context).moveCloser, + 'Move back': AppLocalizations.of(context).moveBack, + 'Move left': AppLocalizations.of(context).moveLeft, + 'Move right': AppLocalizations.of(context).moveRight, + // ... more translations + }; + + return _buildMessage( + translations[guidance] ?? AppLocalizations.of(context).ready + ); +} +``` + +**Debug Mode:** + +Enable `showDebugLandmarks: true` in the controller to visualize facial landmarks with colored markers: +- Blue: Eyes +- Green: Nose +- Red: Mouth +- Yellow: Cheeks +- Purple: Ears +- Cyan: Face bounding box + +This mode is ideal for KYC, document verification, and any application requiring consistent, high-quality face capture. + +#### Capture Countdown + +When `autoCapture` is enabled, you can add a countdown delay before the photo is taken. This gives users time to prepare and ensures they're ready: + +```dart +FaceCameraController( + autoCapture: true, + captureDelay: 3, // 3-second countdown (default) + onCapture: (File? image) { + // Handle captured image + }, +) +``` + +The countdown is displayed as a large centered number (3, 2, 1) and only starts when the face is properly positioned. If the face moves out of position during countdown, the timer resets. Set `captureDelay: 0` for immediate capture without countdown. \ \ @@ -128,9 +294,11 @@ Here is a list of properties available to customize your widget from the control | defaultFlashMode | CameraFlashMode | use this to set initial flash mode | | enableAudio | bool | set false to disable capture sound | | autoCapture | bool | set true to capture image on face detected | +| captureDelay | int | countdown delay in seconds before auto-capture (default: 3, set 0 for immediate) | +| showDebugLandmarks | bool | set true to show colored markers for facial landmarks (eyes, nose, mouth, etc.) | | ignoreFacePositioning | bool | set true to trigger onCapture even when the face is not well positioned | | orientation | CameraOrientation | use this to lock camera orientation | -| performanceMode | FaceDetectorMode | Use this to set your preferred performance mode | +| performanceMode | FaceDetectorMode | use this to set your preferred performance mode | ### Contributions diff --git a/example/lib/main.dart b/example/lib/main.dart index 4cf5eb3..9ce41ed 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -28,7 +28,9 @@ class _MyAppState extends State { void initState() { controller = FaceCameraController( autoCapture: true, + captureDelay: 3, defaultCameraLens: CameraLens.front, + showDebugLandmarks: false, // Set true to see facial landmarks onCapture: (File? image) { setState(() => _capturedImage = image); }, @@ -74,13 +76,19 @@ class _MyAppState extends State { } return SmartFaceCamera( controller: controller, + indicatorShape: IndicatorShape.fixedFrame, messageBuilder: (context, face) { - if (face == null) { - return _message('Place your face in the camera'); + // Show countdown message + if (controller.value.countdown != null) { + return _message('Hold still...'); } - if (!face.wellPositioned) { - return _message('Center your face in the square'); + + // Get positioning guidance + final guidance = controller.getFacePositionGuidance(); + if (guidance != null) { + return _message(_customizeMessage(guidance)); } + return const SizedBox.shrink(); }); })), @@ -95,6 +103,27 @@ class _MyAppState extends State { fontSize: 14, height: 1.5, fontWeight: FontWeight.w400)), ); + String _customizeMessage(String guidance) { + // Customize guidance messages here + switch (guidance) { + case 'Move closer': + return 'Come closer to the camera'; + case 'Move back': + return 'Move away from the camera'; + case 'Move left': + case 'Move right': + case 'Move up': + case 'Move down': + return guidance; // Keep directional messages as-is + case 'Center your face': + return 'Almost there! Center your face'; + case 'Place your face in the frame': + return 'Position your face in the frame'; + default: + return guidance; + } + } + @override void dispose() { controller.dispose(); diff --git a/lib/src/controllers/face_camera_controller.dart b/lib/src/controllers/face_camera_controller.dart index 36a86b3..3ac84a2 100644 --- a/lib/src/controllers/face_camera_controller.dart +++ b/lib/src/controllers/face_camera_controller.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math'; import 'package:camera/camera.dart'; import 'package:flutter/widgets.dart'; @@ -22,9 +23,12 @@ class FaceCameraController extends ValueNotifier { this.ignoreFacePositioning = false, this.orientation = CameraOrientation.portraitUp, this.performanceMode = FaceDetectorMode.fast, + this.captureDelay = 3, + this.showDebugLandmarks = false, required this.onCapture, this.onFaceDetected, - }) : super(FaceCameraState.uninitialized()); + }) : assert(captureDelay >= 0, 'captureDelay must be non-negative'), + super(FaceCameraState.uninitialized()); /// The desired resolution for the camera. final ImageResolution imageResolution; @@ -50,6 +54,17 @@ class FaceCameraController extends ValueNotifier { /// Use this to set your preferred performance mode. final FaceDetectorMode performanceMode; + /// Countdown delay in seconds before auto-capture when face is well-positioned. + /// Only applies when autoCapture is true. Default is 3 seconds. + /// Set to 0 to capture immediately without countdown. + final int captureDelay; + + /// Set true to show debug markers for facial landmarks (eyes, nose, mouth, etc.) + final bool showDebugLandmarks; + + Timer? _countdownTimer; + int _currentCountdown = 0; + /// Callback invoked when camera captures image. final void Function(File? image) onCapture; @@ -167,6 +182,8 @@ class FaceCameraController extends ValueNotifier { if (cameraController == null || !cameraController.value.isInitialized) { return; } + // Clear capturing flag when starting/restarting image stream + value = value.copyWith(isCapturing: false); if (!cameraController.value.isStreamingImages) { await cameraController.startImageStream(_processImage); } @@ -199,13 +216,38 @@ class FaceCameraController extends ValueNotifier { if (result.face != null) { onFaceDetected?.call(result.face); } - if (autoCapture && - (result.wellPositioned || ignoreFacePositioning)) { - captureImage(); + + // Check if face landmarks are centered in frame + // Note: Image dimensions are swapped for portrait orientation + bool isFaceCentered = result.face != null && + _isFaceCentered( + result.face!, + Size(cameraImage.height.toDouble(), + cameraImage.width.toDouble())); + + // Update state with positioning status + value = value.copyWith(isFaceWellPositioned: isFaceCentered); + + // Require face to be detected, then check positioning (unless ignored) + bool shouldCapture = result.face != null && + (isFaceCentered || ignoreFacePositioning); + + // Don't start countdown if already capturing + if (autoCapture && shouldCapture && !value.isCapturing) { + _startCountdown(); + } else if (value.isCapturing) { + // If capturing, make sure countdown is cancelled + _cancelCountdown(); + } else { + _cancelCountdown(); } } catch (e) { logError(e.toString()); } + } else { + // No face detected - update state and cancel countdown + value = value.copyWith(isFaceWellPositioned: false); + _cancelCountdown(); } }); value = value.copyWith(alreadyCheckingImage: false); @@ -216,6 +258,247 @@ class FaceCameraController extends ValueNotifier { } } + void _startCountdown() { + // If countdown is 0, capture immediately + if (captureDelay == 0) { + captureImage(); + return; + } + + // If countdown already running, do nothing + if (_countdownTimer != null && _countdownTimer!.isActive) { + return; + } + + // Start new countdown + _currentCountdown = captureDelay; + value = value.copyWith(countdown: _currentCountdown); + + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + // Check if face is still detected and well-positioned + final detectedFace = value.detectedFace; + if (detectedFace == null || + detectedFace.face == null || + !value.isFaceWellPositioned) { + // Face moved or disappeared - cancel countdown + timer.cancel(); + value = value.copyWith(countdown: null); + _currentCountdown = 0; + return; + } + + _currentCountdown--; + if (_currentCountdown <= 0) { + timer.cancel(); + value = value.copyWith(countdown: null); + captureImage(); + } else { + value = value.copyWith(countdown: _currentCountdown); + } + }); + } + + void _cancelCountdown() { + if (_countdownTimer != null && _countdownTimer!.isActive) { + _countdownTimer!.cancel(); + _currentCountdown = 0; + value = value.copyWith(countdown: null); + } + } + + /// Get guidance for positioning the face in the fixed frame + /// Returns a message like "Move closer", "Move left", etc. + /// Returns null only when face is perfectly positioned (triggers countdown) + String? getFacePositionGuidance() { + final detectedFace = value.detectedFace; + if (detectedFace?.face == null) { + return "Place your face in the frame"; + } + + final face = detectedFace!.face!; + final cameraController = value.cameraController; + if (cameraController == null) return "Place your face in the frame"; + + final imageSize = Size( + cameraController.value.previewSize!.height, + cameraController.value.previewSize!.width, + ); + + // If already centered, no guidance needed (countdown will start) + if (_isFaceCentered(face, imageSize)) { + return null; + } + + // Check face distance by measuring eye separation + final leftEye = face.landmarks[FaceLandmarkType.leftEye]; + final rightEye = face.landmarks[FaceLandmarkType.rightEye]; + if (leftEye != null && rightEye != null) { + final leftEyeX = leftEye.position.x / imageSize.width; + final leftEyeY = leftEye.position.y / imageSize.height; + final rightEyeX = rightEye.position.x / imageSize.width; + final rightEyeY = rightEye.position.y / imageSize.height; + + // Calculate actual Euclidean distance (not squared) + final dx = leftEyeX - rightEyeX; + final dy = leftEyeY - rightEyeY; + final eyeDistance = sqrt(dx * dx + dy * dy); + + // If eyes are too close (distance < 0.14), face is too far + if (eyeDistance < 0.14) { + return "Move closer"; + } + + // If eyes are too far apart (distance > 0.28), face is too close + if (eyeDistance > 0.28) { + return "Move back"; + } + } + + // Calculate face metrics + final frameSize = 0.7; + final frameCenterX = 0.5; + final frameCenterY = 0.5; + + var faceLeft = (face.boundingBox.left / imageSize.width).clamp(0.0, 1.0); + var faceRight = (face.boundingBox.right / imageSize.width).clamp(0.0, 1.0); + var faceTop = (face.boundingBox.top / imageSize.height).clamp(0.0, 1.0); + var faceBottom = (face.boundingBox.bottom / imageSize.height).clamp(0.0, 1.0); + + final faceWidth = faceRight - faceLeft; + final faceCenterX = (faceLeft + faceRight) / 2; + final faceCenterY = (faceTop + faceBottom) / 2; + + // Priority 1: Distance (face too small or too large) + // If face is very small (width < 50% of frame), need to get closer + if (faceWidth < frameSize * 0.5) { + return "Move closer"; + } + + // If face is too large (width > 95% of frame), need to move back + if (faceWidth > frameSize * 0.95) { + return "Move back"; + } + + // Priority 2: Centering (if face is reasonable size but not centered) + final centerOffsetX = faceCenterX - frameCenterX; + final centerOffsetY = faceCenterY - frameCenterY; + + // Horizontal guidance (use threshold slightly less than strict requirement of 18%) + if (centerOffsetX.abs() > 0.15) { + // For front camera (mirrored display): + // If faceCenterX < frameCenterX (left side), user should move right + // If faceCenterX > frameCenterX (right side), user should move left + if (centerOffsetX < 0) { + return "Move right"; + } else { + return "Move left"; + } + } + + // Vertical guidance + if (centerOffsetY.abs() > 0.15) { + if (centerOffsetY < 0) { + return "Move down"; + } else { + return "Move up"; + } + } + + // If we get here, face is good size and reasonably centered, + // but not meeting the strict requirements yet + return "Center your face"; + } + + /// Check if face is well-positioned in the fixed frame + /// Returns true ONLY if ALL required facial landmarks are inside the frame + /// AND the nose is near the center AND the face is close enough + bool _isFaceCentered(Face face, Size imageSize) { + // Define the fixed frame size (70% of screen, centered) + final frameSize = 0.7; + final frameCenterX = 0.5; + final frameCenterY = 0.5; + + // Calculate fixed frame bounds (normalized 0-1) + final frameLeft = frameCenterX - (frameSize / 2); // 0.15 + final frameRight = frameCenterX + (frameSize / 2); // 0.85 + final frameTop = frameCenterY - (frameSize / 2); // 0.15 + final frameBottom = frameCenterY + (frameSize / 2); // 0.85 + + // First check: Face must be at correct distance (eyes not too close or too far) + final leftEye = face.landmarks[FaceLandmarkType.leftEye]; + final rightEye = face.landmarks[FaceLandmarkType.rightEye]; + if (leftEye != null && rightEye != null) { + final leftEyeX = leftEye.position.x / imageSize.width; + final leftEyeY = leftEye.position.y / imageSize.height; + final rightEyeX = rightEye.position.x / imageSize.width; + final rightEyeY = rightEye.position.y / imageSize.height; + + // Calculate actual Euclidean distance (not squared) + final dx = leftEyeX - rightEyeX; + final dy = leftEyeY - rightEyeY; + final eyeDistance = sqrt(dx * dx + dy * dy); + + // Face must be at correct distance (0.14 to 0.28) + if (eyeDistance < 0.14 || eyeDistance > 0.28) { + return false; + } + } + + // Check that ALL required landmarks are within the frame + final requiredLandmarks = [ + FaceLandmarkType.leftEye, + FaceLandmarkType.rightEye, + FaceLandmarkType.noseBase, + FaceLandmarkType.bottomMouth, + FaceLandmarkType.leftMouth, + FaceLandmarkType.rightMouth, + ]; + + for (final landmarkType in requiredLandmarks) { + final landmark = face.landmarks[landmarkType]; + if (landmark == null) { + return false; // Required landmark not detected + } + + // Convert landmark position to normalized coordinates (0-1) + final x = (landmark.position.x / imageSize.width).clamp(0.0, 1.0); + final y = (landmark.position.y / imageSize.height).clamp(0.0, 1.0); + + // Check if landmark is within frame bounds + if (x < frameLeft || x > frameRight || y < frameTop || y > frameBottom) { + return false; // Landmark is outside frame + } + } + + // Additional check: Nose must be near center (within 20% tolerance) + final noseLandmark = face.landmarks[FaceLandmarkType.noseBase]; + if (noseLandmark != null) { + final noseX = (noseLandmark.position.x / imageSize.width).clamp(0.0, 1.0); + final noseY = (noseLandmark.position.y / imageSize.height).clamp(0.0, 1.0); + + // Check if nose is within 20% of center (0.4 to 0.6 range) + final centerTolerance = 0.2; + if (noseX < (frameCenterX - centerTolerance) || + noseX > (frameCenterX + centerTolerance) || + noseY < (frameCenterY - centerTolerance) || + noseY > (frameCenterY + centerTolerance)) { + return false; // Nose is not near center + } + } + + // Check head rotation (must be facing forward) + if (face.headEulerAngleY != null && + (face.headEulerAngleY! > 12 || face.headEulerAngleY! < -12)) { + return false; // Head rotated left/right + } + if (face.headEulerAngleZ != null && + (face.headEulerAngleZ! > 12 || face.headEulerAngleZ! < -12)) { + return false; // Head tilted sideways + } + + return true; // All checks passed + } + @Deprecated('Use [captureImage]') void onTakePictureButtonPressed() async { captureImage(); @@ -224,6 +507,9 @@ class FaceCameraController extends ValueNotifier { void captureImage() async { final CameraController? cameraController = value.cameraController; try { + // Set capturing flag to prevent countdown from starting again + value = value.copyWith(isCapturing: true); + cameraController!.stopImageStream().whenComplete(() async { await Future.delayed(const Duration(milliseconds: 500)); takePicture().then((XFile? file) { @@ -269,6 +555,7 @@ class FaceCameraController extends ValueNotifier { /// Once the controller is disposed, it cannot be used anymore. @override Future dispose() async { + _cancelCountdown(); final CameraController? cameraController = value.cameraController; if (cameraController != null && cameraController.value.isInitialized) { diff --git a/lib/src/controllers/face_camera_state.dart b/lib/src/controllers/face_camera_state.dart index cdb4a11..fc55c7d 100644 --- a/lib/src/controllers/face_camera_state.dart +++ b/lib/src/controllers/face_camera_state.dart @@ -14,6 +14,9 @@ class FaceCameraState { required this.alreadyCheckingImage, this.cameraController, this.detectedFace, + this.countdown, + this.isCapturing = false, + this.isFaceWellPositioned = false, }); /// Create a new [FaceCameraState] instance that is uninitialized. @@ -26,6 +29,9 @@ class FaceCameraState { alreadyCheckingImage: false, cameraController: null, detectedFace: null, + countdown: null, + isCapturing: false, + isFaceWellPositioned: false, availableFlashMode: [ CameraFlashMode.off, CameraFlashMode.auto, @@ -62,6 +68,15 @@ class FaceCameraState { final DetectedFace? detectedFace; + /// Current countdown value (null if not counting down) + final int? countdown; + + /// Whether a capture is currently in progress + final bool isCapturing; + + /// Whether the face is well-positioned (meets all requirements) + final bool isFaceWellPositioned; + /// Create a copy of this state with the given parameters. FaceCameraState copyWith({ List? availableCameraLens, @@ -75,6 +90,9 @@ class FaceCameraState { CameraController? cameraController, List? availableFlashMode, DetectedFace? detectedFace, + Object? countdown = _undefinedCountdown, + bool? isCapturing, + bool? isFaceWellPositioned, }) { return FaceCameraState( availableCameraLens: availableCameraLens ?? this.availableCameraLens, @@ -85,6 +103,14 @@ class FaceCameraState { cameraController: cameraController ?? this.cameraController, availableFlashMode: availableFlashMode ?? this.availableFlashMode, detectedFace: detectedFace ?? this.detectedFace, + countdown: countdown == _undefinedCountdown + ? this.countdown + : countdown as int?, + isCapturing: isCapturing ?? this.isCapturing, + isFaceWellPositioned: isFaceWellPositioned ?? this.isFaceWellPositioned, ); } } + +/// Sentinel value to distinguish between "not provided" and "explicitly null" +const Object _undefinedCountdown = Object(); diff --git a/lib/src/handlers/face_identifier.dart b/lib/src/handlers/face_identifier.dart index d380636..c69d35f 100644 --- a/lib/src/handlers/face_identifier.dart +++ b/lib/src/handlers/face_identifier.dart @@ -116,20 +116,18 @@ class FaceIdentifier { // rect.add(face.boundingBox); detectedFace = face; - // Head is rotated to the right rotY degrees - if (face.headEulerAngleY! > 5 || face.headEulerAngleY! < -5) { + // Head is rotated to the right rotY degrees (balanced) + if (face.headEulerAngleY! > 12 || face.headEulerAngleY! < -12) { wellPositioned = false; } - // Head is tilted sideways rotZ degrees - if (face.headEulerAngleZ! > 5 || face.headEulerAngleZ! < -5) { + // Head is tilted sideways rotZ degrees (balanced) + if (face.headEulerAngleZ! > 12 || face.headEulerAngleZ! < -12) { wellPositioned = false; } - // If landmark detection was enabled with FaceDetectorOptions (mouth, ears, - // eyes, cheeks, and nose available): - final FaceLandmark? leftEar = face.landmarks[FaceLandmarkType.leftEar]; - final FaceLandmark? rightEar = face.landmarks[FaceLandmarkType.rightEar]; + // If landmark detection was enabled with FaceDetectorOptions (mouth, nose available) + // Note: Ears are often not visible in close-up face captures, so we don't require them final FaceLandmark? bottomMouth = face.landmarks[FaceLandmarkType.bottomMouth]; final FaceLandmark? rightMouth = @@ -137,23 +135,24 @@ class FaceIdentifier { final FaceLandmark? leftMouth = face.landmarks[FaceLandmarkType.leftMouth]; final FaceLandmark? noseBase = face.landmarks[FaceLandmarkType.noseBase]; - if (leftEar == null || - rightEar == null || + + // Only require nose and mouth landmarks (ears often not visible in close-up) + if (noseBase == null || bottomMouth == null || rightMouth == null || - leftMouth == null || - noseBase == null) { + leftMouth == null) { wellPositioned = false; } + // Eye open checks - balanced (blinking tolerance but not too loose) if (face.leftEyeOpenProbability != null) { - if (face.leftEyeOpenProbability! < 0.5) { + if (face.leftEyeOpenProbability! < 0.4) { wellPositioned = false; } } if (face.rightEyeOpenProbability != null) { - if (face.rightEyeOpenProbability! < 0.5) { + if (face.rightEyeOpenProbability! < 0.4) { wellPositioned = false; } } diff --git a/lib/src/paints/face_painter.dart b/lib/src/paints/face_painter.dart index 71494d1..b117027 100644 --- a/lib/src/paints/face_painter.dart +++ b/lib/src/paints/face_painter.dart @@ -8,14 +8,31 @@ class FacePainter extends CustomPainter { {required this.imageSize, this.face, required this.indicatorShape, - this.indicatorAssetImage}); + this.indicatorAssetImage, + this.isFaceWellPositioned = false, + this.showDebugLandmarks = false}); final Size imageSize; double? scaleX, scaleY; final Face? face; final IndicatorShape indicatorShape; final String? indicatorAssetImage; + final bool isFaceWellPositioned; + final bool showDebugLandmarks; @override void paint(Canvas canvas, Size size) { + scaleX = size.width / imageSize.width; + scaleY = size.height / imageSize.height; + + // Handle fixedFrame mode separately + if (indicatorShape == IndicatorShape.fixedFrame) { + _drawFixedFrame(canvas, size); + // Don't return early - continue to draw debug landmarks if enabled + if (showDebugLandmarks && face != null) { + _drawDebugLandmarks(canvas, size); + } + return; + } + if (face == null) return; Paint paint; @@ -32,9 +49,6 @@ class FacePainter extends CustomPainter { ..color = Colors.green; } - scaleX = size.width / imageSize.width; - scaleY = size.height / imageSize.height; - switch (indicatorShape) { case IndicatorShape.defaultShape: canvas.drawPath( @@ -102,14 +116,130 @@ class FacePainter extends CustomPainter { ); })); break; + case IndicatorShape.fixedFrame: + // Handled at the beginning of paint() method + break; case IndicatorShape.none: break; } + + // Draw debug landmarks if enabled + if (showDebugLandmarks && face != null) { + _drawDebugLandmarks(canvas, size); + } + } + + /// Draw debug markers for facial landmarks + void _drawDebugLandmarks(Canvas canvas, Size size) { + if (face == null || scaleX == null || scaleY == null) return; + + final landmarkPaint = Paint() + ..style = PaintingStyle.fill + ..strokeWidth = 2.0; + + // Draw each landmark with different colors + final landmarks = { + FaceLandmarkType.leftEye: Colors.blue, + FaceLandmarkType.rightEye: Colors.blue, + FaceLandmarkType.noseBase: Colors.green, + FaceLandmarkType.bottomMouth: Colors.red, + FaceLandmarkType.leftMouth: Colors.red, + FaceLandmarkType.rightMouth: Colors.red, + FaceLandmarkType.leftCheek: Colors.yellow, + FaceLandmarkType.rightCheek: Colors.yellow, + FaceLandmarkType.leftEar: Colors.purple, + FaceLandmarkType.rightEar: Colors.purple, + }; + + for (final entry in landmarks.entries) { + final landmark = face!.landmarks[entry.key]; + if (landmark != null) { + landmarkPaint.color = entry.value; + + // Convert landmark position to screen coordinates + final x = size.width - landmark.position.x.toDouble() * scaleX!; + final y = landmark.position.y.toDouble() * scaleY!; + + // Draw landmark as a circle + canvas.drawCircle(Offset(x, y), 6.0, landmarkPaint); + + // Draw white border for visibility + final borderPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0 + ..color = Colors.white; + canvas.drawCircle(Offset(x, y), 6.0, borderPaint); + } + } + + // Draw face bounding box in debug mode (clamped to image bounds) + final boundingBoxPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0 + ..color = Colors.cyan; + + // Clamp bounding box to valid image range (ML Kit can extend beyond image) + final clampedLeft = face!.boundingBox.left.clamp(0.0, imageSize.width); + final clampedRight = face!.boundingBox.right.clamp(0.0, imageSize.width); + final clampedTop = face!.boundingBox.top.clamp(0.0, imageSize.height); + final clampedBottom = face!.boundingBox.bottom.clamp(0.0, imageSize.height); + + canvas.drawRect( + Rect.fromLTRB( + size.width - clampedLeft * scaleX!, + clampedTop * scaleY!, + size.width - clampedRight * scaleX!, + clampedBottom * scaleY!, + ), + boundingBoxPaint, + ); } @override bool shouldRepaint(FacePainter oldDelegate) { - return oldDelegate.imageSize != imageSize || oldDelegate.face != face; + return oldDelegate.imageSize != imageSize || + oldDelegate.face != face || + oldDelegate.showDebugLandmarks != showDebugLandmarks || + oldDelegate.isFaceWellPositioned != isFaceWellPositioned; + } + + /// Draw a fixed centered frame that changes color based on face position + void _drawFixedFrame(Canvas canvas, Size size) { + // Define fixed square in center of screen (70% of screen width) + final double squareSize = size.width * 0.7; + final Rect fixedRect = Rect.fromCenter( + center: Offset(size.width / 2, size.height / 2), + width: squareSize, + height: squareSize, + ); + + // Determine color based on face positioning + Paint paint; + if (face == null) { + // No face detected - white/gray + paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 3.0 + ..color = Colors.white.withOpacity(0.5); + } else if (isFaceWellPositioned) { + // Face is properly positioned - green + paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 3.0 + ..color = Colors.green; + } else { + // Face detected but not positioned correctly - red + paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 3.0 + ..color = Colors.red; + } + + // Draw fixed rounded square + canvas.drawRRect( + RRect.fromRectAndRadius(fixedRect, const Radius.circular(10)), + paint, + ); } } diff --git a/lib/src/res/enums.dart b/lib/src/res/enums.dart index f45d3aa..ab5ad4f 100644 --- a/lib/src/res/enums.dart +++ b/lib/src/res/enums.dart @@ -78,6 +78,9 @@ enum IndicatorShape { /// Uses an asset image as face indicator image, + /// Fixed centered frame that changes color based on face position + fixedFrame, + /// Hide face indicator none } diff --git a/lib/src/smart_face_camera.dart b/lib/src/smart_face_camera.dart index 9ff4b49..48fba82 100644 --- a/lib/src/smart_face_camera.dart +++ b/lib/src/smart_face_camera.dart @@ -159,6 +159,10 @@ class _SmartFaceCameraState extends State widget.indicatorShape, indicatorAssetImage: widget.indicatorAssetImage, + isFaceWellPositioned: + value.isFaceWellPositioned || value.isCapturing, + showDebugLandmarks: + widget.controller.showDebugLandmarks, imageSize: Size( cameraController .value.previewSize!.height, @@ -185,6 +189,28 @@ class _SmartFaceCameraState extends State painter: HolePainter(), ) ], + if (value.countdown != null) ...[ + Center( + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.6), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '${value.countdown}', + style: const TextStyle( + color: Colors.white, + fontSize: 72, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ], if (widget.showControls) ...[ Align( alignment: Alignment.bottomCenter,