From 9732d920d377b80c236ff8354119c9e774c66bad Mon Sep 17 00:00:00 2001 From: Saade Date: Mon, 9 Feb 2026 13:48:52 -0300 Subject: [PATCH 1/5] feat: fixed frame mode --- README.md | 172 ++++++++++- example/lib/main.dart | 37 ++- .../controllers/face_camera_controller.dart | 286 +++++++++++++++++- lib/src/controllers/face_camera_state.dart | 26 ++ lib/src/handlers/face_identifier.dart | 27 +- lib/src/paints/face_painter.dart | 219 +++++++++++++- lib/src/res/enums.dart | 3 + lib/src/smart_face_camera.dart | 26 ++ 8 files changed, 768 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 6160aaf..5234053 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.02 - 0.08 (normalized) + - Too close → "Move back" + - Too far → "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..0cd664c 100644 --- a/lib/src/controllers/face_camera_controller.dart +++ b/lib/src/controllers/face_camera_controller.dart @@ -22,6 +22,8 @@ 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()); @@ -50,6 +52,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 +180,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 +214,37 @@ 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); + + // Only check if landmarks are inside frame (no other constraints) + bool shouldCapture = 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 +255,243 @@ 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 before continuing countdown + final detectedFace = value.detectedFace; + if (detectedFace == null) { + // Face 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) { + // Calculate distance between eyes (normalized) + 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; + + final eyeDistance = ((leftEyeX - rightEyeX) * (leftEyeX - rightEyeX) + + (leftEyeY - rightEyeY) * (leftEyeY - rightEyeY)).abs(); + + // If eyes are too close together (< 0.02), face is too far + if (eyeDistance < 0.02) { + return "Move closer"; + } + + // If eyes are too far apart (> 0.08), face is too close + if (eyeDistance > 0.08) { + 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) { + // Calculate distance between eyes (normalized) + 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; + + final eyeDistance = ((leftEyeX - rightEyeX) * (leftEyeX - rightEyeX) + + (leftEyeY - rightEyeY) * (leftEyeY - rightEyeY)).abs(); + + // Face must be at correct distance + if (eyeDistance < 0.02 || eyeDistance > 0.08) { + 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 +500,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 +548,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..cfa214b 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,209 @@ 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, + ); + } + + /// Check if detected face is within the fixed frame + bool _isFaceInFrame(Rect fixedRect, Size size) { + if (face == null) return false; + + // Convert face bounding box to screen coordinates + final Rect scaledFaceRect = Rect.fromLTRB( + (size.width - face!.boundingBox.left * scaleX!), + face!.boundingBox.top * scaleY!, + (size.width - face!.boundingBox.right * scaleX!), + face!.boundingBox.bottom * scaleY!, + ); + + // Check if face is reasonably centered in fixed frame + final double overlapThreshold = 0.4; // 40% overlap required + final Rect intersection = _getIntersection(scaledFaceRect, fixedRect); + + if (intersection == Rect.zero) return false; + + final double intersectionArea = intersection.width * intersection.height; + final double faceArea = scaledFaceRect.width * scaledFaceRect.height; + final double overlapRatio = intersectionArea / faceArea; + + return overlapRatio >= overlapThreshold; + } + + /// Check if face is well-positioned (straight, eyes open, landmarks visible) + bool _isFaceWellPositioned() { + if (face == null) return false; + + // Head rotation check - must be facing forward + if (face!.headEulerAngleY! > 10 || face!.headEulerAngleY! < -10) { + return false; + } + + // Head tilt check - must not be tilted sideways + if (face!.headEulerAngleZ! > 10 || face!.headEulerAngleZ! < -10) { + return false; + } + + // Mouth landmarks check (essential for face quality) + final bottomMouth = face!.landmarks[FaceLandmarkType.bottomMouth]; + final rightMouth = face!.landmarks[FaceLandmarkType.rightMouth]; + final leftMouth = face!.landmarks[FaceLandmarkType.leftMouth]; + if (bottomMouth == null || rightMouth == null || leftMouth == null) { + return false; + } + + // Nose check (essential for face quality) + final noseBase = face!.landmarks[FaceLandmarkType.noseBase]; + if (noseBase == null) return false; + + // Note: Ear landmarks not required as they're often not visible in close-up face captures + + // Eyes open check + if (face!.leftEyeOpenProbability != null && + face!.leftEyeOpenProbability! < 0.5) { + return false; + } + if (face!.rightEyeOpenProbability != null && + face!.rightEyeOpenProbability! < 0.5) { + return false; + } + + return true; + } + + /// Get intersection rectangle between two rects + Rect _getIntersection(Rect a, Rect b) { + final left = a.left > b.left ? a.left : b.left; + final top = a.top > b.top ? a.top : b.top; + final right = a.right < b.right ? a.right : b.right; + final bottom = a.bottom < b.bottom ? a.bottom : b.bottom; + + if (left < right && top < bottom) { + return Rect.fromLTRB(left, top, right, bottom); + } + return Rect.zero; } } 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, From b3d1761b676699125ed0b6dfe20ac47bbe2d93aa Mon Sep 17 00:00:00 2001 From: Saade Date: Mon, 9 Feb 2026 15:13:53 -0300 Subject: [PATCH 2/5] fix: correct eye distance calculation from squared to Euclidean distance --- README.md | 6 ++--- .../controllers/face_camera_controller.dart | 27 ++++++++++--------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 5234053..9f8cc58 100644 --- a/README.md +++ b/README.md @@ -128,9 +128,9 @@ The `IndicatorShape.fixedFrame` option displays a centered, fixed-size frame (70 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.02 - 0.08 (normalized) - - Too close → "Move back" - - Too far → "Move closer" + - 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) diff --git a/lib/src/controllers/face_camera_controller.dart b/lib/src/controllers/face_camera_controller.dart index 0cd664c..3ed507f 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'; @@ -328,22 +329,23 @@ class FaceCameraController extends ValueNotifier { final leftEye = face.landmarks[FaceLandmarkType.leftEye]; final rightEye = face.landmarks[FaceLandmarkType.rightEye]; if (leftEye != null && rightEye != null) { - // Calculate distance between eyes (normalized) 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; - final eyeDistance = ((leftEyeX - rightEyeX) * (leftEyeX - rightEyeX) + - (leftEyeY - rightEyeY) * (leftEyeY - rightEyeY)).abs(); + // 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 together (< 0.02), face is too far - if (eyeDistance < 0.02) { + // 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 (> 0.08), face is too close - if (eyeDistance > 0.08) { + // If eyes are too far apart (distance > 0.28), face is too close + if (eyeDistance > 0.28) { return "Move back"; } } @@ -422,17 +424,18 @@ class FaceCameraController extends ValueNotifier { final leftEye = face.landmarks[FaceLandmarkType.leftEye]; final rightEye = face.landmarks[FaceLandmarkType.rightEye]; if (leftEye != null && rightEye != null) { - // Calculate distance between eyes (normalized) 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; - final eyeDistance = ((leftEyeX - rightEyeX) * (leftEyeX - rightEyeX) + - (leftEyeY - rightEyeY) * (leftEyeY - rightEyeY)).abs(); + // 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 - if (eyeDistance < 0.02 || eyeDistance > 0.08) { + // Face must be at correct distance (0.14 to 0.28) + if (eyeDistance < 0.14 || eyeDistance > 0.28) { return false; } } From 431071ccdc2b85b546e487dc3761f4ae67da8c02 Mon Sep 17 00:00:00 2001 From: Saade Date: Mon, 9 Feb 2026 15:17:10 -0300 Subject: [PATCH 3/5] refactor: remove unused face frame detection logic --- lib/src/paints/face_painter.dart | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/lib/src/paints/face_painter.dart b/lib/src/paints/face_painter.dart index cfa214b..f3135c2 100644 --- a/lib/src/paints/face_painter.dart +++ b/lib/src/paints/face_painter.dart @@ -242,31 +242,6 @@ class FacePainter extends CustomPainter { ); } - /// Check if detected face is within the fixed frame - bool _isFaceInFrame(Rect fixedRect, Size size) { - if (face == null) return false; - - // Convert face bounding box to screen coordinates - final Rect scaledFaceRect = Rect.fromLTRB( - (size.width - face!.boundingBox.left * scaleX!), - face!.boundingBox.top * scaleY!, - (size.width - face!.boundingBox.right * scaleX!), - face!.boundingBox.bottom * scaleY!, - ); - - // Check if face is reasonably centered in fixed frame - final double overlapThreshold = 0.4; // 40% overlap required - final Rect intersection = _getIntersection(scaledFaceRect, fixedRect); - - if (intersection == Rect.zero) return false; - - final double intersectionArea = intersection.width * intersection.height; - final double faceArea = scaledFaceRect.width * scaledFaceRect.height; - final double overlapRatio = intersectionArea / faceArea; - - return overlapRatio >= overlapThreshold; - } - /// Check if face is well-positioned (straight, eyes open, landmarks visible) bool _isFaceWellPositioned() { if (face == null) return false; From 969acfad4b7c47bafaeffa165177262143d3833f Mon Sep 17 00:00:00 2001 From: Saade Date: Mon, 9 Feb 2026 15:19:22 -0300 Subject: [PATCH 4/5] refactor: remove unused face position validation and intersection rectangle logic --- lib/src/paints/face_painter.dart | 54 -------------------------------- 1 file changed, 54 deletions(-) diff --git a/lib/src/paints/face_painter.dart b/lib/src/paints/face_painter.dart index f3135c2..b117027 100644 --- a/lib/src/paints/face_painter.dart +++ b/lib/src/paints/face_painter.dart @@ -241,60 +241,6 @@ class FacePainter extends CustomPainter { paint, ); } - - /// Check if face is well-positioned (straight, eyes open, landmarks visible) - bool _isFaceWellPositioned() { - if (face == null) return false; - - // Head rotation check - must be facing forward - if (face!.headEulerAngleY! > 10 || face!.headEulerAngleY! < -10) { - return false; - } - - // Head tilt check - must not be tilted sideways - if (face!.headEulerAngleZ! > 10 || face!.headEulerAngleZ! < -10) { - return false; - } - - // Mouth landmarks check (essential for face quality) - final bottomMouth = face!.landmarks[FaceLandmarkType.bottomMouth]; - final rightMouth = face!.landmarks[FaceLandmarkType.rightMouth]; - final leftMouth = face!.landmarks[FaceLandmarkType.leftMouth]; - if (bottomMouth == null || rightMouth == null || leftMouth == null) { - return false; - } - - // Nose check (essential for face quality) - final noseBase = face!.landmarks[FaceLandmarkType.noseBase]; - if (noseBase == null) return false; - - // Note: Ear landmarks not required as they're often not visible in close-up face captures - - // Eyes open check - if (face!.leftEyeOpenProbability != null && - face!.leftEyeOpenProbability! < 0.5) { - return false; - } - if (face!.rightEyeOpenProbability != null && - face!.rightEyeOpenProbability! < 0.5) { - return false; - } - - return true; - } - - /// Get intersection rectangle between two rects - Rect _getIntersection(Rect a, Rect b) { - final left = a.left > b.left ? a.left : b.left; - final top = a.top > b.top ? a.top : b.top; - final right = a.right < b.right ? a.right : b.right; - final bottom = a.bottom < b.bottom ? a.bottom : b.bottom; - - if (left < right && top < bottom) { - return Rect.fromLTRB(left, top, right, bottom); - } - return Rect.zero; - } } Path _defaultPath( From 2137f294cfb5517d042f27eb8c36b1dc926ab143 Mon Sep 17 00:00:00 2001 From: Saade Date: Mon, 9 Feb 2026 15:41:03 -0300 Subject: [PATCH 5/5] fix: assert captureDelay non-negative and face detection --- lib/src/controllers/face_camera_controller.dart | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/src/controllers/face_camera_controller.dart b/lib/src/controllers/face_camera_controller.dart index 3ed507f..3ac84a2 100644 --- a/lib/src/controllers/face_camera_controller.dart +++ b/lib/src/controllers/face_camera_controller.dart @@ -27,7 +27,8 @@ class FaceCameraController extends ValueNotifier { 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; @@ -227,8 +228,9 @@ class FaceCameraController extends ValueNotifier { // Update state with positioning status value = value.copyWith(isFaceWellPositioned: isFaceCentered); - // Only check if landmarks are inside frame (no other constraints) - bool shouldCapture = isFaceCentered || ignoreFacePositioning; + // 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) { @@ -273,10 +275,12 @@ class FaceCameraController extends ValueNotifier { value = value.copyWith(countdown: _currentCountdown); _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { - // Check if face is still detected before continuing countdown + // Check if face is still detected and well-positioned final detectedFace = value.detectedFace; - if (detectedFace == null) { - // Face disappeared - cancel countdown + if (detectedFace == null || + detectedFace.face == null || + !value.isFaceWellPositioned) { + // Face moved or disappeared - cancel countdown timer.cancel(); value = value.copyWith(countdown: null); _currentCountdown = 0;