Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Camera overlay capture webWorker #1927

Merged
merged 9 commits into from
Mar 24, 2025
168 changes: 127 additions & 41 deletions modules/camera/lib/camera.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:math' as math;
import 'package:image/image.dart' as img;
import 'dart:io' as io;
import 'helper/web.dart' as web;
import 'helper/js.dart' as js;

import 'package:camera/camera.dart';
import 'package:collection/collection.dart' show IterableExtension;
Expand Down Expand Up @@ -364,7 +365,7 @@ class CameraState extends EWidgetState<Camera> with WidgetsBindingObserver {

widget._controller.cameraController = CameraController(
targetCamera,
ResolutionPreset.veryHigh,
kIsWeb? ResolutionPreset.high : ResolutionPreset.veryHigh,
enableAudio: widget._controller.enableMicrophone,
);

Expand Down Expand Up @@ -516,12 +517,7 @@ class CameraState extends EWidgetState<Camera> with WidgetsBindingObserver {
if (isCropping)
Center(
child: widget.loadingWidget ??
Container(
color: Colors.black.withOpacity(0.7),
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
child: CircularProgressIndicator(),
),
CircularProgressIndicator(),
),
imagePreviewButton(),
Align(
Expand Down Expand Up @@ -1093,36 +1089,141 @@ class CameraState extends EWidgetState<Camera> with WidgetsBindingObserver {
final xFile = await widget._controller.cameraController!.takePicture();
final imageBytes = await xFile.readAsBytes();

final img.Image? decodedImage = img.decodeImage(imageBytes);
if (decodedImage == null) {
widget.onError
?.call('Error Capturing overlay: Failed to decode image bytes');
return null;
}

// Calculate overlay position relative to camera preview
final cameraOffset = cameraBoundary.localToGlobal(Offset.zero);
final overlayOffset = overlayBox.localToGlobal(Offset.zero);
final previewBox = cameraBoundary as RenderBox;

final double left = overlayOffset.dx - cameraOffset.dx;
final double top = overlayOffset.dy - cameraOffset.dy;
// Create crop data map with all required parameters
final cropData = {
'imageBytes': imageBytes,
'left': overlayOffset.dx - cameraOffset.dx,
'top': overlayOffset.dy - cameraOffset.dy,
'width': overlayBox.size.width,
'height': overlayBox.size.height,
'previewWidth': previewBox.size.width,
'previewHeight': previewBox.size.height,
};

// Scale factors to map Flutter coordinates to image coordinates
final previewBox = cameraBoundary as RenderBox;
final scaleX = decodedImage.width / previewBox.size.width;
final scaleY = decodedImage.height / previewBox.size.height;
final timestamp = DateTime.now().millisecondsSinceEpoch;
final filename = 'overlay_image_$timestamp.png';

// Process differently based on platform
if (kIsWeb) {
try {
// Convert image bytes to a Blob with explicit MIME type
final blob = web.Blob([cropData['imageBytes']], 'image/png');
final url = web.Url.createObjectUrlFromBlob(blob);

// Create a complete crop data object
final Map<String, dynamic> safeCropData = {
'imageUrl': url.toString(),
'left': cropData['left'] ?? 0,
'top': cropData['top'] ?? 0,
'width': cropData['width'] ?? 0,
'height': cropData['height'] ?? 0,
'previewWidth': cropData['previewWidth'] ?? 0,
'previewHeight': cropData['previewHeight'] ?? 0,
};

// Web worker processing
final croppedPng = await _processWithWebWorker(safeCropData);

// Create file with processed image
final processedBlob = web.Blob([croppedPng], 'image/png');
final path = web.Url.createObjectUrlFromBlob(processedBlob);

return File(filename, 'png', null, path, croppedPng);
} catch (e) {
print('Error in image processing: $e');
widget.onError?.call('Error processing image: $e');
return null;
}
} else {
// Non-web platform processing
try {
final img.Image? decodedImage = img.decodeImage(imageBytes);
if (decodedImage == null) {
widget.onError?.call('Error Capturing overlay: Failed to decode image bytes');
return null;
}

final croppedPng = _cropImageForNonWeb(
decodedImage,
cropData['left'] as double,
cropData['top'] as double,
cropData['width'] as double,
cropData['height'] as double,
cropData['previewWidth'] as double,
cropData['previewHeight'] as double
);

// Save to temp file
final tempDir = await getTemporaryDirectory();
final tempFile = io.File('${tempDir.path}/$filename');
await tempFile.writeAsBytes(croppedPng);

return File(filename, 'png', null, tempFile.path, croppedPng);
} catch (e) {
widget.onError?.call('Error processing image: $e');
return null;
}
}
}

// Helper function for web worker processing
Future<Uint8List> _processWithWebWorker(Map<String, dynamic> cropData) {
final completer = Completer<Uint8List>();
final worker = js.JsObject(js.context['Worker'], ['assets/packages/ensemble_camera/web/image_worker.js']);

// Set up message handler
worker.callMethod('addEventListener', ['message', js.allowInterop((event) {
final result = event.data;
if (result.containsKey('processedImage')) {
try {
// Get the JS array and convert to Dart Uint8List
final jsArray = result['processedImage'];
final List<int> intList = List<int>.generate(
jsArray.length,
(i) => jsArray[i] as int
);
completer.complete(Uint8List.fromList(intList));
} catch (e) {
completer.completeError('Error processing result: $e');
}
} else {
completer.completeError('Invalid response from worker');
}
worker.callMethod('terminate');
})]);

// Send data to the worker
worker.callMethod('postMessage', [js.JsObject.jsify(cropData)]);
return completer.future;
}

// Non-web image cropping
Uint8List _cropImageForNonWeb(
img.Image decodedImage,
double left,
double top,
double width,
double height,
double previewWidth,
double previewHeight
) {
final scaleX = decodedImage.width / previewWidth;
final scaleY = decodedImage.height / previewHeight;

// Calculate crop dimensions in image coordinates
final scaledLeft = (left * scaleX).floor().clamp(0, decodedImage.width);
final scaledTop = (top * scaleY).floor().clamp(0, decodedImage.height);
final scaledWidth = (overlayBox.size.width * scaleX)
final scaledWidth = (width * scaleX)
.floor()
.clamp(0, decodedImage.width - scaledLeft);
final scaledHeight = (overlayBox.size.height * scaleY)
final scaledHeight = (height * scaleY)
.floor()
.clamp(0, decodedImage.height - scaledTop);

// Crop the image
final cropped = img.copyCrop(
decodedImage,
x: scaledLeft,
Expand All @@ -1131,27 +1232,12 @@ class CameraState extends EWidgetState<Camera> with WidgetsBindingObserver {
height: scaledHeight,
);

final Uint8List croppedPng = Uint8List.fromList(img.encodePng(cropped));
final timestamp = DateTime.now().millisecondsSinceEpoch;
final filename = 'overlay_image_$timestamp.png';

String? path;
if (!kIsWeb) {
final tempDir = await getTemporaryDirectory();
final tempFile = io.File('${tempDir.path}/$filename');
await tempFile.writeAsBytes(croppedPng);
path = tempFile.path;
} else {
final blob = web.Blob([croppedPng], 'image/png');
path = web.Url.createObjectUrlFromBlob(blob);
}

return File(filename, 'png', null, path, croppedPng);
return Uint8List.fromList(img.encodePng(cropped));
}

bool canCapture() {
if (!(widget._controller.maxCount != null &&
(widget._controller.files.length + 1) > widget._controller.maxCount!)) {
(widget._controller.files.length + 1) > widget._controller.maxCount!) && !isCropping) {
return true;
}

Expand Down
1 change: 1 addition & 0 deletions modules/camera/lib/helper/js.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export 'stub.dart' if (dart.library.js) 'dart:js';
23 changes: 23 additions & 0 deletions modules/camera/lib/helper/stub.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,26 @@ class Url {
throw UnsupportedError('URL.createObjectURL is only supported on web platforms');
}
}

class JsObject {
JsObject(dynamic constructor, [List<dynamic>? arguments]) {
throw UnsupportedError('JsObject is only supported on web platforms');
}
static dynamic jsify(Object object) {
throw UnsupportedError('JsObject.jsify is only supported on web platforms');
}
void operator []=(String property, dynamic value) {
throw UnsupportedError('Property assignment is only supported on web platforms');
}
dynamic operator [](String property) {
throw UnsupportedError('Property access is only supported on web platforms');
}
dynamic callMethod(String method, [List<dynamic>? args]) {
throw UnsupportedError('callMethod is only supported on web platforms');
}
}

dynamic allowInterop<T extends Function>(T function) {
throw UnsupportedError('allowInterop is only supported on web platforms');
}
final JsObject context = JsObject(null);
1 change: 1 addition & 0 deletions modules/camera/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,4 @@ flutter:
assets:
- web/face_api.js
- web/face_detection.js
- web/image_worker.js
51 changes: 51 additions & 0 deletions modules/camera/web/image_worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
self.importScripts('https://cdn.jsdelivr.net/npm/[email protected]/browser/lib/jimp.js');

self.onmessage = async function(event) {
try {
const data = event.data;

if (!data.imageUrl) {
throw new Error('Missing image URL in worker data');
}

// Fetch the image
const response = await fetch(data.imageUrl);
const buffer = await response.arrayBuffer();

// Load the image into Jimp
const image = await Jimp.read(Buffer.from(buffer));

// Calculate scaling factors
const scaleX = image.bitmap.width / data.previewWidth;
const scaleY = image.bitmap.height / data.previewHeight;

// Calculate crop dimensions
const scaledLeft = Math.floor(data.left * scaleX);
const scaledTop = Math.floor(data.top * scaleY);
const scaledWidth = Math.floor(data.width * scaleX);
const scaledHeight = Math.floor(data.height * scaleY);

// Crop the image
const croppedImage = image.crop(
Math.max(0, scaledLeft),
Math.max(0, scaledTop),
Math.min(scaledWidth, image.bitmap.width - scaledLeft),
Math.min(scaledHeight, image.bitmap.height - scaledTop)
);

// Convert to PNG buffer
const bufferTwo = await croppedImage.getBufferAsync(Jimp.MIME_PNG);

// Convert to regular integers that Dart can handle
const uint8Array = new Uint8Array(bufferTwo);
const intArray = Array.from(uint8Array).map(Number);

// Send the processed image back
self.postMessage({
processedImage: intArray
});

} catch (error) {
self.postMessage({ error: error.toString() });
}
};
6 changes: 4 additions & 2 deletions starter/scripts/modules/enable_camera.dart
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,13 @@ ensemble_camera:
// Add the face detection models to the web/index.html file
if (platforms.contains('web')) {
const webIndexHtml = '''
<!-- Face Detection -->
<!-- Face Detection Scripts -->
<script src="assets/packages/ensemble_camera/web/face_api.js"></script>
<script src="assets/packages/ensemble_camera/web/face_detection.js"></script>
<!-- Image worker Script -->
<script src="assets/packages/ensemble_camera/web/image_worker.js"></script>
''';
updateWebIndexHtml(webIndexHtml, '<!-- Face Detection Scripts -->');
updateWebIndexHtml(webIndexHtml, '<!-- Face Detection -->');
}

print('Camera module enabled successfully for ${platforms.join(', ')}! 🎉');
Expand Down
2 changes: 1 addition & 1 deletion starter/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
<script src="https://maps.googleapis.com/maps/api/js?key=AIzaSyBUNOiR4vRFgSLAMkKG6-Oe2DrwEqaeLgc"></script>
<script src="js/automation.js"></script>

<!-- Face Detection Scripts -->
<!-- Face Detection -->

</head>
<body>
Expand Down