Skip to content

Commit 921385e

Browse files
authored
chore: update Kscan to 0.6.0 (#1164)
1 parent cb75ab2 commit 921385e

File tree

17 files changed

+563
-514
lines changed

17 files changed

+563
-514
lines changed

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ androidx-multidex = "2.0.1"
3838
androidx-core-splashscreen = "1.0.1"
3939
androidx-navigation-compose = "2.9.2"
4040
androidx-datastore = "1.1.7"
41-
androidx-camera = "1.5.0"
41+
androidx-camera = "1.5.3"
4242

4343
# -------------------- UI / Compose --------------------
4444
compose-material-icons = "1.8.2"

shared/kscan/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@ Mobile.
66

77
Based on:
88

9-
* Commit: [afa2caaf14](https://github.com/ismai117/KScan/commit/afa2caaf14614f0d7e08ec34c366d4ec23e2ad11)
10-
* Release tag: [0.4.0](https://github.com/ismai117/KScan/releases/tag/0.4.0)
9+
- Commit: [de1ed36860](https://github.com/ismai117/KScan/commit/de1ed36860b074605b510dabe5bcd0fd8e1358ef)
10+
- Release tag: [0.6.0](https://github.com/ismai117/KScan/releases/tag/0.6.0)
Lines changed: 80 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,22 @@
11
package org.ncgroup.kscan
22

3+
import androidx.annotation.OptIn
34
import androidx.camera.core.Camera
45
import androidx.camera.core.ExperimentalGetImage
56
import androidx.camera.core.ImageAnalysis
67
import androidx.camera.core.ImageProxy
78
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
89
import com.google.mlkit.vision.barcode.BarcodeScanning
910
import com.google.mlkit.vision.barcode.ZoomSuggestionOptions
10-
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_ALL_FORMATS
11-
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_AZTEC
12-
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_CODABAR
13-
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_CODE_128
14-
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_CODE_39
15-
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_CODE_93
16-
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_DATA_MATRIX
17-
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_EAN_13
18-
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_EAN_8
19-
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_ITF
20-
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_PDF417
21-
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_QR_CODE
22-
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_UNKNOWN
23-
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_UPC_A
24-
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_UPC_E
2511
import com.google.mlkit.vision.common.InputImage
2612

2713
/**
28-
* Analyzes images for barcodes using ML Kit.
14+
* Analyzes camera frames for barcodes using ML Kit.
2915
*
30-
* This class implements [ImageAnalysis.Analyzer] to process camera frames.
31-
* It uses ML Kit's Barcode Scanning API to detect and decode barcodes.
32-
*
33-
* It features:
34-
* - **Configurable Barcode Types**: Scans for specific barcode formats defined by `codeTypes`.
35-
* - **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.
36-
* - **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.
37-
* - **Single Success Processing**: Once a barcode is successfully processed (detected twice), further analysis is stopped to prevent redundant callbacks.
38-
* - **Callbacks**:
39-
* - `onSuccess`: Called when a barcode is successfully detected and meets the criteria.
40-
* - `onFailed`: Called if an error occurs during the barcode scanning process.
41-
* - `onCanceled`: Called if the barcode scanning task is canceled.
42-
*
43-
* The analyzer maps ML Kit's barcode formats to a custom `BarcodeFormat` enum for application-specific use.
44-
*
45-
* @property camera The [Camera] instance, used for zoom control. Can be null if zoom control is not needed or available.
46-
* @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.
47-
* @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].
48-
* @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.
49-
* @property onCanceled A callback function that is invoked if the barcode scanning task is canceled.
16+
* Features duplicate filtering (barcode must be detected twice) and auto-zoom suggestions.
5017
*/
5118
class BarcodeAnalyzer(
52-
private val camera: Camera?,
19+
private val getCamera: () -> Camera?,
5320
private val codeTypes: List<BarcodeFormat>,
5421
private val onSuccess: (List<Barcode>) -> Unit,
5522
private val onFailed: (Exception) -> Unit,
@@ -59,10 +26,11 @@ class BarcodeAnalyzer(
5926
private val scannerOptions =
6027
BarcodeScannerOptions
6128
.Builder()
62-
.setBarcodeFormats(getMLKitBarcodeFormats(codeTypes))
29+
.setBarcodeFormats(BarcodeFormatMapper.toMlKitFormats(codeTypes))
6330
.setZoomSuggestionOptions(
6431
ZoomSuggestionOptions
6532
.Builder { zoomRatio ->
33+
val camera = getCamera()
6634
val maxZoomRatio =
6735
(
6836
camera
@@ -83,9 +51,9 @@ class BarcodeAnalyzer(
8351

8452
private val scanner = BarcodeScanning.getClient(scannerOptions)
8553
private val barcodesDetected = mutableMapOf<String, Int>()
86-
private var hasSuccessfullyProcessedBarcode = false //
54+
private var hasSuccessfullyProcessedBarcode = false
8755

88-
@androidx.annotation.OptIn(ExperimentalGetImage::class)
56+
@OptIn(ExperimentalGetImage::class)
8957
override fun analyze(imageProxy: ImageProxy) {
9058
if (hasSuccessfullyProcessedBarcode) {
9159
imageProxy.close()
@@ -106,18 +74,87 @@ class BarcodeAnalyzer(
10674
val relevantBarcodes = barcodes.filter { isRequestedFormat(it) }
10775
if (relevantBarcodes.isNotEmpty()) {
10876
processFoundBarcodes(relevantBarcodes)
77+
imageProxy.close()
78+
} else {
79+
// If no barcodes found, try scanning the inverted image
80+
scanInverted(imageProxy)
10981
}
11082
}.addOnFailureListener {
11183
onFailed(it)
11284
imageProxy.close()
11385
}.addOnCanceledListener {
11486
onCanceled()
11587
imageProxy.close()
88+
}
89+
}
90+
91+
private fun scanInverted(imageProxy: ImageProxy) {
92+
val invertedImage =
93+
try {
94+
createInvertedInputImage(imageProxy)
95+
} catch (e: Exception) {
96+
// Conversion failed, clean up and exit
97+
imageProxy.close()
98+
return
99+
}
100+
101+
scanner
102+
.process(invertedImage)
103+
.addOnSuccessListener { barcodes ->
104+
val relevantBarcodes = barcodes.filter { isRequestedFormat(it) }
105+
if (relevantBarcodes.isNotEmpty()) {
106+
processFoundBarcodes(relevantBarcodes)
107+
}
108+
}.addOnFailureListener {
109+
onFailed(it)
110+
}.addOnCanceledListener {
111+
onCanceled()
116112
}.addOnCompleteListener {
113+
// CRITICAL: Always close the proxy after the final attempt
117114
imageProxy.close()
118115
}
119116
}
120117

118+
@OptIn(ExperimentalGetImage::class)
119+
private fun createInvertedInputImage(imageProxy: ImageProxy): InputImage {
120+
val mediaImage = imageProxy.image ?: throw IllegalArgumentException("Image is null")
121+
require(mediaImage.planes.isNotEmpty()) { "Image has no planes" }
122+
123+
val width = mediaImage.width
124+
val height = mediaImage.height
125+
val yPixelCount = width * height
126+
val nv21Bytes = ByteArray(yPixelCount * 3 / 2)
127+
128+
val yPlane = mediaImage.planes[0]
129+
val rowStride = yPlane.rowStride
130+
require(rowStride >= width) { "Invalid Y rowStride: $rowStride, width: $width" }
131+
132+
val yBuffer = yPlane.buffer.duplicate()
133+
val rowBytes = ByteArray(width)
134+
135+
// Bulk-read one row at a time, then invert into output (fewer ByteBuffer.get() calls)
136+
for (row in 0 until height) {
137+
yBuffer.position(row * rowStride)
138+
yBuffer.get(rowBytes, 0, width)
139+
140+
val outBase = row * width
141+
for (col in 0 until width) {
142+
nv21Bytes[outBase + col] = (rowBytes[col].toInt() xor 0xFF).toByte()
143+
}
144+
}
145+
146+
// Neutral chroma for grayscale in NV21 (VU interleaved)
147+
java.util.Arrays.fill(nv21Bytes, yPixelCount, nv21Bytes.size, 128.toByte())
148+
149+
return InputImage.fromByteArray(
150+
nv21Bytes,
151+
width,
152+
height,
153+
imageProxy.imageInfo.rotationDegrees,
154+
InputImage.IMAGE_FORMAT_NV21,
155+
)
156+
}
157+
121158
private fun processFoundBarcodes(mlKitBarcodes: List<com.google.mlkit.vision.barcode.common.Barcode>) {
122159
if (hasSuccessfullyProcessedBarcode) return
123160

@@ -127,7 +164,7 @@ class BarcodeAnalyzer(
127164

128165
barcodesDetected[displayValue] = (barcodesDetected[displayValue] ?: 0) + 1
129166
if ((barcodesDetected[displayValue] ?: 0) >= 2) {
130-
val appSpecificFormat = mlKitFormatToAppFormat(mlKitBarcode.format)
167+
val appSpecificFormat = BarcodeFormatMapper.toAppFormat(mlKitBarcode.format)
131168
val detectedAppBarcode =
132169
Barcode(
133170
data = displayValue,
@@ -147,47 +184,9 @@ class BarcodeAnalyzer(
147184

148185
private fun isRequestedFormat(mlKitBarcode: com.google.mlkit.vision.barcode.common.Barcode): Boolean {
149186
if (codeTypes.contains(BarcodeFormat.FORMAT_ALL_FORMATS)) {
150-
return MLKIT_TO_APP_FORMAT_MAP.containsKey(mlKitBarcode.format)
187+
return BarcodeFormatMapper.isKnownFormat(mlKitBarcode.format)
151188
}
152-
val appFormat = mlKitFormatToAppFormat(mlKitBarcode.format)
189+
val appFormat = BarcodeFormatMapper.toAppFormat(mlKitBarcode.format)
153190
return codeTypes.contains(appFormat)
154191
}
155-
156-
companion object {
157-
private val APP_TO_MLKIT_FORMAT_MAP: Map<BarcodeFormat, Int> =
158-
mapOf(
159-
BarcodeFormat.FORMAT_QR_CODE to FORMAT_QR_CODE,
160-
BarcodeFormat.FORMAT_CODE_128 to FORMAT_CODE_128,
161-
BarcodeFormat.FORMAT_CODE_39 to FORMAT_CODE_39,
162-
BarcodeFormat.FORMAT_CODE_93 to FORMAT_CODE_93,
163-
BarcodeFormat.FORMAT_CODABAR to FORMAT_CODABAR,
164-
BarcodeFormat.FORMAT_DATA_MATRIX to FORMAT_DATA_MATRIX,
165-
BarcodeFormat.FORMAT_EAN_13 to FORMAT_EAN_13,
166-
BarcodeFormat.FORMAT_EAN_8 to FORMAT_EAN_8,
167-
BarcodeFormat.FORMAT_ITF to FORMAT_ITF,
168-
BarcodeFormat.FORMAT_UPC_A to FORMAT_UPC_A,
169-
BarcodeFormat.FORMAT_UPC_E to FORMAT_UPC_E,
170-
BarcodeFormat.FORMAT_PDF417 to FORMAT_PDF417,
171-
BarcodeFormat.FORMAT_AZTEC to FORMAT_AZTEC,
172-
)
173-
174-
private val MLKIT_TO_APP_FORMAT_MAP: Map<Int, BarcodeFormat> =
175-
APP_TO_MLKIT_FORMAT_MAP.entries
176-
.associateBy({ it.value }) { it.key }
177-
.plus(FORMAT_UNKNOWN to BarcodeFormat.TYPE_UNKNOWN)
178-
179-
fun getMLKitBarcodeFormats(appFormats: List<BarcodeFormat>): Int {
180-
if (appFormats.isEmpty() || appFormats.contains(BarcodeFormat.FORMAT_ALL_FORMATS)) {
181-
return FORMAT_ALL_FORMATS
182-
}
183-
184-
return appFormats
185-
.mapNotNull { APP_TO_MLKIT_FORMAT_MAP[it] }
186-
.distinct()
187-
.fold(0) { acc, formatInt -> acc or formatInt }
188-
.let { if (it == 0) FORMAT_ALL_FORMATS else it }
189-
}
190-
191-
fun mlKitFormatToAppFormat(mlKitFormat: Int): BarcodeFormat = MLKIT_TO_APP_FORMAT_MAP[mlKitFormat] ?: BarcodeFormat.TYPE_UNKNOWN
192-
}
193192
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package org.ncgroup.kscan
2+
3+
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_ALL_FORMATS
4+
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_AZTEC
5+
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_CODABAR
6+
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_CODE_128
7+
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_CODE_39
8+
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_CODE_93
9+
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_DATA_MATRIX
10+
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_EAN_13
11+
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_EAN_8
12+
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_ITF
13+
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_PDF417
14+
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_QR_CODE
15+
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_UNKNOWN
16+
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_UPC_A
17+
import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_UPC_E
18+
19+
/**
20+
* Maps between app [BarcodeFormat] and ML Kit barcode format integers.
21+
*/
22+
internal object BarcodeFormatMapper {
23+
private val APP_TO_MLKIT_FORMAT_MAP: Map<BarcodeFormat, Int> =
24+
mapOf(
25+
BarcodeFormat.FORMAT_QR_CODE to FORMAT_QR_CODE,
26+
BarcodeFormat.FORMAT_CODE_128 to FORMAT_CODE_128,
27+
BarcodeFormat.FORMAT_CODE_39 to FORMAT_CODE_39,
28+
BarcodeFormat.FORMAT_CODE_93 to FORMAT_CODE_93,
29+
BarcodeFormat.FORMAT_CODABAR to FORMAT_CODABAR,
30+
BarcodeFormat.FORMAT_DATA_MATRIX to FORMAT_DATA_MATRIX,
31+
BarcodeFormat.FORMAT_EAN_13 to FORMAT_EAN_13,
32+
BarcodeFormat.FORMAT_EAN_8 to FORMAT_EAN_8,
33+
BarcodeFormat.FORMAT_ITF to FORMAT_ITF,
34+
BarcodeFormat.FORMAT_UPC_A to FORMAT_UPC_A,
35+
BarcodeFormat.FORMAT_UPC_E to FORMAT_UPC_E,
36+
BarcodeFormat.FORMAT_PDF417 to FORMAT_PDF417,
37+
BarcodeFormat.FORMAT_AZTEC to FORMAT_AZTEC,
38+
)
39+
40+
private val MLKIT_TO_APP_FORMAT_MAP: Map<Int, BarcodeFormat> =
41+
APP_TO_MLKIT_FORMAT_MAP.entries
42+
.associateBy({ it.value }) { it.key }
43+
.plus(FORMAT_UNKNOWN to BarcodeFormat.TYPE_UNKNOWN)
44+
45+
fun toMlKitFormats(appFormats: List<BarcodeFormat>): Int {
46+
if (appFormats.isEmpty() || appFormats.contains(BarcodeFormat.FORMAT_ALL_FORMATS)) {
47+
return FORMAT_ALL_FORMATS
48+
}
49+
50+
return appFormats
51+
.mapNotNull { APP_TO_MLKIT_FORMAT_MAP[it] }
52+
.distinct()
53+
.fold(0) { acc, formatInt -> acc or formatInt }
54+
.let { if (it == 0) FORMAT_ALL_FORMATS else it }
55+
}
56+
57+
fun toAppFormat(mlKitFormat: Int): BarcodeFormat = MLKIT_TO_APP_FORMAT_MAP[mlKitFormat] ?: BarcodeFormat.TYPE_UNKNOWN
58+
59+
fun isKnownFormat(mlKitFormat: Int): Boolean = MLKIT_TO_APP_FORMAT_MAP.containsKey(mlKitFormat)
60+
}

shared/kscan/src/androidMain/kotlin/org/ncgroup/kscan/BarcodeFormats.android.kt

Lines changed: 0 additions & 68 deletions
This file was deleted.

0 commit comments

Comments
 (0)