stabilized bounding boxes
This commit is contained in:
@@ -22,6 +22,11 @@ data class DetectionBox(
|
|||||||
val corners: List<DetectionPoint> = emptyList()
|
val corners: List<DetectionPoint> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class TrackedDetection(
|
||||||
|
val box: DetectionBox,
|
||||||
|
val missCount: Int
|
||||||
|
)
|
||||||
|
|
||||||
class MlKitBarcodeAnalyzer(
|
class MlKitBarcodeAnalyzer(
|
||||||
private val isAnalysisEnabled: () -> Boolean = { true },
|
private val isAnalysisEnabled: () -> Boolean = { true },
|
||||||
private val onDetectionStateChanged: (
|
private val onDetectionStateChanged: (
|
||||||
@@ -31,8 +36,18 @@ class MlKitBarcodeAnalyzer(
|
|||||||
sourceWidth: Int,
|
sourceWidth: Int,
|
||||||
sourceHeight: Int
|
sourceHeight: Int
|
||||||
) -> Unit = { _, _, _, _, _ -> },
|
) -> Unit = { _, _, _, _, _ -> },
|
||||||
private val onDetected: (ScanResult) -> Unit
|
private val onDetected: (
|
||||||
|
result: ScanResult,
|
||||||
|
readableBox: DetectionBox?,
|
||||||
|
sourceWidth: Int,
|
||||||
|
sourceHeight: Int
|
||||||
|
) -> Unit
|
||||||
) : ImageAnalysis.Analyzer, AutoCloseable {
|
) : ImageAnalysis.Analyzer, AutoCloseable {
|
||||||
|
private companion object {
|
||||||
|
const val MATCH_DISTANCE_THRESHOLD = 0.18f
|
||||||
|
const val BOX_SMOOTHING_ALPHA = 0.35f
|
||||||
|
const val MAX_MISSED_FRAMES = 2
|
||||||
|
}
|
||||||
|
|
||||||
private val scanner = BarcodeScanning.getClient(
|
private val scanner = BarcodeScanning.getClient(
|
||||||
BarcodeScannerOptions.Builder()
|
BarcodeScannerOptions.Builder()
|
||||||
@@ -52,9 +67,15 @@ class MlKitBarcodeAnalyzer(
|
|||||||
private var lastSourceWidth = 0
|
private var lastSourceWidth = 0
|
||||||
@Volatile
|
@Volatile
|
||||||
private var lastSourceHeight = 0
|
private var lastSourceHeight = 0
|
||||||
|
private var trackedDetections: List<TrackedDetection> = emptyList()
|
||||||
|
private var trackedSourceWidth = 0
|
||||||
|
private var trackedSourceHeight = 0
|
||||||
|
|
||||||
override fun analyze(imageProxy: ImageProxy) {
|
override fun analyze(imageProxy: ImageProxy) {
|
||||||
if (!isAnalysisEnabled()) {
|
if (!isAnalysisEnabled()) {
|
||||||
|
trackedDetections = emptyList()
|
||||||
|
trackedSourceWidth = 0
|
||||||
|
trackedSourceHeight = 0
|
||||||
publishDetectionState(
|
publishDetectionState(
|
||||||
hasPotential = false,
|
hasPotential = false,
|
||||||
hasReadable = false,
|
hasReadable = false,
|
||||||
@@ -87,44 +108,73 @@ class MlKitBarcodeAnalyzer(
|
|||||||
val bounds = barcode.boundingBox ?: return@mapNotNull null
|
val bounds = barcode.boundingBox ?: return@mapNotNull null
|
||||||
val normalized = normalizeBoundingBox(
|
val normalized = normalizeBoundingBox(
|
||||||
rect = bounds,
|
rect = bounds,
|
||||||
width = imageProxy.width,
|
sourceWidth = sourceWidth,
|
||||||
height = imageProxy.height,
|
sourceHeight = sourceHeight
|
||||||
rotationDegrees = rotationDegrees
|
|
||||||
) ?: return@mapNotNull null
|
) ?: return@mapNotNull null
|
||||||
val normalizedCorners = barcode.cornerPoints?.map { p ->
|
val normalizedCorners = barcode.cornerPoints?.map { p ->
|
||||||
normalizePoint(
|
normalizePoint(
|
||||||
x = p.x.toFloat(),
|
x = p.x.toFloat(),
|
||||||
y = p.y.toFloat(),
|
y = p.y.toFloat(),
|
||||||
width = imageProxy.width,
|
sourceWidth = sourceWidth,
|
||||||
height = imageProxy.height,
|
sourceHeight = sourceHeight
|
||||||
rotationDegrees = rotationDegrees
|
|
||||||
)
|
)
|
||||||
}?.filterNotNull() ?: emptyList()
|
}?.filterNotNull() ?: emptyList()
|
||||||
normalized.copy(corners = normalizedCorners)
|
normalized.copy(corners = normalizedCorners)
|
||||||
}
|
}
|
||||||
|
val stabilizedBoxes = stabilizeBoxes(boxes, sourceWidth, sourceHeight)
|
||||||
publishDetectionState(
|
publishDetectionState(
|
||||||
hasPotential = barcodes.isNotEmpty(),
|
hasPotential = stabilizedBoxes.isNotEmpty(),
|
||||||
hasReadable = readable != null,
|
hasReadable = readable != null,
|
||||||
boxes = boxes,
|
boxes = stabilizedBoxes,
|
||||||
sourceWidth = sourceWidth,
|
sourceWidth = sourceWidth,
|
||||||
sourceHeight = sourceHeight
|
sourceHeight = sourceHeight
|
||||||
)
|
)
|
||||||
if (readable != null) {
|
if (readable != null) {
|
||||||
|
val readableBox = readable.boundingBox?.let { bounds ->
|
||||||
|
val normalized = normalizeBoundingBox(
|
||||||
|
rect = bounds,
|
||||||
|
sourceWidth = sourceWidth,
|
||||||
|
sourceHeight = sourceHeight
|
||||||
|
)
|
||||||
|
val normalizedCorners = readable.cornerPoints?.map { p ->
|
||||||
|
normalizePoint(
|
||||||
|
x = p.x.toFloat(),
|
||||||
|
y = p.y.toFloat(),
|
||||||
|
sourceWidth = sourceWidth,
|
||||||
|
sourceHeight = sourceHeight
|
||||||
|
)
|
||||||
|
}?.filterNotNull() ?: emptyList()
|
||||||
|
normalized?.copy(corners = normalizedCorners)
|
||||||
|
}
|
||||||
|
val gatedReadableBox = readableBox?.let { rb ->
|
||||||
|
stabilizedBoxes.minByOrNull { distanceBetweenCenters(it, rb) } ?: rb
|
||||||
|
}
|
||||||
onDetected(
|
onDetected(
|
||||||
ScanResult(
|
ScanResult(
|
||||||
content = readable.rawValue.orEmpty(),
|
content = readable.rawValue.orEmpty(),
|
||||||
type = readable.valueType.toHumanType()
|
type = readable.valueType.toHumanType()
|
||||||
)
|
),
|
||||||
|
gatedReadableBox,
|
||||||
|
sourceWidth,
|
||||||
|
sourceHeight
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.addOnFailureListener {
|
.addOnFailureListener {
|
||||||
|
val sourceW = trackedSourceWidth
|
||||||
|
val sourceH = trackedSourceHeight
|
||||||
|
val stabilizedBoxes = if (sourceW > 0 && sourceH > 0) {
|
||||||
|
stabilizeBoxes(emptyList(), sourceW, sourceH)
|
||||||
|
} else {
|
||||||
|
trackedDetections = emptyList()
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
publishDetectionState(
|
publishDetectionState(
|
||||||
hasPotential = false,
|
hasPotential = stabilizedBoxes.isNotEmpty(),
|
||||||
hasReadable = false,
|
hasReadable = false,
|
||||||
boxes = emptyList(),
|
boxes = stabilizedBoxes,
|
||||||
sourceWidth = 0,
|
sourceWidth = if (stabilizedBoxes.isNotEmpty()) sourceW else 0,
|
||||||
sourceHeight = 0
|
sourceHeight = if (stabilizedBoxes.isNotEmpty()) sourceH else 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.addOnCompleteListener {
|
.addOnCompleteListener {
|
||||||
@@ -177,94 +227,120 @@ class MlKitBarcodeAnalyzer(
|
|||||||
|
|
||||||
private fun normalizeBoundingBox(
|
private fun normalizeBoundingBox(
|
||||||
rect: Rect,
|
rect: Rect,
|
||||||
width: Int,
|
sourceWidth: Int,
|
||||||
height: Int,
|
sourceHeight: Int
|
||||||
rotationDegrees: Int
|
|
||||||
): DetectionBox? {
|
): DetectionBox? {
|
||||||
if (width <= 0 || height <= 0) return null
|
if (sourceWidth <= 0 || sourceHeight <= 0) return null
|
||||||
|
val outWidth = sourceWidth.toFloat()
|
||||||
var left = rect.left.toFloat()
|
val outHeight = sourceHeight.toFloat()
|
||||||
var top = rect.top.toFloat()
|
|
||||||
var right = rect.right.toFloat()
|
|
||||||
var bottom = rect.bottom.toFloat()
|
|
||||||
var outWidth = width.toFloat()
|
|
||||||
var outHeight = height.toFloat()
|
|
||||||
|
|
||||||
when (rotationDegrees) {
|
|
||||||
90 -> {
|
|
||||||
left = height - rect.bottom.toFloat()
|
|
||||||
top = rect.left.toFloat()
|
|
||||||
right = height - rect.top.toFloat()
|
|
||||||
bottom = rect.right.toFloat()
|
|
||||||
outWidth = height.toFloat()
|
|
||||||
outHeight = width.toFloat()
|
|
||||||
}
|
|
||||||
180 -> {
|
|
||||||
left = width - rect.right.toFloat()
|
|
||||||
top = height - rect.bottom.toFloat()
|
|
||||||
right = width - rect.left.toFloat()
|
|
||||||
bottom = height - rect.top.toFloat()
|
|
||||||
outWidth = width.toFloat()
|
|
||||||
outHeight = height.toFloat()
|
|
||||||
}
|
|
||||||
270 -> {
|
|
||||||
left = rect.top.toFloat()
|
|
||||||
top = width - rect.right.toFloat()
|
|
||||||
right = rect.bottom.toFloat()
|
|
||||||
bottom = width - rect.left.toFloat()
|
|
||||||
outWidth = height.toFloat()
|
|
||||||
outHeight = width.toFloat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outWidth <= 0f || outHeight <= 0f) return null
|
|
||||||
return DetectionBox(
|
return DetectionBox(
|
||||||
left = (left / outWidth).coerceIn(0f, 1f),
|
left = (rect.left.toFloat() / outWidth).coerceIn(0f, 1f),
|
||||||
top = (top / outHeight).coerceIn(0f, 1f),
|
top = (rect.top.toFloat() / outHeight).coerceIn(0f, 1f),
|
||||||
right = (right / outWidth).coerceIn(0f, 1f),
|
right = (rect.right.toFloat() / outWidth).coerceIn(0f, 1f),
|
||||||
bottom = (bottom / outHeight).coerceIn(0f, 1f)
|
bottom = (rect.bottom.toFloat() / outHeight).coerceIn(0f, 1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun normalizePoint(
|
private fun normalizePoint(
|
||||||
x: Float,
|
x: Float,
|
||||||
y: Float,
|
y: Float,
|
||||||
width: Int,
|
sourceWidth: Int,
|
||||||
height: Int,
|
sourceHeight: Int
|
||||||
rotationDegrees: Int
|
|
||||||
): DetectionPoint? {
|
): DetectionPoint? {
|
||||||
if (width <= 0 || height <= 0) return null
|
if (sourceWidth <= 0 || sourceHeight <= 0) return null
|
||||||
|
val outWidth = sourceWidth.toFloat()
|
||||||
var outX = x
|
val outHeight = sourceHeight.toFloat()
|
||||||
var outY = y
|
|
||||||
var outWidth = width.toFloat()
|
|
||||||
var outHeight = height.toFloat()
|
|
||||||
|
|
||||||
when (rotationDegrees) {
|
|
||||||
90 -> {
|
|
||||||
outX = height - y
|
|
||||||
outY = x
|
|
||||||
outWidth = height.toFloat()
|
|
||||||
outHeight = width.toFloat()
|
|
||||||
}
|
|
||||||
180 -> {
|
|
||||||
outX = width - x
|
|
||||||
outY = height - y
|
|
||||||
outWidth = width.toFloat()
|
|
||||||
outHeight = height.toFloat()
|
|
||||||
}
|
|
||||||
270 -> {
|
|
||||||
outX = y
|
|
||||||
outY = width - x
|
|
||||||
outWidth = height.toFloat()
|
|
||||||
outHeight = width.toFloat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outWidth <= 0f || outHeight <= 0f) return null
|
|
||||||
return DetectionPoint(
|
return DetectionPoint(
|
||||||
x = (outX / outWidth).coerceIn(0f, 1f),
|
x = (x / outWidth).coerceIn(0f, 1f),
|
||||||
y = (outY / outHeight).coerceIn(0f, 1f)
|
y = (y / outHeight).coerceIn(0f, 1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stabilizeBoxes(
|
||||||
|
rawBoxes: List<DetectionBox>,
|
||||||
|
sourceWidth: Int,
|
||||||
|
sourceHeight: Int
|
||||||
|
): List<DetectionBox> {
|
||||||
|
if (sourceWidth <= 0 || sourceHeight <= 0) {
|
||||||
|
trackedDetections = emptyList()
|
||||||
|
trackedSourceWidth = 0
|
||||||
|
trackedSourceHeight = 0
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trackedSourceWidth != sourceWidth || trackedSourceHeight != sourceHeight) {
|
||||||
|
trackedDetections = emptyList()
|
||||||
|
}
|
||||||
|
trackedSourceWidth = sourceWidth
|
||||||
|
trackedSourceHeight = sourceHeight
|
||||||
|
|
||||||
|
val remaining = trackedDetections.toMutableList()
|
||||||
|
val next = mutableListOf<TrackedDetection>()
|
||||||
|
|
||||||
|
rawBoxes.forEach { raw ->
|
||||||
|
val matchIndex = remaining.indices.minByOrNull { idx ->
|
||||||
|
distanceBetweenCenters(remaining[idx].box, raw)
|
||||||
|
} ?: -1
|
||||||
|
if (matchIndex >= 0) {
|
||||||
|
val previous = remaining[matchIndex]
|
||||||
|
val distance = distanceBetweenCenters(previous.box, raw)
|
||||||
|
if (distance <= MATCH_DISTANCE_THRESHOLD) {
|
||||||
|
remaining.removeAt(matchIndex)
|
||||||
|
next += TrackedDetection(
|
||||||
|
box = smoothBox(previous.box, raw, BOX_SMOOTHING_ALPHA),
|
||||||
|
missCount = 0
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
next += TrackedDetection(raw, 0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next += TrackedDetection(raw, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining.forEach { stale ->
|
||||||
|
val misses = stale.missCount + 1
|
||||||
|
if (misses <= MAX_MISSED_FRAMES) {
|
||||||
|
next += stale.copy(missCount = misses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trackedDetections = next
|
||||||
|
return next.map { it.box }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun distanceBetweenCenters(a: DetectionBox, b: DetectionBox): Float {
|
||||||
|
val ax = (a.left + a.right) * 0.5f
|
||||||
|
val ay = (a.top + a.bottom) * 0.5f
|
||||||
|
val bx = (b.left + b.right) * 0.5f
|
||||||
|
val by = (b.top + b.bottom) * 0.5f
|
||||||
|
val dx = ax - bx
|
||||||
|
val dy = ay - by
|
||||||
|
return kotlin.math.sqrt(dx * dx + dy * dy)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun smoothBox(previous: DetectionBox, current: DetectionBox, alpha: Float): DetectionBox {
|
||||||
|
fun lerp(start: Float, end: Float): Float = start + (end - start) * alpha
|
||||||
|
|
||||||
|
val corners = if (previous.corners.isNotEmpty() && previous.corners.size == current.corners.size) {
|
||||||
|
previous.corners.zip(current.corners).map { (p, c) ->
|
||||||
|
DetectionPoint(
|
||||||
|
x = lerp(p.x, c.x),
|
||||||
|
y = lerp(p.y, c.y)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (current.corners.isNotEmpty()) {
|
||||||
|
current.corners
|
||||||
|
} else {
|
||||||
|
previous.corners
|
||||||
|
}
|
||||||
|
|
||||||
|
return DetectionBox(
|
||||||
|
left = lerp(previous.left, current.left).coerceIn(0f, 1f),
|
||||||
|
top = lerp(previous.top, current.top).coerceIn(0f, 1f),
|
||||||
|
right = lerp(previous.right, current.right).coerceIn(0f, 1f),
|
||||||
|
bottom = lerp(previous.bottom, current.bottom).coerceIn(0f, 1f),
|
||||||
|
corners = corners
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ fun CameraPreview(
|
|||||||
sourceWidth: Int,
|
sourceWidth: Int,
|
||||||
sourceHeight: Int
|
sourceHeight: Int
|
||||||
) -> Unit = { _, _, _, _, _ -> },
|
) -> Unit = { _, _, _, _, _ -> },
|
||||||
onScan: (String, String) -> Unit
|
onScan: (String, String, DetectionBox?, Int, Int) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
@@ -67,7 +67,9 @@ fun CameraPreview(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDetected = { result -> latestOnScan.value(result.content, result.type) }
|
onDetected = { result, readableBox, sourceWidth, sourceHeight ->
|
||||||
|
latestOnScan.value(result.content, result.type, readableBox, sourceWidth, sourceHeight)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val cameraProviderRef = remember { mutableStateOf<ProcessCameraProvider?>(null) }
|
val cameraProviderRef = remember { mutableStateOf<ProcessCameraProvider?>(null) }
|
||||||
|
|||||||
@@ -3,18 +3,24 @@ package com.clean.scanner.ui.screens
|
|||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.ImageDecoder
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.media.ToneGenerator
|
import android.media.ToneGenerator
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -55,9 +61,11 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.geometry.CornerRadius
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.Path
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -82,6 +90,12 @@ import com.google.mlkit.vision.common.InputImage
|
|||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
private data class GalleryScanCandidate(
|
||||||
|
val result: ScanResult,
|
||||||
|
val box: DetectionBox?
|
||||||
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -120,7 +134,8 @@ fun ScannerScreen(
|
|||||||
var pendingOpenUrl by remember { mutableStateOf<String?>(null) }
|
var pendingOpenUrl by remember { mutableStateOf<String?>(null) }
|
||||||
var showImageScanFailed by remember { mutableStateOf(false) }
|
var showImageScanFailed by remember { mutableStateOf(false) }
|
||||||
var showNoCodeInImage by remember { mutableStateOf(false) }
|
var showNoCodeInImage by remember { mutableStateOf(false) }
|
||||||
var imageScanCandidates by remember { mutableStateOf<List<ScanResult>>(emptyList()) }
|
var imageScanCandidates by remember { mutableStateOf<List<GalleryScanCandidate>>(emptyList()) }
|
||||||
|
var imageScanPreviewUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
var hasPotentialInView by remember { mutableStateOf(false) }
|
var hasPotentialInView by remember { mutableStateOf(false) }
|
||||||
var hasReadableInView by remember { mutableStateOf(false) }
|
var hasReadableInView by remember { mutableStateOf(false) }
|
||||||
var detectionBoxes by remember { mutableStateOf<List<DetectionBox>>(emptyList()) }
|
var detectionBoxes by remember { mutableStateOf<List<DetectionBox>>(emptyList()) }
|
||||||
@@ -163,20 +178,34 @@ fun ScannerScreen(
|
|||||||
.addOnSuccessListener { barcodes ->
|
.addOnSuccessListener { barcodes ->
|
||||||
val candidates = barcodes.mapNotNull { barcode ->
|
val candidates = barcodes.mapNotNull { barcode ->
|
||||||
val raw = barcode.rawValue?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
val raw = barcode.rawValue?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||||
ScanResult(content = raw, type = barcode.valueType.toHumanType())
|
val normalizedBox = barcode.boundingBox?.let { bounds ->
|
||||||
}.distinctBy { "${it.type}|${it.content}" }
|
val corners = barcode.cornerPoints?.map { p ->
|
||||||
|
com.clean.scanner.data.scanner.DetectionPoint(
|
||||||
|
x = (p.x / image.width.toFloat()).coerceIn(0f, 1f),
|
||||||
|
y = (p.y / image.height.toFloat()).coerceIn(0f, 1f)
|
||||||
|
)
|
||||||
|
} ?: emptyList()
|
||||||
|
DetectionBox(
|
||||||
|
left = (bounds.left / image.width.toFloat()).coerceIn(0f, 1f),
|
||||||
|
top = (bounds.top / image.height.toFloat()).coerceIn(0f, 1f),
|
||||||
|
right = (bounds.right / image.width.toFloat()).coerceIn(0f, 1f),
|
||||||
|
bottom = (bounds.bottom / image.height.toFloat()).coerceIn(0f, 1f),
|
||||||
|
corners = corners
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GalleryScanCandidate(
|
||||||
|
result = ScanResult(content = raw, type = barcode.valueType.toHumanType()),
|
||||||
|
box = normalizedBox
|
||||||
|
)
|
||||||
|
}.distinctBy { "${it.result.type}|${it.result.content}" }
|
||||||
|
|
||||||
when (candidates.size) {
|
when (candidates.size) {
|
||||||
0 -> showNoCodeInImage = true
|
0 -> showNoCodeInImage = true
|
||||||
1 -> {
|
else -> {
|
||||||
if (barcodes.size > 1) {
|
imageScanPreviewUri = uri
|
||||||
imageScanCandidates = candidates
|
imageScanCandidates = candidates
|
||||||
} else {
|
|
||||||
onScan(candidates.first())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> imageScanCandidates = candidates
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.addOnFailureListener {
|
.addOnFailureListener {
|
||||||
showImageScanFailed = true
|
showImageScanFailed = true
|
||||||
@@ -219,7 +248,17 @@ fun ScannerScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val viewW = with(density) { maxWidth.toPx() }
|
||||||
|
val viewH = with(density) { maxHeight.toPx() }
|
||||||
|
val aimW = viewW * 0.62f
|
||||||
|
val aimH = with(density) { 200.dp.toPx() }
|
||||||
|
val aimLeft = (viewW - aimW) / 2f
|
||||||
|
val aimTop = (viewH - aimH) / 2f
|
||||||
|
val aimRight = aimLeft + aimW
|
||||||
|
val aimBottom = aimTop + aimH
|
||||||
|
|
||||||
if (cameraGranted) {
|
if (cameraGranted) {
|
||||||
CameraPreview(
|
CameraPreview(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -233,7 +272,25 @@ fun ScannerScreen(
|
|||||||
detectionSourceWidth = sourceWidth
|
detectionSourceWidth = sourceWidth
|
||||||
detectionSourceHeight = sourceHeight
|
detectionSourceHeight = sourceHeight
|
||||||
},
|
},
|
||||||
onScan = { content, type ->
|
onScan = { content, type, readableBox, sourceWidth, sourceHeight ->
|
||||||
|
val box = readableBox ?: return@CameraPreview
|
||||||
|
if (sourceWidth <= 0 || sourceHeight <= 0 || viewW <= 0f || viewH <= 0f) {
|
||||||
|
return@CameraPreview
|
||||||
|
}
|
||||||
|
|
||||||
|
val srcW = sourceWidth.toFloat()
|
||||||
|
val srcH = sourceHeight.toFloat()
|
||||||
|
val scale = max(viewW / srcW, viewH / srcH)
|
||||||
|
val scaledW = srcW * scale
|
||||||
|
val scaledH = srcH * scale
|
||||||
|
val offsetX = (viewW - scaledW) / 2f
|
||||||
|
val offsetY = (viewH - scaledH) / 2f
|
||||||
|
val centerX = offsetX + (((box.left + box.right) * 0.5f) * srcW * scale)
|
||||||
|
val centerY = offsetY + (((box.top + box.bottom) * 0.5f) * srcH * scale)
|
||||||
|
|
||||||
|
val insideAim = centerX in aimLeft..aimRight && centerY in aimTop..aimBottom
|
||||||
|
if (!insideAim) return@CameraPreview
|
||||||
|
|
||||||
onScan(ScanResult(content = content, type = type))
|
onScan(ScanResult(content = content, type = type))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -292,29 +349,22 @@ fun ScannerScreen(
|
|||||||
Canvas(
|
Canvas(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
.fillMaxWidth(0.7f)
|
.fillMaxWidth(0.62f)
|
||||||
.height(220.dp)
|
.height(200.dp)
|
||||||
) {
|
) {
|
||||||
val guideColor = when {
|
val guideColor = when {
|
||||||
hasReadableInView -> Color(0xFF4AE3A3)
|
hasReadableInView -> Color(0xFF4AE3A3)
|
||||||
hasPotentialInView -> Color(0xFFFFC857)
|
hasPotentialInView -> Color(0xFFFFC857)
|
||||||
else -> Color(0xFF7CE6C6)
|
else -> Color(0xFF7CE6C6)
|
||||||
}
|
}
|
||||||
val cx = size.width / 2f
|
|
||||||
val cy = size.height / 2f
|
|
||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
color = guideColor.copy(alpha = 0.10f),
|
color = guideColor.copy(alpha = 0.08f),
|
||||||
cornerRadius = CornerRadius(26f, 26f)
|
cornerRadius = CornerRadius(22f, 22f)
|
||||||
)
|
)
|
||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
color = guideColor.copy(alpha = 0.90f),
|
color = guideColor.copy(alpha = 0.90f),
|
||||||
cornerRadius = CornerRadius(26f, 26f),
|
cornerRadius = CornerRadius(22f, 22f),
|
||||||
style = Stroke(width = 4f)
|
style = Stroke(width = 3.5f)
|
||||||
)
|
|
||||||
drawCircle(
|
|
||||||
color = guideColor.copy(alpha = 0.90f),
|
|
||||||
radius = 8f,
|
|
||||||
center = androidx.compose.ui.geometry.Offset(cx, cy)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +372,7 @@ fun ScannerScreen(
|
|||||||
text = when {
|
text = when {
|
||||||
hasReadableInView -> stringResource(R.string.live_readable_detected)
|
hasReadableInView -> stringResource(R.string.live_readable_detected)
|
||||||
hasPotentialInView -> stringResource(R.string.live_potential_detected)
|
hasPotentialInView -> stringResource(R.string.live_potential_detected)
|
||||||
else -> stringResource(R.string.pinch_to_zoom_hint)
|
else -> stringResource(R.string.aim_center_hint)
|
||||||
},
|
},
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
@@ -530,51 +580,17 @@ fun ScannerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (imageScanCandidates.isNotEmpty()) {
|
if (imageScanCandidates.isNotEmpty()) {
|
||||||
AlertDialog(
|
GalleryScanPreviewDialog(
|
||||||
onDismissRequest = { imageScanCandidates = emptyList() },
|
imageUri = imageScanPreviewUri,
|
||||||
title = { Text(stringResource(R.string.image_scan_pick_title, imageScanCandidates.size)) },
|
candidates = imageScanCandidates,
|
||||||
text = {
|
onPick = { candidate ->
|
||||||
Column(
|
onScan(candidate.result)
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.image_scan_pick_subtitle),
|
|
||||||
modifier = Modifier.padding(bottom = 4.dp)
|
|
||||||
)
|
|
||||||
imageScanCandidates.forEach { candidate ->
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
onScan(candidate)
|
|
||||||
imageScanCandidates = emptyList()
|
imageScanCandidates = emptyList()
|
||||||
|
imageScanPreviewUri = null
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth()
|
onDismiss = {
|
||||||
) {
|
imageScanCandidates = emptyList()
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
imageScanPreviewUri = null
|
||||||
Text(
|
|
||||||
text = candidate.type,
|
|
||||||
textAlign = TextAlign.Start,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = candidate.content,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
textAlign = TextAlign.Start,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { imageScanCandidates = emptyList() }) {
|
|
||||||
Text(stringResource(R.string.cancel))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -717,6 +733,143 @@ private fun buildBatchExport(results: List<BatchScanRecord>): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GalleryScanPreviewDialog(
|
||||||
|
imageUri: Uri?,
|
||||||
|
candidates: List<GalleryScanCandidate>,
|
||||||
|
onPick: (GalleryScanCandidate) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val bitmap = remember(imageUri) {
|
||||||
|
imageUri?.let { loadBitmapFromUri(context, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.image_scan_pick_title, candidates.size)) },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
if (bitmap != null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(260.dp)
|
||||||
|
.background(Color.Black.copy(alpha = 0.32f), RoundedCornerShape(12.dp)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = bitmap.asImageBitmap(),
|
||||||
|
contentDescription = stringResource(R.string.scan_from_image),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(260.dp),
|
||||||
|
contentScale = androidx.compose.ui.layout.ContentScale.Fit
|
||||||
|
)
|
||||||
|
Canvas(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(260.dp)
|
||||||
|
) {
|
||||||
|
val bitmapW = bitmap.width.toFloat()
|
||||||
|
val bitmapH = bitmap.height.toFloat()
|
||||||
|
if (bitmapW <= 0f || bitmapH <= 0f) return@Canvas
|
||||||
|
|
||||||
|
val scale = min(size.width / bitmapW, size.height / bitmapH)
|
||||||
|
val drawW = bitmapW * scale
|
||||||
|
val drawH = bitmapH * scale
|
||||||
|
val offsetX = (size.width - drawW) / 2f
|
||||||
|
val offsetY = (size.height - drawH) / 2f
|
||||||
|
|
||||||
|
candidates.forEach { candidate ->
|
||||||
|
val box = candidate.box ?: return@forEach
|
||||||
|
val color = Color(0xFF4AE3A3).copy(alpha = 0.96f)
|
||||||
|
val points = box.corners.map { p ->
|
||||||
|
androidx.compose.ui.geometry.Offset(
|
||||||
|
x = offsetX + (p.x * drawW),
|
||||||
|
y = offsetY + (p.y * drawH)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (points.size >= 4) {
|
||||||
|
val path = Path().apply {
|
||||||
|
moveTo(points.first().x, points.first().y)
|
||||||
|
points.drop(1).forEach { pt -> lineTo(pt.x, pt.y) }
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(path = path, color = color, style = Stroke(width = 4f))
|
||||||
|
} else {
|
||||||
|
val left = offsetX + (box.left * drawW)
|
||||||
|
val top = offsetY + (box.top * drawH)
|
||||||
|
val right = offsetX + (box.right * drawW)
|
||||||
|
val bottom = offsetY + (box.bottom * drawH)
|
||||||
|
if (right > left && bottom > top) {
|
||||||
|
drawRoundRect(
|
||||||
|
color = color,
|
||||||
|
topLeft = androidx.compose.ui.geometry.Offset(left, top),
|
||||||
|
size = androidx.compose.ui.geometry.Size(right - left, bottom - top),
|
||||||
|
cornerRadius = CornerRadius(10f, 10f),
|
||||||
|
style = Stroke(width = 4f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(text = stringResource(R.string.image_scan_pick_subtitle))
|
||||||
|
candidates.forEach { candidate ->
|
||||||
|
TextButton(
|
||||||
|
onClick = { onPick(candidate) },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = candidate.result.type,
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = candidate.result.content,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadBitmapFromUri(context: android.content.Context, uri: Uri): Bitmap? {
|
||||||
|
return try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
||||||
|
ImageDecoder.decodeBitmap(source)
|
||||||
|
} else {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { stream ->
|
||||||
|
BitmapFactory.decodeStream(stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PermissionContent(
|
private fun PermissionContent(
|
||||||
showSettingsHint: Boolean,
|
showSettingsHint: Boolean,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
<string name="content_value">Inhalt</string>
|
<string name="content_value">Inhalt</string>
|
||||||
<string name="request_camera">Kamera erlauben</string>
|
<string name="request_camera">Kamera erlauben</string>
|
||||||
<string name="pinch_to_zoom_hint">Zum Zoomen bei kleinen Codes mit zwei Fingern aufziehen</string>
|
<string name="pinch_to_zoom_hint">Zum Zoomen bei kleinen Codes mit zwei Fingern aufziehen</string>
|
||||||
|
<string name="aim_center_hint">Code im mittleren Rahmen ausrichten.</string>
|
||||||
<string name="live_potential_detected">Code erkannt. Ruhig halten oder näher rangehen.</string>
|
<string name="live_potential_detected">Code erkannt. Ruhig halten oder näher rangehen.</string>
|
||||||
<string name="live_readable_detected">Lesbarer Code erkannt.</string>
|
<string name="live_readable_detected">Lesbarer Code erkannt.</string>
|
||||||
<string name="share_history">Historie teilen</string>
|
<string name="share_history">Historie teilen</string>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
<string name="content_value">Content</string>
|
<string name="content_value">Content</string>
|
||||||
<string name="request_camera">Allow camera</string>
|
<string name="request_camera">Allow camera</string>
|
||||||
<string name="pinch_to_zoom_hint">Pinch to zoom for small codes</string>
|
<string name="pinch_to_zoom_hint">Pinch to zoom for small codes</string>
|
||||||
|
<string name="aim_center_hint">Aim the code inside the center frame.</string>
|
||||||
<string name="live_potential_detected">Code spotted. Hold steady or move closer.</string>
|
<string name="live_potential_detected">Code spotted. Hold steady or move closer.</string>
|
||||||
<string name="live_readable_detected">Readable code detected.</string>
|
<string name="live_readable_detected">Readable code detected.</string>
|
||||||
<string name="share_history">Share history</string>
|
<string name="share_history">Share history</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user