reintroducing face filters

This commit is contained in:
otsmr 2026-03-14 15:20:17 +01:00
parent 5a2049fd4a
commit 8ae729f53d
12 changed files with 720 additions and 18 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View file

@ -46,6 +46,10 @@ class MainCameraPreview extends StatelessWidget {
Positioned.fill(
child: mainCameraController.customPaint!,
),
if (mainCameraController.facePaint != null)
Positioned.fill(
child: mainCameraController.facePaint!,
),
],
),
),

View file

@ -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),
],
),
],
),

View file

@ -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);
}
}
}

View file

@ -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();
}
}

View file

@ -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,
),
),
);
}
}

View file

@ -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,
),
),
],
),
),
],
),
);
}
}

View file

@ -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,
),
);
}
}

View file

@ -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:

View file

@ -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