diff --git a/android/src/main/kotlin/dev/steenbakker/mobile_scanner/MobileScanner.kt b/android/src/main/kotlin/dev/steenbakker/mobile_scanner/MobileScanner.kt index f56be75a9..609f21a66 100644 --- a/android/src/main/kotlin/dev/steenbakker/mobile_scanner/MobileScanner.kt +++ b/android/src/main/kotlin/dev/steenbakker/mobile_scanner/MobileScanner.kt @@ -1,11 +1,13 @@ package dev.steenbakker.mobile_scanner +import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.graphics.Bitmap import android.graphics.Matrix import android.graphics.Rect import android.hardware.display.DisplayManager +import android.media.Image import android.net.Uri import android.os.Build import android.os.Handler @@ -58,6 +60,8 @@ class MobileScanner( /// Configurable variables var scanWindow: List? = null + var shouldConsiderInvertedImages: Boolean = false + private var invertCurrentImage: Boolean = false private var detectionSpeed: DetectionSpeed = DetectionSpeed.NO_DUPLICATES private var detectionTimeout: Long = 250 private var returnImage = false @@ -77,7 +81,17 @@ class MobileScanner( @ExperimentalGetImage val captureOutput = ImageAnalysis.Analyzer { imageProxy -> // YUV_420_888 format val mediaImage = imageProxy.image ?: return@Analyzer - val inputImage = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + + // Invert every other frame. + if (shouldConsiderInvertedImages) { + invertCurrentImage = !invertCurrentImage // so we jump from one normal to one inverted and viceversa + } + + val inputImage = if (invertCurrentImage) { + invertInputImage(imageProxy) + } else { + InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + } if (detectionSpeed == DetectionSpeed.NORMAL && scannerTimeout) { imageProxy.close() @@ -244,11 +258,13 @@ class MobileScanner( mobileScannerErrorCallback: (exception: Exception) -> Unit, detectionTimeout: Long, cameraResolution: Size?, - newCameraResolutionSelector: Boolean + newCameraResolutionSelector: Boolean, + shouldConsiderInvertedImages: Boolean, ) { this.detectionSpeed = detectionSpeed this.detectionTimeout = detectionTimeout this.returnImage = returnImage + this.shouldConsiderInvertedImages = shouldConsiderInvertedImages if (camera?.cameraInfo != null && preview != null && textureEntry != null) { mobileScannerErrorCallback(AlreadyStarted()) @@ -462,6 +478,45 @@ class MobileScanner( } } + /** + * Inverts the image colours respecting the alpha channel + */ + @SuppressLint("UnsafeOptInUsageError") + fun invertInputImage(imageProxy: ImageProxy): InputImage { + val image = imageProxy.image ?: throw IllegalArgumentException("Image is null") + + // Convert YUV_420_888 image to NV21 format + // based on our util helper + val bitmap = Bitmap.createBitmap(image.width, image.height, Bitmap.Config.ARGB_8888) + YuvToRgbConverter(activity).yuvToRgb(image, bitmap) + + // Invert RGB values + invertBitmapColors(bitmap) + + return InputImage.fromBitmap(bitmap, imageProxy.imageInfo.rotationDegrees) + } + + // Helper function to invert the colors of the bitmap + private fun invertBitmapColors(bitmap: Bitmap) { + val width = bitmap.width + val height = bitmap.height + for (x in 0 until width) { + for (y in 0 until height) { + val pixel = bitmap.getPixel(x, y) + val invertedColor = invertColor(pixel) + bitmap.setPixel(x, y, invertedColor) + } + } + } + + private fun invertColor(pixel: Int): Int { + val alpha = pixel and 0xFF000000.toInt() + val red = 255 - (pixel shr 16 and 0xFF) + val green = 255 - (pixel shr 8 and 0xFF) + val blue = 255 - (pixel and 0xFF) + return alpha or (red shl 16) or (green shl 8) or blue + } + /** * Analyze a single image. */ diff --git a/android/src/main/kotlin/dev/steenbakker/mobile_scanner/MobileScannerHandler.kt b/android/src/main/kotlin/dev/steenbakker/mobile_scanner/MobileScannerHandler.kt index 8b67192f5..2ae1372c3 100644 --- a/android/src/main/kotlin/dev/steenbakker/mobile_scanner/MobileScannerHandler.kt +++ b/android/src/main/kotlin/dev/steenbakker/mobile_scanner/MobileScannerHandler.kt @@ -124,6 +124,7 @@ class MobileScannerHandler( "setScale" -> setScale(call, result) "resetScale" -> resetScale(result) "updateScanWindow" -> updateScanWindow(call, result) + "setShouldConsiderInvertedImages" -> setShouldConsiderInvertedImages(call, result) else -> result.notImplemented() } } @@ -143,6 +144,7 @@ class MobileScannerHandler( } else { null } + val shouldConsiderInvertedImages: Boolean = call.argument("shouldConsiderInvertedImages") ?: false val barcodeScannerOptions: BarcodeScannerOptions? = buildBarcodeScannerOptions(formats) @@ -209,10 +211,20 @@ class MobileScannerHandler( }, timeout.toLong(), cameraResolution, - useNewCameraSelector + useNewCameraSelector, + shouldConsiderInvertedImages, ) } + private fun setShouldConsiderInvertedImages(call: MethodCall, result: MethodChannel.Result) { + val shouldConsiderInvertedImages = call.argument("shouldConsiderInvertedImages") + + if (shouldConsiderInvertedImages != null) + mobileScanner?.shouldConsiderInvertedImages = shouldConsiderInvertedImages + + result.success(null) + } + private fun stop(result: MethodChannel.Result) { try { mobileScanner!!.stop() diff --git a/ios/Classes/MobileScanner.swift b/ios/Classes/MobileScanner.swift index cec1c52ed..83e0978ae 100644 --- a/ios/Classes/MobileScanner.swift +++ b/ios/Classes/MobileScanner.swift @@ -48,6 +48,12 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega var detectionSpeed: DetectionSpeed = DetectionSpeed.noDuplicates + var shouldConsiderInvertedImages: Bool = false + // local variable to invert this image only this time, + // it changes based on [shouldConsiderInvertedImages] and + // it defaults as false + private var invertCurrentImage: Bool = false + private let backgroundQueue = DispatchQueue(label: "camera-handling") var standardZoomFactor: CGFloat = 1 @@ -120,6 +126,14 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega func requestPermission(_ result: @escaping FlutterResult) { AVCaptureDevice.requestAccess(for: .video, completionHandler: { result($0) }) } + + private func convertCIImageToCGImage(inputImage: CIImage) -> CGImage? { + let context = CIContext(options: nil) + if let cgImage = context.createCGImage(inputImage, from: inputImage.extent) { + return cgImage + } + return nil + } /// Gets called when a new image is added to the buffer public func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { @@ -136,10 +150,19 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega nextScanTime = currentTime + timeoutSeconds imagesCurrentlyBeingProcessed = true - - let ciImage = latestBuffer.image - let image = VisionImage(image: ciImage) + // Invert every other frame. + let uiImage : UIImage + if (shouldConsiderInvertedImages) { + invertCurrentImage = !invertCurrentImage + } + if (invertCurrentImage) { + uiImage = self.invertInputImage(image: latestBuffer.image) + } else { + uiImage = latestBuffer.image + } + + let image = VisionImage(image: uiImage) image.orientation = imageOrientation( deviceOrientation: UIDevice.current.orientation, defaultOrientation: .portrait, @@ -163,14 +186,15 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega } } - mobileScannerCallback(barcodes, error, ciImage) + mobileScannerCallback(barcodes, error, uiImage) } } } /// Start scanning for barcodes - func start(barcodeScannerOptions: BarcodeScannerOptions?, cameraPosition: AVCaptureDevice.Position, torch: Bool, detectionSpeed: DetectionSpeed, completion: @escaping (MobileScannerStartParameters) -> ()) throws { + func start(barcodeScannerOptions: BarcodeScannerOptions?, cameraPosition: AVCaptureDevice.Position, shouldConsiderInvertedImages: Bool, torch: Bool, detectionSpeed: DetectionSpeed, completion: @escaping (MobileScannerStartParameters) -> ()) throws { self.detectionSpeed = detectionSpeed + self.shouldConsiderInvertedImages = shouldConsiderInvertedImages if (device != nil || captureSession != nil) { throw MobileScannerError.alreadyStarted } @@ -355,6 +379,10 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega device.unlockForConfiguration() } catch(_) {} } + + func setShouldConsiderInvertedImages(_ shouldConsiderInvertedImages: Bool) { + self.shouldConsiderInvertedImages = shouldConsiderInvertedImages + } /// Turn the torch on. private func turnTorchOn() { @@ -434,8 +462,14 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega /// Analyze a single image func analyzeImage(image: UIImage, position: AVCaptureDevice.Position, barcodeScannerOptions: BarcodeScannerOptions?, callback: @escaping BarcodeScanningCallback) { - let image = VisionImage(image: image) - image.orientation = imageOrientation( + let uiImage: UIImage + if (invertCurrentImage) { + uiImage = self.invertInputImage(image: uiImage) + } else { + uiImage = image + } + let visionImage = VisionImage(image: uiImage) + visionImage.orientation = imageOrientation( deviceOrientation: UIDevice.current.orientation, defaultOrientation: .portrait, position: position @@ -443,7 +477,26 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega let scanner: BarcodeScanner = barcodeScannerOptions != nil ? BarcodeScanner.barcodeScanner(options: barcodeScannerOptions!) : BarcodeScanner.barcodeScanner() - scanner.process(image, completion: callback) + scanner.process(visionImage, completion: callback) + } + + private func invertInputImage(image: UIImage) -> UIImage { + let ciImage = CIImage(image: image) + + let filter: CIFilter? + + if #available(iOS 13.0, *) { + filter = CIFilter.colorInvert() + filter?.setValue(ciImage, forKey: kCIInputImageKey) + } else { + filter = CIFilter(name: "CIColorInvert") + filter?.setValue(ciImage, forKey: kCIInputImageKey) + } + + let outputImage = filter?.outputImage + let cgImage = convertCIImageToCGImage(inputImage: outputImage!) + + return UIImage(cgImage: cgImage!, scale: image.scale, orientation: image.imageOrientation) } var barcodesString: Array? diff --git a/ios/Classes/MobileScannerPlugin.swift b/ios/Classes/MobileScannerPlugin.swift index ff09ff77f..7cc50861e 100644 --- a/ios/Classes/MobileScannerPlugin.swift +++ b/ios/Classes/MobileScannerPlugin.swift @@ -117,6 +117,8 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin { resetScale(call, result) case "updateScanWindow": updateScanWindow(call, result) + case "setShouldConsiderInvertedImages": + setShouldConsiderInvertedImages(call, result) default: result(FlutterMethodNotImplemented) } @@ -128,6 +130,7 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin { let facing: Int = (call.arguments as! Dictionary)["facing"] as? Int ?? 1 let formats: Array = (call.arguments as! Dictionary)["formats"] as? Array ?? [] let returnImage: Bool = (call.arguments as! Dictionary)["returnImage"] as? Bool ?? false + let shouldConsiderInvertedImages: Bool = (call.arguments as! Dictionary)["shouldConsiderInvertedImages"] as? Bool ?? false let speed: Int = (call.arguments as! Dictionary)["speed"] as? Int ?? 0 let timeoutMs: Int = (call.arguments as! Dictionary)["timeout"] as? Int ?? 0 self.mobileScanner.timeoutSeconds = Double(timeoutMs) / Double(1000) @@ -139,7 +142,7 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin { let detectionSpeed: DetectionSpeed = DetectionSpeed(rawValue: speed)! do { - try mobileScanner.start(barcodeScannerOptions: barcodeOptions, cameraPosition: position, torch: torch, detectionSpeed: detectionSpeed) { parameters in + try mobileScanner.start(barcodeScannerOptions: barcodeOptions, cameraPosition: position, shouldConsiderInvertedImages: shouldConsiderInvertedImages, torch: torch, detectionSpeed: detectionSpeed) { parameters in DispatchQueue.main.async { result([ "textureId": parameters.textureId, @@ -167,6 +170,19 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin { } } + /// Sets the zoomScale. + private func setShouldConsiderInvertedImages(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { + let shouldConsiderInvertedImages = call.arguments as? Bool + if (shouldConsiderInvertedImages == nil) { + result(FlutterError(code: "MobileScanner", + message: "You must provide a shouldConsiderInvertedImages (bool) when calling setShouldConsiderInvertedImages", + details: nil)) + return + } + mobileScanner.setShouldConsiderInvertedImages(shouldConsiderInvertedImages!) + result(nil) + } + /// Stops the mobileScanner and closes the texture. private func stop(_ result: @escaping FlutterResult) { do { diff --git a/lib/src/method_channel/mobile_scanner_method_channel.dart b/lib/src/method_channel/mobile_scanner_method_channel.dart index 793492bf3..2c86c3e82 100644 --- a/lib/src/method_channel/mobile_scanner_method_channel.dart +++ b/lib/src/method_channel/mobile_scanner_method_channel.dart @@ -288,6 +288,14 @@ class MethodChannelMobileScanner extends MobileScannerPlatform { ); } + @override + Future setShouldConsiderInvertedImages(bool shouldConsiderInvertedImages) async { + await methodChannel.invokeMethod( + 'setShouldConsiderInvertedImages', + {'shouldConsiderInvertedImages': shouldConsiderInvertedImages}, + ); + } + @override Future stop() async { if (_textureId == null) { diff --git a/lib/src/mobile_scanner_controller.dart b/lib/src/mobile_scanner_controller.dart index e4edd86b4..5692947ad 100644 --- a/lib/src/mobile_scanner_controller.dart +++ b/lib/src/mobile_scanner_controller.dart @@ -25,6 +25,7 @@ class MobileScannerController extends ValueNotifier { this.formats = const [], this.returnImage = false, this.torchEnabled = false, + this.shouldConsiderInvertedImages = false, this.useNewCameraSelector = false, }) : detectionTimeoutMs = detectionSpeed == DetectionSpeed.normal ? detectionTimeoutMs : 0, @@ -82,6 +83,17 @@ class MobileScannerController extends ValueNotifier { /// Defaults to false, and is only supported on iOS, MacOS and Android. final bool returnImage; + /// Whether the scanner should try to detect color-inverted barcodes in every other frame. + /// + /// When this option is enabled, every odd frame from the camera preview has its colors inverted before processing. + /// This is useful if barcodes can be both black-on-white (the most common) and white-on-black (less common). + /// Usage of this parameter can incur a performance cost, as some frames need to be altered further during processing. + /// + /// Defaults to false and is only supported on Android and iOS. + /// + /// Defaults to false. + final bool shouldConsiderInvertedImages; + /// Whether the flashlight should be turned on when the camera is started. /// /// Defaults to false. @@ -278,6 +290,7 @@ class MobileScannerController extends ValueNotifier { returnImage: returnImage, torchEnabled: torchEnabled, useNewCameraSelector: useNewCameraSelector, + shouldConsiderInvertedImages: shouldConsiderInvertedImages, ); try { diff --git a/lib/src/mobile_scanner_platform_interface.dart b/lib/src/mobile_scanner_platform_interface.dart index a283e0c57..ec090a925 100644 --- a/lib/src/mobile_scanner_platform_interface.dart +++ b/lib/src/mobile_scanner_platform_interface.dart @@ -111,6 +111,11 @@ abstract class MobileScannerPlatform extends PlatformInterface { throw UnimplementedError('updateScanWindow() has not been implemented.'); } + /// Set inverting image colors in intervals (for negative Data Matrices). + Future setShouldConsiderInvertedImages(bool shouldConsiderInvertedImages) { + throw UnimplementedError('setInvertImage() has not been implemented.'); + } + /// Dispose of this [MobileScannerPlatform] instance. Future dispose() { throw UnimplementedError('dispose() has not been implemented.'); diff --git a/lib/src/objects/start_options.dart b/lib/src/objects/start_options.dart index 3b19e7b92..e39a157a1 100644 --- a/lib/src/objects/start_options.dart +++ b/lib/src/objects/start_options.dart @@ -15,6 +15,7 @@ class StartOptions { required this.returnImage, required this.torchEnabled, required this.useNewCameraSelector, + required this.shouldConsiderInvertedImages, }); /// The direction for the camera. @@ -23,6 +24,9 @@ class StartOptions { /// The desired camera resolution for the scanner. final Size? cameraResolution; + /// Whether the scanner should try to detect color-inverted barcodes in every other frame. + final bool shouldConsiderInvertedImages; + /// The detection speed for the scanner. final DetectionSpeed detectionSpeed; @@ -58,6 +62,7 @@ class StartOptions { 'timeout': detectionTimeoutMs, 'torch': torchEnabled, 'useNewCameraSelector': useNewCameraSelector, + 'shouldConsiderInvertedImages': shouldConsiderInvertedImages, }; } }