diff --git a/README.md b/README.md index 7fa63a97..755c760e 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ See the example app for detailed implementation information. | analyzeImage (Gallery) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :x: | | returnImage | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :x: | | scanWindow | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :x: | +| autoZoom | :heavy_check_mark: | :x: | :x: | :x: | ## Platform Support diff --git a/android/build.gradle b/android/build.gradle index 62bd0fcf..8aa32a84 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -9,7 +9,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:8.3.2' + classpath 'com.android.tools.build:gradle:8.8.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -77,8 +77,8 @@ dependencies { // See: https://youtrack.jetbrains.com/issue/KT-55297/kotlin-stdlib-should-declare-constraints-on-kotlin-stdlib-jdk8-and-kotlin-stdlib-jdk7 implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.8.22")) - implementation 'androidx.camera:camera-lifecycle:1.3.4' - implementation 'androidx.camera:camera-camera2:1.3.4' + implementation 'androidx.camera:camera-camera2:1.4.1' + implementation 'androidx.camera:camera-lifecycle:1.4.1' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' testImplementation 'org.jetbrains.kotlin:kotlin-test' 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 9c998db7..668219cc 100644 --- a/android/src/main/kotlin/dev/steenbakker/mobile_scanner/MobileScanner.kt +++ b/android/src/main/kotlin/dev/steenbakker/mobile_scanner/MobileScanner.kt @@ -3,7 +3,11 @@ package dev.steenbakker.mobile_scanner import android.app.Activity import android.content.Context import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter import android.graphics.Matrix +import android.graphics.Paint import android.graphics.Rect import android.hardware.display.DisplayManager import android.net.Uri @@ -63,6 +67,7 @@ class MobileScanner( /// Configurable variables var scanWindow: List? = null + private var invertImage: Boolean = false private var detectionSpeed: DetectionSpeed = DetectionSpeed.NO_DUPLICATES private var detectionTimeout: Long = 250 private var returnImage = false @@ -83,7 +88,12 @@ 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) + + val inputImage = if (invertImage) { + invertInputImage(imageProxy) + } else { + InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + } if (detectionSpeed == DetectionSpeed.NORMAL && scannerTimeout) { imageProxy.close() @@ -134,8 +144,8 @@ class MobileScanner( mobileScannerCallback( barcodeMap, null, - if (portrait) mediaImage.width else mediaImage.height, - if (portrait) mediaImage.height else mediaImage.width) + if (portrait) inputImage.width else inputImage.height, + if (portrait) inputImage.height else inputImage.width) return@addOnSuccessListener } @@ -232,11 +242,13 @@ class MobileScanner( mobileScannerStartedCallback: MobileScannerStartedCallback, mobileScannerErrorCallback: (exception: Exception) -> Unit, detectionTimeout: Long, - cameraResolutionWanted: Size? + cameraResolutionWanted: Size?, + invertImage: Boolean, ) { this.detectionSpeed = detectionSpeed this.detectionTimeout = detectionTimeout this.returnImage = returnImage + this.invertImage = invertImage if (camera?.cameraInfo != null && preview != null && textureEntry != null && !isPaused) { @@ -429,14 +441,14 @@ class MobileScanner( isPaused = true } - private fun resumeCamera() { - // Resume camera by rebinding use cases - cameraProvider?.let { provider -> - val owner = activity as LifecycleOwner - cameraSelector?.let { provider.bindToLifecycle(owner, it, preview) } - } - isPaused = false - } +// private fun resumeCamera() { +// // Resume camera by rebinding use cases +// cameraProvider?.let { provider -> +// val owner = activity as LifecycleOwner +// cameraSelector?.let { provider.bindToLifecycle(owner, it, preview) } +// } +// isPaused = false +// } private fun releaseCamera() { if (displayListener != null) { @@ -485,6 +497,50 @@ class MobileScanner( } } + /** + * Inverts the image colours respecting the alpha channel + */ + @ExperimentalGetImage + fun invertInputImage(imageProxy: ImageProxy): InputImage { + val image = imageProxy.image ?: throw IllegalArgumentException("Image is null") + + // Convert YUV_420_888 image to RGB Bitmap + val bitmap = Bitmap.createBitmap(image.width, image.height, Bitmap.Config.ARGB_8888) + try { + val imageFormat = YuvToRgbConverter(activity.applicationContext) + imageFormat.yuvToRgb(image, bitmap) + + // Create an inverted bitmap + val invertedBitmap = invertBitmapColors(bitmap) + imageFormat.release() + + return InputImage.fromBitmap(invertedBitmap, imageProxy.imageInfo.rotationDegrees) + } finally { + // Release resources + bitmap.recycle() // Free up bitmap memory + imageProxy.close() // Close ImageProxy + } + } + + // Efficiently invert bitmap colors using ColorMatrix + private fun invertBitmapColors(bitmap: Bitmap): Bitmap { + val colorMatrix = ColorMatrix().apply { + set(floatArrayOf( + -1f, 0f, 0f, 0f, 255f, // Red + 0f, -1f, 0f, 0f, 255f, // Green + 0f, 0f, -1f, 0f, 255f, // Blue + 0f, 0f, 0f, 1f, 0f // Alpha + )) + } + val paint = Paint().apply { colorFilter = ColorMatrixColorFilter(colorMatrix) } + + val invertedBitmap = Bitmap.createBitmap(bitmap.width, bitmap.height, bitmap.config) + val canvas = Canvas(invertedBitmap) + canvas.drawBitmap(bitmap, 0f, 0f, paint) + + return invertedBitmap + } + /** * Analyze a single image. */ @@ -530,6 +586,11 @@ class MobileScanner( camera?.cameraControl?.setLinearZoom(scale.toFloat()) } + fun setZoomRatio(zoomRatio: Double) { + if (camera == null) throw ZoomWhenStopped() + camera?.cameraControl?.setZoomRatio(zoomRatio.toFloat()) + } + /** * Reset the zoom rate of the camera. */ 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 ebbe4925..da9e1ab0 100644 --- a/android/src/main/kotlin/dev/steenbakker/mobile_scanner/MobileScannerHandler.kt +++ b/android/src/main/kotlin/dev/steenbakker/mobile_scanner/MobileScannerHandler.kt @@ -1,6 +1,9 @@ package dev.steenbakker.mobile_scanner import android.app.Activity +import android.content.Context +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraManager import android.net.Uri import android.os.Handler import android.os.Looper @@ -18,6 +21,7 @@ import io.flutter.plugin.common.PluginRegistry.RequestPermissionsResultListener import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.view.TextureRegistry import java.io.File +import com.google.mlkit.vision.barcode.ZoomSuggestionOptions class MobileScannerHandler( private val activity: Activity, @@ -138,13 +142,15 @@ class MobileScannerHandler( val speed: Int = call.argument("speed") ?: 1 val timeout: Int = call.argument("timeout") ?: 250 val cameraResolutionValues: List? = call.argument>("cameraResolution") + val autoZoom: Boolean = call.argument("autoZoom") ?: false val cameraResolution: Size? = if (cameraResolutionValues != null) { Size(cameraResolutionValues[0], cameraResolutionValues[1]) } else { null } + val invertImage: Boolean = call.argument("invertImage") ?: false - val barcodeScannerOptions: BarcodeScannerOptions? = buildBarcodeScannerOptions(formats) + val barcodeScannerOptions: BarcodeScannerOptions? = buildBarcodeScannerOptions(formats, autoZoom) val position = if (facing == 0) CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA @@ -208,7 +214,8 @@ class MobileScannerHandler( } }, timeout.toLong(), - cameraResolution + cameraResolution, + invertImage, ) } @@ -241,7 +248,7 @@ class MobileScannerHandler( mobileScanner!!.analyzeImage( Uri.fromFile(File(filePath)), - buildBarcodeScannerOptions(formats), + buildBarcodeScannerOptions(formats, false), analyzeImageSuccessCallback, analyzeImageErrorCallback) } @@ -264,6 +271,14 @@ class MobileScannerHandler( } } + private fun setZoomRatio(scale: Float) : Boolean { + try { + mobileScanner!!.setZoomRatio(scale.toDouble()) + return true + } catch (e: ZoomWhenStopped) { } + return false + } + private fun resetScale(result: MethodChannel.Result) { try { mobileScanner!!.resetScale() @@ -280,25 +295,54 @@ class MobileScannerHandler( result.success(null) } - private fun buildBarcodeScannerOptions(formats: List?): BarcodeScannerOptions? { + private fun buildBarcodeScannerOptions(formats: List?, autoZoom: Boolean): BarcodeScannerOptions? { + val builder : BarcodeScannerOptions.Builder? if (formats == null) { - return null - } + builder = BarcodeScannerOptions.Builder() + } else { + val formatsList: MutableList = mutableListOf() - val formatsList: MutableList = mutableListOf() + for (formatValue in formats) { + formatsList.add(BarcodeFormats.fromRawValue(formatValue).intValue) + } - for (formatValue in formats) { - formatsList.add(BarcodeFormats.fromRawValue(formatValue).intValue) + if (formatsList.size == 1) { + builder = BarcodeScannerOptions.Builder().setBarcodeFormats(formatsList.first()) + } else { + builder = BarcodeScannerOptions.Builder().setBarcodeFormats( + formatsList.first(), + *formatsList.subList(1, formatsList.size).toIntArray() + ) + } } - if (formatsList.size == 1) { - return BarcodeScannerOptions.Builder().setBarcodeFormats(formatsList.first()) - .build() + if (autoZoom) { + builder.setZoomSuggestionOptions( + ZoomSuggestionOptions.Builder { + setZoomRatio(it) + }.setMaxSupportedZoomRatio(getMaxZoomRatio()) + .build()) } - return BarcodeScannerOptions.Builder().setBarcodeFormats( - formatsList.first(), - *formatsList.subList(1, formatsList.size).toIntArray() - ).build() + return builder.build() + } + + private fun getMaxZoomRatio(): Float { + val cameraManager = activity.getSystemService(Context.CAMERA_SERVICE) as CameraManager + var maxZoom = 1.0F + + try { + for (cameraId in cameraManager.cameraIdList) { + val characteristics = cameraManager.getCameraCharacteristics(cameraId) + + val maxZoomRatio = characteristics.get(CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM) + if (maxZoomRatio != null && maxZoomRatio > maxZoom) { + maxZoom = maxZoomRatio + } + } + } catch (e: Exception) { + e.printStackTrace() + } + return maxZoom } } diff --git a/android/src/main/kotlin/dev/steenbakker/mobile_scanner/utils/Yuv.kt b/android/src/main/kotlin/dev/steenbakker/mobile_scanner/utils/Yuv.kt index f42d1f41..56e378d8 100644 --- a/android/src/main/kotlin/dev/steenbakker/mobile_scanner/utils/Yuv.kt +++ b/android/src/main/kotlin/dev/steenbakker/mobile_scanner/utils/Yuv.kt @@ -40,7 +40,7 @@ the conversion is done into these formats. More about each format: https://www.fourcc.org/yuv.php */ -@kotlin.annotation.Retention(AnnotationRetention.SOURCE) +@Retention(AnnotationRetention.SOURCE) @IntDef(ImageFormat.NV21, ImageFormat.YUV_420_888) annotation class YuvType @@ -181,9 +181,7 @@ class YuvByteBuffer(image: Image, dstBuffer: ByteBuffer? = null) { } } - private class PlaneWrapper(width: Int, height: Int, plane: Image.Plane) { - val width = width - val height = height + private class PlaneWrapper(val width: Int, val height: Int, plane: Image.Plane) { val buffer: ByteBuffer = plane.buffer val rowStride = plane.rowStride val pixelStride = plane.pixelStride diff --git a/android/src/main/kotlin/dev/steenbakker/mobile_scanner/utils/YuvToRgbConverter.kt b/android/src/main/kotlin/dev/steenbakker/mobile_scanner/utils/YuvToRgbConverter.kt index 734a2996..b67bfcbd 100644 --- a/android/src/main/kotlin/dev/steenbakker/mobile_scanner/utils/YuvToRgbConverter.kt +++ b/android/src/main/kotlin/dev/steenbakker/mobile_scanner/utils/YuvToRgbConverter.kt @@ -1,3 +1,5 @@ + + package dev.steenbakker.mobile_scanner.utils import android.content.Context @@ -99,4 +101,4 @@ class YuvToRgbConverter(context: Context) { scriptYuvToRgb.destroy() rs.destroy() } -} \ No newline at end of file +} diff --git a/example/lib/barcode_scanner_controller.dart b/example/lib/barcode_scanner_controller.dart index 9ad0bfb6..c9704399 100644 --- a/example/lib/barcode_scanner_controller.dart +++ b/example/lib/barcode_scanner_controller.dart @@ -18,7 +18,9 @@ class _BarcodeScannerWithControllerState extends State with WidgetsBindingObserver { final MobileScannerController controller = MobileScannerController( autoStart: false, - torchEnabled: true, + // torchEnabled: true, + autoZoom: true, + // invertImage: true, ); @override diff --git a/lib/src/mobile_scanner_controller.dart b/lib/src/mobile_scanner_controller.dart index 1c5fb702..b3c75f9b 100644 --- a/lib/src/mobile_scanner_controller.dart +++ b/lib/src/mobile_scanner_controller.dart @@ -28,6 +28,8 @@ class MobileScannerController extends ValueNotifier { this.formats = const [], this.returnImage = false, this.torchEnabled = false, + this.invertImage = false, + this.autoZoom = false, }) : detectionTimeoutMs = detectionSpeed == DetectionSpeed.normal ? detectionTimeoutMs : 0, assert( @@ -84,11 +86,23 @@ class MobileScannerController extends ValueNotifier { /// Defaults to false, and is only supported on iOS, MacOS and Android. final bool returnImage; + /// Invert image colors for analyzer to support white-on-black barcodes, which are not supported by MLKit. + /// Usage of this parameter can incur a performance cost, as frames need to be altered during processing. + /// + /// Defaults to false and is only supported on Android. + final bool invertImage; + /// Whether the flashlight should be turned on when the camera is started. /// /// Defaults to false. final bool torchEnabled; + /// Whether the camera should auto zoom if the detected code is to far from + /// the camera. + /// + /// Only supported on Android. + final bool autoZoom; + /// The internal barcode controller, that listens for detected barcodes. final StreamController _barcodesController = StreamController.broadcast(); @@ -304,6 +318,8 @@ class MobileScannerController extends ValueNotifier { formats: formats, returnImage: returnImage, torchEnabled: torchEnabled, + invertImage: invertImage, + autoZoom: autoZoom, ); try { diff --git a/lib/src/objects/start_options.dart b/lib/src/objects/start_options.dart index 6a6321d6..ce41476a 100644 --- a/lib/src/objects/start_options.dart +++ b/lib/src/objects/start_options.dart @@ -15,6 +15,8 @@ class StartOptions { required this.formats, required this.returnImage, required this.torchEnabled, + required this.invertImage, + required this.autoZoom, }); /// The direction for the camera. @@ -23,6 +25,9 @@ class StartOptions { /// The desired camera resolution for the scanner. final Size? cameraResolution; + /// Invert image colors for analyzer to support white-on-black barcodes, which are not supported by MLKit. + final bool invertImage; + /// The detection speed for the scanner. final DetectionSpeed detectionSpeed; @@ -38,6 +43,12 @@ class StartOptions { /// Whether the torch should be turned on when the scanner starts. final bool torchEnabled; + /// Whether the camera should auto zoom if the detected code is to far from + /// the camera. + /// + /// This option is only supported on Android. Other platforms will ignore this option. + final bool autoZoom; + /// Converts this object to a map. Map toMap() { return { @@ -53,6 +64,8 @@ class StartOptions { 'speed': detectionSpeed.rawValue, 'timeout': detectionTimeoutMs, 'torch': torchEnabled, + 'invertImage': invertImage, + 'autoZoom': autoZoom, }; } }