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

feat: Ability to scan both normal codes and inverted codes #1215

Open
wants to merge 30 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
dff1935
Feature: Read inverted data matrix
RafaRuiz Oct 12, 2024
740b720
Feature: Read inverted data matrix
RafaRuiz Oct 12, 2024
ebfe698
Clean up
RafaRuiz Oct 12, 2024
b1bb926
Update lib/src/objects/start_options.dart
RafaRuiz Oct 14, 2024
25e52d7
Update lib/src/mobile_scanner_controller.dart
RafaRuiz Oct 14, 2024
88b7b67
Update android/src/main/kotlin/dev/steenbakker/mobile_scanner/MobileS…
RafaRuiz Oct 14, 2024
89d0f20
Update ios/Classes/MobileScanner.swift
RafaRuiz Oct 14, 2024
5ceb867
Rename feature
RafaRuiz Oct 18, 2024
989f441
Rename methods and variables
RafaRuiz Oct 18, 2024
71d1e83
Wording
RafaRuiz Oct 18, 2024
1e76615
Refactor the way to invert the image, applying the conversion to NV21…
RafaRuiz Oct 18, 2024
c8f48b2
Refactor convertCIImageToCGImage
RafaRuiz Oct 18, 2024
96becf0
redundant temp variable
RafaRuiz Oct 18, 2024
d8512f4
rename var
RafaRuiz Oct 18, 2024
62a728d
let
RafaRuiz Oct 18, 2024
638c94b
private
RafaRuiz Oct 18, 2024
b7080a6
CIFilter magic string
RafaRuiz Oct 18, 2024
47f8155
Merge branch 'master' into master
RafaRuiz Oct 18, 2024
c1313ef
let uiimage
RafaRuiz Oct 18, 2024
ebae80d
make CIFilter API available
RafaRuiz Oct 18, 2024
a386275
Merge branch 'master' into master-cv
juliansteenbakker Jan 16, 2025
1debc4a
Merge branch 'develop' into master-rafaruiz
juliansteenbakker Jan 17, 2025
0b81241
fix: remove merge conflicts
juliansteenbakker Jan 17, 2025
46850cd
style: format
juliansteenbakker Jan 17, 2025
0277f9b
imp: improve scan speed for inverted images
juliansteenbakker Jan 17, 2025
f1a60d1
imp: update parameter name, remove unused method, suppress deprecatio…
juliansteenbakker Jan 19, 2025
a6cbfaf
style: remove blank line
juliansteenbakker Jan 21, 2025
6f45752
style: rename invertImages to invertImage
juliansteenbakker Jan 21, 2025
f420ab7
style: inline deprecation
juliansteenbakker Jan 21, 2025
ab2021e
style: update docs
juliansteenbakker Jan 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -59,6 +63,7 @@ class MobileScanner(

/// Configurable variables
var scanWindow: List<Float>? = null
private var invertImage: Boolean = false
RafaRuiz marked this conversation as resolved.
Show resolved Hide resolved
private var detectionSpeed: DetectionSpeed = DetectionSpeed.NO_DUPLICATES
private var detectionTimeout: Long = 250
private var returnImage = false
Expand All @@ -79,7 +84,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()
Expand Down Expand Up @@ -127,14 +137,13 @@ 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
}

val bitmap = Bitmap.createBitmap(mediaImage.width, mediaImage.height, Bitmap.Config.ARGB_8888)
val imageFormat = YuvToRgbConverter(activity.applicationContext)

imageFormat.yuvToRgb(mediaImage, bitmap)

val bmResult = rotateBitmap(bitmap, camera?.cameraInfo?.sensorRotationDegrees?.toFloat() ?: 90f)
Expand All @@ -145,6 +154,7 @@ class MobileScanner(
val bmWidth = bmResult.width
val bmHeight = bmResult.height
bmResult.recycle()
imageFormat.release()

mobileScannerCallback(
barcodeMap,
Expand Down Expand Up @@ -219,11 +229,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) {

Expand Down Expand Up @@ -416,14 +428,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() {
juliansteenbakker marked this conversation as resolved.
Show resolved Hide resolved
// // 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) {
Expand Down Expand Up @@ -472,6 +484,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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ class MobileScannerHandler(
} else {
null
}
val invertImage: Boolean = call.argument<Boolean>("invertImage") ?: false

val barcodeScannerOptions: BarcodeScannerOptions? = buildBarcodeScannerOptions(formats)

Expand Down Expand Up @@ -208,7 +209,8 @@ class MobileScannerHandler(
}
},
timeout.toLong(),
cameraResolution
cameraResolution,
invertImage,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@


package dev.steenbakker.mobile_scanner.utils

import android.content.Context
import android.graphics.Bitmap
import android.graphics.ImageFormat
import android.media.Image
import android.os.Build
import android.renderscript.Allocation
import android.renderscript.Element
import android.renderscript.RenderScript
import android.renderscript.ScriptIntrinsicYuvToRGB
import android.renderscript.Type
import androidx.annotation.RequiresApi
import java.nio.ByteBuffer


/**
* Helper class used to efficiently convert a [Media.Image] object from
* YUV_420_888 format to an RGB [Bitmap] object.
Expand All @@ -23,59 +21,84 @@ import java.nio.ByteBuffer
* The [yuvToRgb] method is able to achieve the same FPS as the CameraX image
* analysis use case at the default analyzer resolution, which is 30 FPS with
* 640x480 on a Pixel 3 XL device.
*/class YuvToRgbConverter(context: Context) {
*/
/// TODO: Upgrade to implementation without deprecated android.renderscript, but with same or better performance. See https://github.com/juliansteenbakker/mobile_scanner/issues/1142
class YuvToRgbConverter(context: Context) {
@Suppress("DEPRECATION")
private val rs = RenderScript.create(context)
@Suppress("DEPRECATION")
private val scriptYuvToRgb =
ScriptIntrinsicYuvToRGB.create(rs, Element.U8_4(rs))

// Do not add getters/setters functions to these private variables
// because yuvToRgb() assume they won't be modified elsewhere
private var yuvBits: ByteBuffer? = null
private var bytes: ByteArray = ByteArray(0)
@Suppress("DEPRECATION")
private var inputAllocation: Allocation? = null
@Suppress("DEPRECATION")
private var outputAllocation: Allocation? = null

@Synchronized
fun yuvToRgb(image: Image, output: Bitmap) {
val yuvBuffer = YuvByteBuffer(image, yuvBits)
yuvBits = yuvBuffer.buffer
try {
val yuvBuffer = YuvByteBuffer(image, yuvBits)
yuvBits = yuvBuffer.buffer

if (needCreateAllocations(image, yuvBuffer)) {
val yuvType = Type.Builder(rs, Element.U8(rs))
.setX(image.width)
.setY(image.height)
.setYuvFormat(yuvBuffer.type)
inputAllocation = Allocation.createTyped(
rs,
yuvType.create(),
Allocation.USAGE_SCRIPT
)
bytes = ByteArray(yuvBuffer.buffer.capacity())
val rgbaType = Type.Builder(rs, Element.RGBA_8888(rs))
.setX(image.width)
.setY(image.height)
outputAllocation = Allocation.createTyped(
rs,
rgbaType.create(),
Allocation.USAGE_SCRIPT
)
}
if (needCreateAllocations(image, yuvBuffer)) {
createAllocations(image, yuvBuffer)
}

yuvBuffer.buffer.get(bytes)
inputAllocation!!.copyFrom(bytes)
yuvBuffer.buffer.get(bytes)
@Suppress("DEPRECATION")
inputAllocation!!.copyFrom(bytes)

// Convert NV21 or YUV_420_888 format to RGB
inputAllocation!!.copyFrom(bytes)
scriptYuvToRgb.setInput(inputAllocation)
scriptYuvToRgb.forEach(outputAllocation)
outputAllocation!!.copyTo(output)
@Suppress("DEPRECATION")
scriptYuvToRgb.setInput(inputAllocation)
@Suppress("DEPRECATION")
scriptYuvToRgb.forEach(outputAllocation)
@Suppress("DEPRECATION")
outputAllocation!!.copyTo(output)
} catch (e: Exception) {
throw IllegalStateException("Failed to convert YUV to RGB", e)
}
}

private fun needCreateAllocations(image: Image, yuvBuffer: YuvByteBuffer): Boolean {
return (inputAllocation == null || // the very 1st call
inputAllocation!!.type.x != image.width || // image size changed
inputAllocation!!.type.y != image.height ||
inputAllocation!!.type.yuv != yuvBuffer.type || // image format changed
bytes.size == yuvBuffer.buffer.capacity())
@Suppress("DEPRECATION")
return inputAllocation?.type?.x != image.width ||
inputAllocation?.type?.y != image.height ||
inputAllocation?.type?.yuv != yuvBuffer.type
}

private fun createAllocations(image: Image, yuvBuffer: YuvByteBuffer) {
@Suppress("DEPRECATION")
val yuvType = Type.Builder(rs, Element.U8(rs))
.setX(image.width)
.setY(image.height)
.setYuvFormat(yuvBuffer.type)
@Suppress("DEPRECATION")
inputAllocation = Allocation.createTyped(
rs,
yuvType.create(),
Allocation.USAGE_SCRIPT
)
bytes = ByteArray(yuvBuffer.buffer.capacity())
@Suppress("DEPRECATION")
val rgbaType = Type.Builder(rs, Element.RGBA_8888(rs))
.setX(image.width)
.setY(image.height)
@Suppress("DEPRECATION")
outputAllocation = Allocation.createTyped(
rs,
rgbaType.create(),
Allocation.USAGE_SCRIPT
)
}

@Suppress("DEPRECATION")
fun release() {
inputAllocation?.destroy()
outputAllocation?.destroy()
scriptYuvToRgb.destroy()
rs.destroy()
}
}
}
2 changes: 1 addition & 1 deletion example/lib/barcode_scanner_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class _BarcodeScannerWithControllerState
extends State<BarcodeScannerWithController> with WidgetsBindingObserver {
final MobileScannerController controller = MobileScannerController(
autoStart: false,
torchEnabled: true,
juliansteenbakker marked this conversation as resolved.
Show resolved Hide resolved
invertImage: true,
);

@override
Expand Down
8 changes: 8 additions & 0 deletions lib/src/mobile_scanner_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class MobileScannerController extends ValueNotifier<MobileScannerState> {
this.formats = const <BarcodeFormat>[],
this.returnImage = false,
this.torchEnabled = false,
this.invertImage = false,
}) : detectionTimeoutMs =
detectionSpeed == DetectionSpeed.normal ? detectionTimeoutMs : 0,
assert(
Expand Down Expand Up @@ -84,6 +85,12 @@ class MobileScannerController extends ValueNotifier<MobileScannerState> {
/// 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.
Expand Down Expand Up @@ -304,6 +311,7 @@ class MobileScannerController extends ValueNotifier<MobileScannerState> {
formats: formats,
returnImage: returnImage,
torchEnabled: torchEnabled,
invertImage: invertImage,
);

try {
Expand Down
5 changes: 5 additions & 0 deletions lib/src/objects/start_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class StartOptions {
required this.formats,
required this.returnImage,
required this.torchEnabled,
required this.invertImage,
});

/// The direction for the camera.
Expand All @@ -23,6 +24,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;

Expand Down Expand Up @@ -53,6 +57,7 @@ class StartOptions {
'speed': detectionSpeed.rawValue,
'timeout': detectionTimeoutMs,
'torch': torchEnabled,
'invertImage': invertImage,
};
}
}
Loading