Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ androidx-multidex = "2.0.1"
androidx-core-splashscreen = "1.0.1"
androidx-navigation-compose = "2.9.2"
androidx-datastore = "1.1.7"
androidx-camera = "1.5.0"
androidx-camera = "1.5.3"

# -------------------- UI / Compose --------------------
compose-material-icons = "1.8.2"
Expand Down
4 changes: 2 additions & 2 deletions shared/kscan/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ Mobile.

Based on:

* Commit: [afa2caaf14](https://github.com/ismai117/KScan/commit/afa2caaf14614f0d7e08ec34c366d4ec23e2ad11)
* Release tag: [0.4.0](https://github.com/ismai117/KScan/releases/tag/0.4.0)
- Commit: [de1ed36860](https://github.com/ismai117/KScan/commit/de1ed36860b074605b510dabe5bcd0fd8e1358ef)
- Release tag: [0.6.0](https://github.com/ismai117/KScan/releases/tag/0.6.0)
Original file line number Diff line number Diff line change
@@ -1,55 +1,22 @@
package org.ncgroup.kscan

import androidx.annotation.OptIn
import androidx.camera.core.Camera
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.ZoomSuggestionOptions
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_ALL_FORMATS
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_AZTEC
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_CODABAR
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_CODE_128
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_CODE_39
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_CODE_93
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_DATA_MATRIX
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_EAN_13
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_EAN_8
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_ITF
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_PDF417
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_QR_CODE
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_UNKNOWN
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_UPC_A
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_UPC_E
import com.google.mlkit.vision.common.InputImage

/**
* Analyzes images for barcodes using ML Kit.
* Analyzes camera frames for barcodes using ML Kit.
*
* This class implements [ImageAnalysis.Analyzer] to process camera frames.
* It uses ML Kit's Barcode Scanning API to detect and decode barcodes.
*
* It features:
* - **Configurable Barcode Types**: Scans for specific barcode formats defined by `codeTypes`.
* - **Zoom Suggestion**: Utilizes ML Kit's zoom suggestion feature to prompt the user to zoom if a barcode is detected but is too small. The zoom is handled automatically if the camera supports it.
* - **Duplicate Filtering**: To ensure accuracy and prevent multiple triggers for the same barcode, a barcode must be detected twice in quick succession before it's considered successfully processed.
* - **Single Success Processing**: Once a barcode is successfully processed (detected twice), further analysis is stopped to prevent redundant callbacks.
* - **Callbacks**:
* - `onSuccess`: Called when a barcode is successfully detected and meets the criteria.
* - `onFailed`: Called if an error occurs during the barcode scanning process.
* - `onCanceled`: Called if the barcode scanning task is canceled.
*
* The analyzer maps ML Kit's barcode formats to a custom `BarcodeFormat` enum for application-specific use.
*
* @property camera The [Camera] instance, used for zoom control. Can be null if zoom control is not needed or available.
* @property codeTypes A list of [BarcodeFormat] enums specifying which barcode types to scan for. If empty or contains `BarcodeFormat.FORMAT_ALL_FORMATS`, all supported formats are scanned.
* @property onSuccess A callback function that is invoked when a barcode is successfully detected and validated. It receives a list containing the single detected [Barcode].
* @property onFailed A callback function that is invoked when an error occurs during the image analysis or barcode scanning process. It receives the [Exception] that occurred.
* @property onCanceled A callback function that is invoked if the barcode scanning task is canceled.
* Features duplicate filtering (barcode must be detected twice) and auto-zoom suggestions.
*/
class BarcodeAnalyzer(
private val camera: Camera?,
private val getCamera: () -> Camera?,
private val codeTypes: List<BarcodeFormat>,
private val onSuccess: (List<Barcode>) -> Unit,
private val onFailed: (Exception) -> Unit,
Expand All @@ -59,10 +26,11 @@ class BarcodeAnalyzer(
private val scannerOptions =
BarcodeScannerOptions
.Builder()
.setBarcodeFormats(getMLKitBarcodeFormats(codeTypes))
.setBarcodeFormats(BarcodeFormatMapper.toMlKitFormats(codeTypes))
.setZoomSuggestionOptions(
ZoomSuggestionOptions
.Builder { zoomRatio ->
val camera = getCamera()
val maxZoomRatio =
(
camera
Expand All @@ -83,9 +51,9 @@ class BarcodeAnalyzer(

private val scanner = BarcodeScanning.getClient(scannerOptions)
private val barcodesDetected = mutableMapOf<String, Int>()
private var hasSuccessfullyProcessedBarcode = false //
private var hasSuccessfullyProcessedBarcode = false

@androidx.annotation.OptIn(ExperimentalGetImage::class)
@OptIn(ExperimentalGetImage::class)
override fun analyze(imageProxy: ImageProxy) {
if (hasSuccessfullyProcessedBarcode) {
imageProxy.close()
Expand All @@ -106,18 +74,87 @@ class BarcodeAnalyzer(
val relevantBarcodes = barcodes.filter { isRequestedFormat(it) }
if (relevantBarcodes.isNotEmpty()) {
processFoundBarcodes(relevantBarcodes)
imageProxy.close()
} else {
// If no barcodes found, try scanning the inverted image
scanInverted(imageProxy)
}
}.addOnFailureListener {
onFailed(it)
imageProxy.close()
}.addOnCanceledListener {
onCanceled()
imageProxy.close()
}
}

private fun scanInverted(imageProxy: ImageProxy) {
val invertedImage =
try {
createInvertedInputImage(imageProxy)
} catch (e: Exception) {
// Conversion failed, clean up and exit
imageProxy.close()
return
}

scanner
.process(invertedImage)
.addOnSuccessListener { barcodes ->
val relevantBarcodes = barcodes.filter { isRequestedFormat(it) }
if (relevantBarcodes.isNotEmpty()) {
processFoundBarcodes(relevantBarcodes)
}
}.addOnFailureListener {
onFailed(it)
}.addOnCanceledListener {
onCanceled()
}.addOnCompleteListener {
// CRITICAL: Always close the proxy after the final attempt
imageProxy.close()
}
}

@OptIn(ExperimentalGetImage::class)
private fun createInvertedInputImage(imageProxy: ImageProxy): InputImage {
val mediaImage = imageProxy.image ?: throw IllegalArgumentException("Image is null")
require(mediaImage.planes.isNotEmpty()) { "Image has no planes" }

val width = mediaImage.width
val height = mediaImage.height
val yPixelCount = width * height
val nv21Bytes = ByteArray(yPixelCount * 3 / 2)

val yPlane = mediaImage.planes[0]
val rowStride = yPlane.rowStride
require(rowStride >= width) { "Invalid Y rowStride: $rowStride, width: $width" }

val yBuffer = yPlane.buffer.duplicate()
val rowBytes = ByteArray(width)

// Bulk-read one row at a time, then invert into output (fewer ByteBuffer.get() calls)
for (row in 0 until height) {
yBuffer.position(row * rowStride)
yBuffer.get(rowBytes, 0, width)

val outBase = row * width
for (col in 0 until width) {
nv21Bytes[outBase + col] = (rowBytes[col].toInt() xor 0xFF).toByte()
}
}

// Neutral chroma for grayscale in NV21 (VU interleaved)
java.util.Arrays.fill(nv21Bytes, yPixelCount, nv21Bytes.size, 128.toByte())

return InputImage.fromByteArray(
nv21Bytes,
width,
height,
imageProxy.imageInfo.rotationDegrees,
InputImage.IMAGE_FORMAT_NV21,
)
}

private fun processFoundBarcodes(mlKitBarcodes: List<com.google.mlkit.vision.barcode.common.Barcode>) {
if (hasSuccessfullyProcessedBarcode) return

Expand All @@ -127,7 +164,7 @@ class BarcodeAnalyzer(

barcodesDetected[displayValue] = (barcodesDetected[displayValue] ?: 0) + 1
if ((barcodesDetected[displayValue] ?: 0) >= 2) {
val appSpecificFormat = mlKitFormatToAppFormat(mlKitBarcode.format)
val appSpecificFormat = BarcodeFormatMapper.toAppFormat(mlKitBarcode.format)
val detectedAppBarcode =
Barcode(
data = displayValue,
Expand All @@ -147,47 +184,9 @@ class BarcodeAnalyzer(

private fun isRequestedFormat(mlKitBarcode: com.google.mlkit.vision.barcode.common.Barcode): Boolean {
if (codeTypes.contains(BarcodeFormat.FORMAT_ALL_FORMATS)) {
return MLKIT_TO_APP_FORMAT_MAP.containsKey(mlKitBarcode.format)
return BarcodeFormatMapper.isKnownFormat(mlKitBarcode.format)
}
val appFormat = mlKitFormatToAppFormat(mlKitBarcode.format)
val appFormat = BarcodeFormatMapper.toAppFormat(mlKitBarcode.format)
return codeTypes.contains(appFormat)
}

companion object {
private val APP_TO_MLKIT_FORMAT_MAP: Map<BarcodeFormat, Int> =
mapOf(
BarcodeFormat.FORMAT_QR_CODE to FORMAT_QR_CODE,
BarcodeFormat.FORMAT_CODE_128 to FORMAT_CODE_128,
BarcodeFormat.FORMAT_CODE_39 to FORMAT_CODE_39,
BarcodeFormat.FORMAT_CODE_93 to FORMAT_CODE_93,
BarcodeFormat.FORMAT_CODABAR to FORMAT_CODABAR,
BarcodeFormat.FORMAT_DATA_MATRIX to FORMAT_DATA_MATRIX,
BarcodeFormat.FORMAT_EAN_13 to FORMAT_EAN_13,
BarcodeFormat.FORMAT_EAN_8 to FORMAT_EAN_8,
BarcodeFormat.FORMAT_ITF to FORMAT_ITF,
BarcodeFormat.FORMAT_UPC_A to FORMAT_UPC_A,
BarcodeFormat.FORMAT_UPC_E to FORMAT_UPC_E,
BarcodeFormat.FORMAT_PDF417 to FORMAT_PDF417,
BarcodeFormat.FORMAT_AZTEC to FORMAT_AZTEC,
)

private val MLKIT_TO_APP_FORMAT_MAP: Map<Int, BarcodeFormat> =
APP_TO_MLKIT_FORMAT_MAP.entries
.associateBy({ it.value }) { it.key }
.plus(FORMAT_UNKNOWN to BarcodeFormat.TYPE_UNKNOWN)

fun getMLKitBarcodeFormats(appFormats: List<BarcodeFormat>): Int {
if (appFormats.isEmpty() || appFormats.contains(BarcodeFormat.FORMAT_ALL_FORMATS)) {
return FORMAT_ALL_FORMATS
}

return appFormats
.mapNotNull { APP_TO_MLKIT_FORMAT_MAP[it] }
.distinct()
.fold(0) { acc, formatInt -> acc or formatInt }
.let { if (it == 0) FORMAT_ALL_FORMATS else it }
}

fun mlKitFormatToAppFormat(mlKitFormat: Int): BarcodeFormat = MLKIT_TO_APP_FORMAT_MAP[mlKitFormat] ?: BarcodeFormat.TYPE_UNKNOWN
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package org.ncgroup.kscan

import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_ALL_FORMATS
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_AZTEC
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_CODABAR
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_CODE_128
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_CODE_39
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_CODE_93
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_DATA_MATRIX
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_EAN_13
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_EAN_8
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_ITF
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_PDF417
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_QR_CODE
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_UNKNOWN
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_UPC_A
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_UPC_E

/**
* Maps between app [BarcodeFormat] and ML Kit barcode format integers.
*/
internal object BarcodeFormatMapper {
private val APP_TO_MLKIT_FORMAT_MAP: Map<BarcodeFormat, Int> =
mapOf(
BarcodeFormat.FORMAT_QR_CODE to FORMAT_QR_CODE,
BarcodeFormat.FORMAT_CODE_128 to FORMAT_CODE_128,
BarcodeFormat.FORMAT_CODE_39 to FORMAT_CODE_39,
BarcodeFormat.FORMAT_CODE_93 to FORMAT_CODE_93,
BarcodeFormat.FORMAT_CODABAR to FORMAT_CODABAR,
BarcodeFormat.FORMAT_DATA_MATRIX to FORMAT_DATA_MATRIX,
BarcodeFormat.FORMAT_EAN_13 to FORMAT_EAN_13,
BarcodeFormat.FORMAT_EAN_8 to FORMAT_EAN_8,
BarcodeFormat.FORMAT_ITF to FORMAT_ITF,
BarcodeFormat.FORMAT_UPC_A to FORMAT_UPC_A,
BarcodeFormat.FORMAT_UPC_E to FORMAT_UPC_E,
BarcodeFormat.FORMAT_PDF417 to FORMAT_PDF417,
BarcodeFormat.FORMAT_AZTEC to FORMAT_AZTEC,
)

private val MLKIT_TO_APP_FORMAT_MAP: Map<Int, BarcodeFormat> =
APP_TO_MLKIT_FORMAT_MAP.entries
.associateBy({ it.value }) { it.key }
.plus(FORMAT_UNKNOWN to BarcodeFormat.TYPE_UNKNOWN)

fun toMlKitFormats(appFormats: List<BarcodeFormat>): Int {
if (appFormats.isEmpty() || appFormats.contains(BarcodeFormat.FORMAT_ALL_FORMATS)) {
return FORMAT_ALL_FORMATS
}

return appFormats
.mapNotNull { APP_TO_MLKIT_FORMAT_MAP[it] }
.distinct()
.fold(0) { acc, formatInt -> acc or formatInt }
.let { if (it == 0) FORMAT_ALL_FORMATS else it }
}

fun toAppFormat(mlKitFormat: Int): BarcodeFormat = MLKIT_TO_APP_FORMAT_MAP[mlKitFormat] ?: BarcodeFormat.TYPE_UNKNOWN

fun isKnownFormat(mlKitFormat: Int): Boolean = MLKIT_TO_APP_FORMAT_MAP.containsKey(mlKitFormat)
}

This file was deleted.

Loading
Loading