mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-03-03 15:46:47 +00:00
added support for face filters
This commit is contained in:
parent
448a28cdbd
commit
42cc6db0e2
14 changed files with 696 additions and 15 deletions
|
|
@ -1,5 +1,9 @@
|
|||
# Changelog
|
||||
|
||||
## 0.0.87
|
||||
|
||||
- Added basic support for face filters
|
||||
|
||||
## 0.0.86
|
||||
|
||||
- Allows to reopen send images (if send without time limit or enabled auth)
|
||||
|
|
|
|||
BIN
assets/filters/beard_upper_lip.webp
Normal file
BIN
assets/filters/beard_upper_lip.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 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 |
|
|
@ -136,6 +136,10 @@ PODS:
|
|||
- google_mlkit_commons (0.11.0):
|
||||
- Flutter
|
||||
- MLKitVision
|
||||
- google_mlkit_face_detection (0.13.1):
|
||||
- Flutter
|
||||
- google_mlkit_commons
|
||||
- GoogleMLKit/FaceDetection (~> 7.0.0)
|
||||
- GoogleAdsOnDeviceConversion (3.2.0):
|
||||
- GoogleUtilities/Environment (~> 8.1)
|
||||
- GoogleUtilities/Logger (~> 8.1)
|
||||
|
|
@ -169,6 +173,9 @@ PODS:
|
|||
- GoogleMLKit/BarcodeScanning (7.0.0):
|
||||
- GoogleMLKit/MLKitCore
|
||||
- MLKitBarcodeScanning (~> 6.0.0)
|
||||
- GoogleMLKit/FaceDetection (7.0.0):
|
||||
- GoogleMLKit/MLKitCore
|
||||
- MLKitFaceDetection (~> 6.0.0)
|
||||
- GoogleMLKit/MLKitCore (7.0.0):
|
||||
- MLKitCommon (~> 12.0.0)
|
||||
- GoogleToolboxForMac/Defines (4.2.1)
|
||||
|
|
@ -251,6 +258,9 @@ PODS:
|
|||
- GoogleUtilities/Logger (~> 8.0)
|
||||
- GoogleUtilities/UserDefaults (~> 8.0)
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||
- MLKitFaceDetection (6.0.0):
|
||||
- MLKitCommon (~> 12.0)
|
||||
- MLKitVision (~> 8.0)
|
||||
- MLKitVision (8.0.0):
|
||||
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||
|
|
@ -357,6 +367,7 @@ DEPENDENCIES:
|
|||
- gal (from `.symlinks/plugins/gal/darwin`)
|
||||
- google_mlkit_barcode_scanning (from `.symlinks/plugins/google_mlkit_barcode_scanning/ios`)
|
||||
- google_mlkit_commons (from `.symlinks/plugins/google_mlkit_commons/ios`)
|
||||
- google_mlkit_face_detection (from `.symlinks/plugins/google_mlkit_face_detection/ios`)
|
||||
- GoogleUtilities
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`)
|
||||
|
|
@ -398,6 +409,7 @@ SPEC REPOS:
|
|||
- MLImage
|
||||
- MLKitBarcodeScanning
|
||||
- MLKitCommon
|
||||
- MLKitFaceDetection
|
||||
- MLKitVision
|
||||
- nanopb
|
||||
- PromisesObjC
|
||||
|
|
@ -454,6 +466,8 @@ EXTERNAL SOURCES:
|
|||
:path: ".symlinks/plugins/google_mlkit_barcode_scanning/ios"
|
||||
google_mlkit_commons:
|
||||
:path: ".symlinks/plugins/google_mlkit_commons/ios"
|
||||
google_mlkit_face_detection:
|
||||
:path: ".symlinks/plugins/google_mlkit_face_detection/ios"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
in_app_purchase_storekit:
|
||||
|
|
@ -518,6 +532,7 @@ SPEC CHECKSUMS:
|
|||
gal: baecd024ebfd13c441269ca7404792a7152fde89
|
||||
google_mlkit_barcode_scanning: 8f5987f244a43fe1167689c548342a5174108159
|
||||
google_mlkit_commons: 2abe6a70e1824e431d16a51085cb475b672c8aab
|
||||
google_mlkit_face_detection: 754da2113a1952f063c7c5dc347ac6ae8934fb77
|
||||
GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f
|
||||
GoogleAppMeasurement: 3bf40aff49a601af5da1c3345702fcb4991d35ee
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
|
|
@ -533,6 +548,7 @@ SPEC CHECKSUMS:
|
|||
MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56
|
||||
MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2
|
||||
MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d
|
||||
MLKitFaceDetection: 2a593db4837db503ad3426b565e7aab045cefea5
|
||||
MLKitVision: 45e79d68845a2de77e2dd4d7f07947f0ed157b0e
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
no_screenshot: 89e778ede9f1e39cc3fb9404d782a42712f2a0b2
|
||||
|
|
|
|||
|
|
@ -37,7 +37,18 @@ class MainCameraPreview extends StatelessWidget {
|
|||
.cameraController!.value.previewSize!.width,
|
||||
child: CameraPreview(
|
||||
mainCameraController.cameraController!,
|
||||
child: mainCameraController.customPaint,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (mainCameraController.customPaint != null)
|
||||
Positioned.fill(
|
||||
child: mainCameraController.customPaint!,
|
||||
),
|
||||
if (mainCameraController.facePaint != null)
|
||||
Positioned.fill(
|
||||
child: mainCameraController.facePaint!,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -21,6 +21,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';
|
||||
|
|
@ -506,6 +507,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) {
|
||||
|
|
@ -736,15 +767,19 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
|||
children: [
|
||||
if (!_isVideoRecording)
|
||||
GestureDetector(
|
||||
onTap: pickImageFromGallery,
|
||||
onTap: pressSideButtonLeft,
|
||||
child: Align(
|
||||
child: Container(
|
||||
height: 50,
|
||||
width: 80,
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: const Center(
|
||||
child: Center(
|
||||
child: FaIcon(
|
||||
FontAwesomeIcons.photoFilm,
|
||||
mc.isSelectingFaceFilters
|
||||
? mc.currentFilterType.index == 1
|
||||
? FontAwesomeIcons.xmark
|
||||
: FontAwesomeIcons.arrowLeft
|
||||
: FontAwesomeIcons.photoFilm,
|
||||
color: Colors.white,
|
||||
size: 25,
|
||||
),
|
||||
|
|
@ -771,10 +806,39 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
|||
: Colors.white,
|
||||
),
|
||||
),
|
||||
child: mc.currentFilterType.preview,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_isVideoRecording) const SizedBox(width: 80),
|
||||
if (!_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/painters/face_filters/beard_filter_painter.dart';
|
||||
import 'package:twonly/src/views/camera/painters/face_filters/dog_filter_painter.dart';
|
||||
|
||||
enum FaceFilterType {
|
||||
none,
|
||||
dogBrown,
|
||||
beardUpperLip,
|
||||
}
|
||||
|
||||
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.beardUpperLip:
|
||||
return BeardFilterPainter.getPreview();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,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:twonly/globals.dart';
|
||||
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
|
|
@ -15,7 +16,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/painters/barcode_detector_painter.dart';
|
||||
import 'package:twonly/src/views/camera/painters/face_filters/beard_filter_painter.dart';
|
||||
import 'package:twonly/src/views/camera/painters/face_filters/dog_filter_painter.dart';
|
||||
import 'package:twonly/src/views/camera/painters/face_filters/face_filter_painter.dart';
|
||||
|
||||
class ScannedVerifiedContact {
|
||||
ScannedVerifiedContact({
|
||||
|
|
@ -45,6 +50,22 @@ class MainCameraController {
|
|||
Map<int, ScannedNewProfile> scannedNewProfiles = {};
|
||||
String? scannedUrl;
|
||||
GlobalKey zoomButtonKey = GlobalKey();
|
||||
bool isSelectingFaceFilters = false;
|
||||
|
||||
final BarcodeScanner _barcodeScanner = BarcodeScanner();
|
||||
final FaceDetector _faceDetector = FaceDetector(
|
||||
options: FaceDetectorOptions(
|
||||
enableContours: true,
|
||||
enableLandmarks: true,
|
||||
),
|
||||
);
|
||||
bool _isBusy = false;
|
||||
bool _isBusyFaces = false;
|
||||
CustomPaint? customPaint;
|
||||
CustomPaint? facePaint;
|
||||
|
||||
FaceFilterType _currentFilterType = FaceFilterType.beardUpperLip;
|
||||
FaceFilterType get currentFilterType => _currentFilterType;
|
||||
|
||||
Future<void> closeCamera() async {
|
||||
contactsVerified = {};
|
||||
|
|
@ -73,10 +94,9 @@ class MainCameraController {
|
|||
selectedCameraDetails = opts.$1;
|
||||
cameraController = opts.$2;
|
||||
}
|
||||
if (cameraController?.description.lensDirection ==
|
||||
CameraLensDirection.back) {
|
||||
await cameraController?.startImageStream(_processCameraImage);
|
||||
}
|
||||
isSelectingFaceFilters = false;
|
||||
setFilter(FaceFilterType.none);
|
||||
await cameraController?.startImageStream(_processCameraImage);
|
||||
zoomButtonKey = GlobalKey();
|
||||
setState();
|
||||
return cameraController;
|
||||
|
|
@ -95,13 +115,23 @@ class MainCameraController {
|
|||
}
|
||||
final tmp = cameraController;
|
||||
cameraController = null;
|
||||
facePaint = null;
|
||||
customPaint = null;
|
||||
await tmp!.dispose();
|
||||
await selectCamera((selectedCameraDetails.cameraId + 1) % 2, false);
|
||||
}
|
||||
|
||||
final BarcodeScanner _barcodeScanner = BarcodeScanner();
|
||||
bool _isBusy = false;
|
||||
CustomPaint? customPaint;
|
||||
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,
|
||||
|
|
@ -113,7 +143,16 @@ class MainCameraController {
|
|||
void _processCameraImage(CameraImage image) {
|
||||
final inputImage = _inputImageFromCameraImage(image);
|
||||
if (inputImage == null) return;
|
||||
_processImage(inputImage);
|
||||
_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) {
|
||||
|
|
@ -175,7 +214,7 @@ class MainCameraController {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> _processImage(InputImage inputImage) async {
|
||||
Future<void> _processBarcode(InputImage inputImage) async {
|
||||
if (_isBusy) return;
|
||||
_isBusy = true;
|
||||
final barcodes = await _barcodeScanner.processImage(inputImage);
|
||||
|
|
@ -255,4 +294,48 @@ 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;
|
||||
if (_currentFilterType == FaceFilterType.dogBrown) {
|
||||
painter = DogFilterPainter(
|
||||
faces,
|
||||
inputImage.metadata!.size,
|
||||
inputImage.metadata!.rotation,
|
||||
cameraController!.description.lensDirection,
|
||||
);
|
||||
} else if (_currentFilterType == FaceFilterType.beardUpperLip) {
|
||||
painter = BeardFilterPainter(
|
||||
faces,
|
||||
inputImage.metadata!.size,
|
||||
inputImage.metadata!.rotation,
|
||||
cameraController!.description.lensDirection,
|
||||
);
|
||||
}
|
||||
|
||||
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,174 @@
|
|||
import 'dart:async';
|
||||
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/painters/coordinates_translator.dart';
|
||||
import 'package:twonly/src/views/camera/painters/face_filters/face_filter_painter.dart';
|
||||
|
||||
class BeardFilterPainter extends FaceFilterPainter {
|
||||
BeardFilterPainter(
|
||||
super.faces,
|
||||
super.imageSize,
|
||||
super.rotation,
|
||||
super.cameraLensDirection,
|
||||
) {
|
||||
_loadAssets();
|
||||
}
|
||||
|
||||
static ui.Image? _beardImage;
|
||||
static bool _loading = false;
|
||||
|
||||
static Future<void> _loadAssets() async {
|
||||
if (_loading || _beardImage != null) return;
|
||||
_loading = true;
|
||||
try {
|
||||
_beardImage = await _loadImage('assets/filters/beard_upper_lip.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 (_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, -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() {
|
||||
return Preview(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Image.asset(
|
||||
'assets/filters/beard_upper_lip.webp',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
import 'dart:async';
|
||||
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/painters/coordinates_translator.dart';
|
||||
import 'package:twonly/src/views/camera/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, -1);
|
||||
} else {
|
||||
canvas.scale(scaleX, -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.0"
|
||||
google_mlkit_face_detection:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_mlkit_face_detection
|
||||
sha256: f336737d5b8a86797fd4368f42a5c26aeaa9c6dcc5243f0a16b5f6f663cfb70a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.1"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
|
|||
|
||||
publish_to: 'none'
|
||||
|
||||
version: 0.0.85+85
|
||||
version: 0.0.86+86
|
||||
|
||||
environment:
|
||||
sdk: ^3.6.0
|
||||
|
|
@ -110,6 +110,7 @@ dependencies:
|
|||
hand_signature: ^3.0.3
|
||||
flutter_sharing_intent: ^2.0.4
|
||||
no_screenshot: ^0.3.1
|
||||
google_mlkit_face_detection: ^0.13.1
|
||||
|
||||
dependency_overrides:
|
||||
dots_indicator:
|
||||
|
|
@ -203,5 +204,6 @@ flutter:
|
|||
- assets/animated_icons/
|
||||
- assets/animations/
|
||||
- assets/passwords/
|
||||
- assets/filters/
|
||||
- CHANGELOG.md
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue