mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-04-16 06:52:53 +00:00
reintroducing face filters
This commit is contained in:
parent
5a2049fd4a
commit
8ae729f53d
12 changed files with 720 additions and 18 deletions
BIN
assets/filters/beard_upper_lip_green.webp
Normal file
BIN
assets/filters/beard_upper_lip_green.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
BIN
assets/filters/dog_brown_ear.webp
Normal file
BIN
assets/filters/dog_brown_ear.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
BIN
assets/filters/dog_brown_nose.webp
Normal file
BIN
assets/filters/dog_brown_nose.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
|
|
@ -46,6 +46,10 @@ class MainCameraPreview extends StatelessWidget {
|
|||
Positioned.fill(
|
||||
child: mainCameraController.customPaint!,
|
||||
),
|
||||
if (mainCameraController.facePaint != null)
|
||||
Positioned.fill(
|
||||
child: mainCameraController.facePaint!,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import 'package:twonly/src/utils/misc.dart';
|
|||
import 'package:twonly/src/utils/qr.dart';
|
||||
import 'package:twonly/src/utils/screenshot.dart';
|
||||
import 'package:twonly/src/utils/storage.dart';
|
||||
import 'package:twonly/src/views/camera/camera_preview_components/face_filters.dart';
|
||||
import 'package:twonly/src/views/camera/camera_preview_components/main_camera_controller.dart';
|
||||
import 'package:twonly/src/views/camera/camera_preview_components/permissions_view.dart';
|
||||
import 'package:twonly/src/views/camera/camera_preview_components/send_to.dart';
|
||||
|
|
@ -457,6 +458,36 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
|||
});
|
||||
}
|
||||
|
||||
Future<void> pressSideButtonLeft() async {
|
||||
if (!mc.isSelectingFaceFilters) {
|
||||
return pickImageFromGallery();
|
||||
}
|
||||
if (mc.currentFilterType.index == 1) {
|
||||
mc.setFilter(FaceFilterType.none);
|
||||
setState(() {
|
||||
mc.isSelectingFaceFilters = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
mc.setFilter(mc.currentFilterType.goLeft());
|
||||
}
|
||||
|
||||
Future<void> pressSideButtonRight() async {
|
||||
if (!mc.isSelectingFaceFilters) {
|
||||
setState(() {
|
||||
mc.isSelectingFaceFilters = true;
|
||||
});
|
||||
}
|
||||
if (mc.currentFilterType.index == FaceFilterType.values.length - 1) {
|
||||
mc.setFilter(FaceFilterType.none);
|
||||
setState(() {
|
||||
mc.isSelectingFaceFilters = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
mc.setFilter(mc.currentFilterType.goRight());
|
||||
}
|
||||
|
||||
Future<void> startVideoRecording() async {
|
||||
if (mc.cameraController != null &&
|
||||
mc.cameraController!.value.isRecordingVideo) {
|
||||
|
|
@ -693,27 +724,84 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
GestureDetector(
|
||||
onTap: takePicture,
|
||||
// onLongPress: startVideoRecording,
|
||||
key: keyTriggerButton,
|
||||
child: Align(
|
||||
child: Container(
|
||||
height: 100,
|
||||
width: 100,
|
||||
clipBehavior: Clip.antiAliasWithSaveLayer,
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
width: 7,
|
||||
color: mc.isVideoRecording
|
||||
? Colors.red
|
||||
: Colors.white,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (!mc.isVideoRecording)
|
||||
GestureDetector(
|
||||
onTap: pressSideButtonLeft,
|
||||
child: Align(
|
||||
child: Container(
|
||||
height: 50,
|
||||
width: 80,
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: Center(
|
||||
child: FaIcon(
|
||||
mc.isSelectingFaceFilters
|
||||
? mc.currentFilterType.index == 1
|
||||
? FontAwesomeIcons.xmark
|
||||
: FontAwesomeIcons.arrowLeft
|
||||
: FontAwesomeIcons.photoFilm,
|
||||
color: Colors.white,
|
||||
size: 25,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: takePicture,
|
||||
// onLongPress: startVideoRecording,
|
||||
key: keyTriggerButton,
|
||||
child: Align(
|
||||
child: Container(
|
||||
height: 100,
|
||||
width: 100,
|
||||
clipBehavior: Clip.antiAliasWithSaveLayer,
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
width: 7,
|
||||
color: mc.isVideoRecording
|
||||
? Colors.red
|
||||
: Colors.white,
|
||||
),
|
||||
),
|
||||
child: mc.currentFilterType.preview,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!mc.isVideoRecording)
|
||||
if (isFront)
|
||||
GestureDetector(
|
||||
onTap: pressSideButtonRight,
|
||||
child: Align(
|
||||
child: Container(
|
||||
height: 50,
|
||||
width: 80,
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: Center(
|
||||
child: FaIcon(
|
||||
mc.isSelectingFaceFilters
|
||||
? mc.currentFilterType.index ==
|
||||
FaceFilterType
|
||||
.values.length -
|
||||
1
|
||||
? FontAwesomeIcons.xmark
|
||||
: FontAwesomeIcons.arrowRight
|
||||
: FontAwesomeIcons
|
||||
.faceGrinTongueSquint,
|
||||
color: Colors.white,
|
||||
size: 25,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 80),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/beard_filter_painter.dart';
|
||||
import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/dog_filter_painter.dart';
|
||||
|
||||
enum FaceFilterType {
|
||||
none,
|
||||
dogBrown,
|
||||
beardUpperLipGreen,
|
||||
}
|
||||
|
||||
extension FaceFilterTypeExtension on FaceFilterType {
|
||||
FaceFilterType goRight() {
|
||||
final nextIndex = (index + 1) % FaceFilterType.values.length;
|
||||
return FaceFilterType.values[nextIndex];
|
||||
}
|
||||
|
||||
FaceFilterType goLeft() {
|
||||
final prevIndex = (index - 1 + FaceFilterType.values.length) %
|
||||
FaceFilterType.values.length;
|
||||
return FaceFilterType.values[prevIndex];
|
||||
}
|
||||
|
||||
Widget get preview {
|
||||
switch (this) {
|
||||
case FaceFilterType.none:
|
||||
return Container();
|
||||
case FaceFilterType.dogBrown:
|
||||
return DogFilterPainter.getPreview();
|
||||
case FaceFilterType.beardUpperLipGreen:
|
||||
return BeardFilterPainter.getPreview(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import 'package:drift/drift.dart' show Value;
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_mlkit_barcode_scanning/google_mlkit_barcode_scanning.dart';
|
||||
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
||||
|
|
@ -18,7 +19,11 @@ import 'package:twonly/src/utils/misc.dart';
|
|||
import 'package:twonly/src/utils/qr.dart';
|
||||
import 'package:twonly/src/utils/screenshot.dart';
|
||||
import 'package:twonly/src/views/camera/camera_preview_components/camera_preview_controller_view.dart';
|
||||
import 'package:twonly/src/views/camera/camera_preview_components/face_filters.dart';
|
||||
import 'package:twonly/src/views/camera/camera_preview_components/painters/barcode_detector_painter.dart';
|
||||
import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/beard_filter_painter.dart';
|
||||
import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/dog_filter_painter.dart';
|
||||
import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/face_filter_painter.dart';
|
||||
|
||||
class ScannedVerifiedContact {
|
||||
ScannedVerifiedContact({required this.contact, required this.verificationOk});
|
||||
|
|
@ -44,6 +49,7 @@ class MainCameraController {
|
|||
GlobalKey zoomButtonKey = GlobalKey();
|
||||
GlobalKey cameraPreviewKey = GlobalKey();
|
||||
|
||||
bool isSelectingFaceFilters = false;
|
||||
bool isSharePreviewIsShown = false;
|
||||
bool isVideoRecording = false;
|
||||
DateTime? timeSharedLinkWasSetWithQr;
|
||||
|
|
@ -61,10 +67,21 @@ class MainCameraController {
|
|||
}
|
||||
|
||||
final BarcodeScanner _barcodeScanner = BarcodeScanner();
|
||||
final FaceDetector _faceDetector = FaceDetector(
|
||||
options: FaceDetectorOptions(
|
||||
enableContours: true,
|
||||
enableLandmarks: true,
|
||||
),
|
||||
);
|
||||
bool _isBusy = false;
|
||||
bool _isBusyFaces = false;
|
||||
CustomPaint? customPaint;
|
||||
CustomPaint? facePaint;
|
||||
Offset? focusPointOffset;
|
||||
|
||||
FaceFilterType _currentFilterType = FaceFilterType.none;
|
||||
FaceFilterType get currentFilterType => _currentFilterType;
|
||||
|
||||
Future<void> closeCamera() async {
|
||||
contactsVerified = {};
|
||||
scannedNewProfiles = {};
|
||||
|
|
@ -154,7 +171,10 @@ class MainCameraController {
|
|||
..cameraLoaded = true
|
||||
..cameraId = cameraId;
|
||||
|
||||
facePaint = null;
|
||||
customPaint = null;
|
||||
isSelectingFaceFilters = false;
|
||||
setFilter(FaceFilterType.none);
|
||||
zoomButtonKey = GlobalKey();
|
||||
setState();
|
||||
}
|
||||
|
|
@ -191,6 +211,18 @@ class MainCameraController {
|
|||
setState();
|
||||
}
|
||||
|
||||
void setFilter(FaceFilterType type) {
|
||||
_currentFilterType = type;
|
||||
if (_currentFilterType == FaceFilterType.none) {
|
||||
faceFilterPainter = null;
|
||||
facePaint = null;
|
||||
_isBusyFaces = false;
|
||||
}
|
||||
setState();
|
||||
}
|
||||
|
||||
FaceFilterPainter? faceFilterPainter;
|
||||
|
||||
final Map<DeviceOrientation, int> _orientations = {
|
||||
DeviceOrientation.portraitUp: 0,
|
||||
DeviceOrientation.landscapeLeft: 90,
|
||||
|
|
@ -205,6 +237,15 @@ class MainCameraController {
|
|||
final inputImage = _inputImageFromCameraImage(image);
|
||||
if (inputImage == null) return;
|
||||
_processBarcode(inputImage);
|
||||
// check if front camera is selected
|
||||
if (cameraController?.description.lensDirection ==
|
||||
CameraLensDirection.front) {
|
||||
if (_currentFilterType != FaceFilterType.none) {
|
||||
_processFaces(inputImage);
|
||||
}
|
||||
} else {
|
||||
_processBarcode(inputImage);
|
||||
}
|
||||
}
|
||||
|
||||
InputImage? _inputImageFromCameraImage(CameraImage image) {
|
||||
|
|
@ -360,4 +401,52 @@ class MainCameraController {
|
|||
_isBusy = false;
|
||||
setState();
|
||||
}
|
||||
|
||||
Future<void> _processFaces(InputImage inputImage) async {
|
||||
if (_isBusyFaces) return;
|
||||
_isBusyFaces = true;
|
||||
final faces = await _faceDetector.processImage(inputImage);
|
||||
if (inputImage.metadata?.size != null &&
|
||||
inputImage.metadata?.rotation != null &&
|
||||
cameraController != null) {
|
||||
if (faces.isNotEmpty) {
|
||||
CustomPainter? painter;
|
||||
switch (_currentFilterType) {
|
||||
case FaceFilterType.dogBrown:
|
||||
painter = DogFilterPainter(
|
||||
faces,
|
||||
inputImage.metadata!.size,
|
||||
inputImage.metadata!.rotation,
|
||||
cameraController!.description.lensDirection,
|
||||
);
|
||||
case FaceFilterType.beardUpperLipGreen:
|
||||
painter = BeardFilterPainter(
|
||||
_currentFilterType,
|
||||
faces,
|
||||
inputImage.metadata!.size,
|
||||
inputImage.metadata!.rotation,
|
||||
cameraController!.description.lensDirection,
|
||||
);
|
||||
case FaceFilterType.none:
|
||||
break;
|
||||
}
|
||||
|
||||
if (painter != null) {
|
||||
facePaint = CustomPaint(painter: painter);
|
||||
// Also set the correct FaceFilterPainter reference if needed for other logic,
|
||||
// though currently facePaint is what's used for display.
|
||||
if (painter is FaceFilterPainter) {
|
||||
faceFilterPainter = painter;
|
||||
}
|
||||
} else {
|
||||
facePaint = null;
|
||||
faceFilterPainter = null;
|
||||
}
|
||||
} else {
|
||||
facePaint = null;
|
||||
}
|
||||
}
|
||||
_isBusyFaces = false;
|
||||
setState();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,191 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
import 'package:twonly/src/views/camera/camera_preview_components/face_filters.dart';
|
||||
import 'package:twonly/src/views/camera/camera_preview_components/painters/coordinates_translator.dart';
|
||||
import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/face_filter_painter.dart';
|
||||
|
||||
class BeardFilterPainter extends FaceFilterPainter {
|
||||
BeardFilterPainter(
|
||||
FaceFilterType beardType,
|
||||
super.faces,
|
||||
super.imageSize,
|
||||
super.rotation,
|
||||
super.cameraLensDirection,
|
||||
) {
|
||||
_loadAssets(beardType);
|
||||
}
|
||||
|
||||
static FaceFilterType? _lastLoadedBeardType;
|
||||
static ui.Image? _beardImage;
|
||||
static bool _loading = false;
|
||||
|
||||
static String getAssetPath(FaceFilterType beardType) {
|
||||
switch (beardType) {
|
||||
case FaceFilterType.beardUpperLipGreen:
|
||||
return 'assets/filters/beard_upper_lip_green.webp';
|
||||
case FaceFilterType.dogBrown:
|
||||
case FaceFilterType.none:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _loadAssets(FaceFilterType beardType) async {
|
||||
if ((_loading || _beardImage != null) &&
|
||||
_lastLoadedBeardType == beardType) {
|
||||
return;
|
||||
}
|
||||
_loading = true;
|
||||
try {
|
||||
_beardImage = await _loadImage(getAssetPath(beardType));
|
||||
} catch (e) {
|
||||
Log.error('Failed to load filter assets: $e');
|
||||
} finally {
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<ui.Image> _loadImage(String assetPath) async {
|
||||
final data = await rootBundle.load(assetPath);
|
||||
final list = Uint8List.view(data.buffer);
|
||||
final completer = Completer<ui.Image>();
|
||||
ui.decodeImageFromList(list, completer.complete);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (_beardImage == null) return;
|
||||
|
||||
for (final face in faces) {
|
||||
final noseBase = face.landmarks[FaceLandmarkType.noseBase];
|
||||
final mouthLeft = face.landmarks[FaceLandmarkType.leftMouth];
|
||||
final mouthRight = face.landmarks[FaceLandmarkType.rightMouth];
|
||||
final bottomMouth = face.landmarks[FaceLandmarkType.bottomMouth];
|
||||
|
||||
if (noseBase != null &&
|
||||
mouthLeft != null &&
|
||||
mouthRight != null &&
|
||||
bottomMouth != null) {
|
||||
final noseX = translateX(
|
||||
noseBase.position.x.toDouble(),
|
||||
size,
|
||||
imageSize,
|
||||
rotation,
|
||||
cameraLensDirection,
|
||||
);
|
||||
final noseY = translateY(
|
||||
noseBase.position.y.toDouble(),
|
||||
size,
|
||||
imageSize,
|
||||
rotation,
|
||||
cameraLensDirection,
|
||||
);
|
||||
|
||||
final mouthLeftX = translateX(
|
||||
mouthLeft.position.x.toDouble(),
|
||||
size,
|
||||
imageSize,
|
||||
rotation,
|
||||
cameraLensDirection,
|
||||
);
|
||||
final mouthLeftY = translateY(
|
||||
mouthLeft.position.y.toDouble(),
|
||||
size,
|
||||
imageSize,
|
||||
rotation,
|
||||
cameraLensDirection,
|
||||
);
|
||||
|
||||
final mouthRightX = translateX(
|
||||
mouthRight.position.x.toDouble(),
|
||||
size,
|
||||
imageSize,
|
||||
rotation,
|
||||
cameraLensDirection,
|
||||
);
|
||||
final mouthRightY = translateY(
|
||||
mouthRight.position.y.toDouble(),
|
||||
size,
|
||||
imageSize,
|
||||
rotation,
|
||||
cameraLensDirection,
|
||||
);
|
||||
|
||||
final mouthCenterX = (mouthLeftX + mouthRightX) / 2;
|
||||
final mouthCenterY = (mouthLeftY + mouthRightY) / 2;
|
||||
|
||||
final beardCenterX = (noseX + mouthCenterX) / 2;
|
||||
final beardCenterY = (noseY + mouthCenterY) / 2;
|
||||
|
||||
final dx = mouthRightX - mouthLeftX;
|
||||
final dy = mouthRightY - mouthLeftY;
|
||||
final angle = atan2(dy, dx);
|
||||
|
||||
final mouthWidth = sqrt(dx * dx + dy * dy);
|
||||
final beardWidth = mouthWidth * 1.5;
|
||||
|
||||
final yaw = face.headEulerAngleY ?? 0;
|
||||
final scaleX = cos(yaw * pi / 180).abs();
|
||||
|
||||
_drawImage(
|
||||
canvas,
|
||||
_beardImage!,
|
||||
Offset(beardCenterX, beardCenterY),
|
||||
beardWidth,
|
||||
angle,
|
||||
scaleX,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _drawImage(
|
||||
Canvas canvas,
|
||||
ui.Image image,
|
||||
Offset position,
|
||||
double width,
|
||||
double rotation,
|
||||
double scaleX,
|
||||
) {
|
||||
canvas
|
||||
..save()
|
||||
..translate(position.dx, position.dy)
|
||||
..rotate(rotation)
|
||||
..scale(scaleX, Platform.isAndroid ? -1 : 1);
|
||||
|
||||
final srcRect =
|
||||
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble());
|
||||
|
||||
final aspectRatio = image.width / image.height;
|
||||
final dstWidth = width;
|
||||
final dstHeight = width / aspectRatio;
|
||||
|
||||
final dstRect = Rect.fromCenter(
|
||||
center: Offset.zero,
|
||||
width: dstWidth,
|
||||
height: dstHeight,
|
||||
);
|
||||
|
||||
canvas
|
||||
..drawImageRect(image, srcRect, dstRect, Paint())
|
||||
..restore();
|
||||
}
|
||||
|
||||
static Widget getPreview(FaceFilterType beardType) {
|
||||
return Preview(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Image.asset(
|
||||
getAssetPath(beardType),
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
import 'package:twonly/src/views/camera/camera_preview_components/painters/coordinates_translator.dart';
|
||||
import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/face_filter_painter.dart';
|
||||
|
||||
class DogFilterPainter extends FaceFilterPainter {
|
||||
DogFilterPainter(
|
||||
super.faces,
|
||||
super.imageSize,
|
||||
super.rotation,
|
||||
super.cameraLensDirection,
|
||||
) {
|
||||
_loadAssets();
|
||||
}
|
||||
|
||||
static ui.Image? _earImage;
|
||||
static ui.Image? _noseImage;
|
||||
static bool _loading = false;
|
||||
|
||||
static Future<void> _loadAssets() async {
|
||||
if (_loading || (_earImage != null && _noseImage != null)) return;
|
||||
_loading = true;
|
||||
try {
|
||||
_earImage = await _loadImage('assets/filters/dog_brown_ear.webp');
|
||||
_noseImage = await _loadImage('assets/filters/dog_brown_nose.webp');
|
||||
} catch (e) {
|
||||
Log.error('Failed to load filter assets: $e');
|
||||
} finally {
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<ui.Image> _loadImage(String assetPath) async {
|
||||
final data = await rootBundle.load(assetPath);
|
||||
final list = Uint8List.view(data.buffer);
|
||||
final completer = Completer<ui.Image>();
|
||||
ui.decodeImageFromList(list, completer.complete);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (_earImage == null || _noseImage == null) return;
|
||||
|
||||
for (final face in faces) {
|
||||
final faceContour = face.contours[FaceContourType.face];
|
||||
final noseBase = face.landmarks[FaceLandmarkType.noseBase];
|
||||
|
||||
if (faceContour != null && noseBase != null) {
|
||||
final points = faceContour.points;
|
||||
if (points.isEmpty) continue;
|
||||
|
||||
final upperPoints =
|
||||
points.where((p) => p.y < noseBase.position.y).toList();
|
||||
|
||||
if (upperPoints.isEmpty) continue;
|
||||
|
||||
Point<int>? leftMost;
|
||||
Point<int>? rightMost;
|
||||
Point<int>? topMost;
|
||||
|
||||
for (final point in upperPoints) {
|
||||
if (leftMost == null || point.x < leftMost.x) {
|
||||
leftMost = point;
|
||||
}
|
||||
if (rightMost == null || point.x > rightMost.x) {
|
||||
rightMost = point;
|
||||
}
|
||||
if (topMost == null || point.y < topMost.y) {
|
||||
topMost = point;
|
||||
}
|
||||
}
|
||||
|
||||
if (leftMost == null || rightMost == null || topMost == null) continue;
|
||||
|
||||
final leftEarX = translateX(
|
||||
leftMost.x.toDouble(),
|
||||
size,
|
||||
imageSize,
|
||||
rotation,
|
||||
cameraLensDirection,
|
||||
);
|
||||
final leftEarY = translateY(
|
||||
topMost.y.toDouble(),
|
||||
size,
|
||||
imageSize,
|
||||
rotation,
|
||||
cameraLensDirection,
|
||||
);
|
||||
|
||||
final rightEarX = translateX(
|
||||
rightMost.x.toDouble(),
|
||||
size,
|
||||
imageSize,
|
||||
rotation,
|
||||
cameraLensDirection,
|
||||
);
|
||||
final rightEarY = translateY(
|
||||
topMost.y.toDouble(),
|
||||
size,
|
||||
imageSize,
|
||||
rotation,
|
||||
cameraLensDirection,
|
||||
);
|
||||
|
||||
final noseX = translateX(
|
||||
noseBase.position.x.toDouble(),
|
||||
size,
|
||||
imageSize,
|
||||
rotation,
|
||||
cameraLensDirection,
|
||||
);
|
||||
final noseY = translateY(
|
||||
noseBase.position.y.toDouble(),
|
||||
size,
|
||||
imageSize,
|
||||
rotation,
|
||||
cameraLensDirection,
|
||||
);
|
||||
|
||||
final dx = rightEarX - leftEarX;
|
||||
final dy = rightEarY - leftEarY;
|
||||
|
||||
final faceWidth = sqrt(dx * dx + dy * dy) * 1.5;
|
||||
final angle = atan2(dy, dx);
|
||||
|
||||
final yaw = face.headEulerAngleY ?? 0;
|
||||
final scaleX = cos(yaw * pi / 180).abs();
|
||||
|
||||
final earSize = faceWidth / 2.5;
|
||||
|
||||
_drawImage(
|
||||
canvas,
|
||||
_earImage!,
|
||||
Offset(leftEarX, leftEarY + earSize * 0.3),
|
||||
earSize,
|
||||
angle,
|
||||
scaleX,
|
||||
);
|
||||
|
||||
_drawImage(
|
||||
canvas,
|
||||
_earImage!,
|
||||
Offset(rightEarX, rightEarY + earSize * 0.3),
|
||||
earSize,
|
||||
angle,
|
||||
scaleX,
|
||||
isFlipped: true,
|
||||
);
|
||||
|
||||
final noseSize = faceWidth * 0.4;
|
||||
_drawImage(
|
||||
canvas,
|
||||
_noseImage!,
|
||||
Offset(noseX, noseY + noseSize * 0.1),
|
||||
noseSize,
|
||||
angle,
|
||||
scaleX,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _drawImage(
|
||||
Canvas canvas,
|
||||
ui.Image image,
|
||||
Offset position,
|
||||
double size,
|
||||
double rotation,
|
||||
double scaleX, {
|
||||
bool isFlipped = false,
|
||||
}) {
|
||||
canvas
|
||||
..save()
|
||||
..translate(position.dx, position.dy)
|
||||
..rotate(rotation);
|
||||
if (isFlipped) {
|
||||
canvas.scale(-scaleX, Platform.isAndroid ? -1 : 1);
|
||||
} else {
|
||||
canvas.scale(scaleX, Platform.isAndroid ? -1 : 1);
|
||||
}
|
||||
|
||||
final srcRect =
|
||||
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble());
|
||||
final aspectRatio = image.width / image.height;
|
||||
final dstWidth = size;
|
||||
final dstHeight = size / aspectRatio;
|
||||
|
||||
final dstRect = Rect.fromCenter(
|
||||
center: Offset.zero,
|
||||
width: dstWidth,
|
||||
height: dstHeight,
|
||||
);
|
||||
|
||||
canvas
|
||||
..drawImageRect(image, srcRect, dstRect, Paint())
|
||||
..restore();
|
||||
}
|
||||
|
||||
static Widget getPreview() {
|
||||
return Preview(
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 25),
|
||||
child: Image.asset(
|
||||
'assets/filters/dog_brown_nose.webp',
|
||||
width: 25,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/filters/dog_brown_ear.webp',
|
||||
width: 20,
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Transform.scale(
|
||||
scaleX: -1,
|
||||
child: Image.asset(
|
||||
'assets/filters/dog_brown_ear.webp',
|
||||
width: 20,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
|
||||
|
||||
abstract class FaceFilterPainter extends CustomPainter {
|
||||
FaceFilterPainter(
|
||||
this.faces,
|
||||
this.imageSize,
|
||||
this.rotation,
|
||||
this.cameraLensDirection,
|
||||
);
|
||||
|
||||
final List<Face> faces;
|
||||
final Size imageSize;
|
||||
final InputImageRotation rotation;
|
||||
final CameraLensDirection cameraLensDirection;
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant FaceFilterPainter oldDelegate) {
|
||||
return oldDelegate.imageSize != imageSize ||
|
||||
oldDelegate.faces != faces ||
|
||||
oldDelegate.rotation != rotation ||
|
||||
oldDelegate.cameraLensDirection != cameraLensDirection;
|
||||
}
|
||||
}
|
||||
|
||||
class Preview extends StatelessWidget {
|
||||
const Preview({required this.child, super.key});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
child: Center(
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -868,6 +868,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
google_mlkit_face_detection:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_mlkit_face_detection
|
||||
sha256: "7b6ddcc69dbd6fbfa313fb2d974ad0f0c3a0d1657560f0da6be465baf1889687"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.2"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ dependencies:
|
|||
flutter_volume_controller: ^1.3.4
|
||||
gal: ^2.3.1
|
||||
google_mlkit_barcode_scanning: ^0.14.1
|
||||
google_mlkit_face_detection: ^0.13.1
|
||||
pro_video_editor: ^1.6.1
|
||||
|
||||
dependency_overrides:
|
||||
|
|
@ -201,6 +202,7 @@ flutter:
|
|||
- assets/icons/
|
||||
- assets/animated_icons/
|
||||
- assets/animations/
|
||||
- assets/filters/
|
||||
- assets/passwords/
|
||||
- CHANGELOG.md
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue