Improve QRS scan

This commit is contained in:
世界
2026-01-02 22:33:31 +08:00
parent aaa3ef044a
commit 7a5c3640c6
10 changed files with 523 additions and 36 deletions

View File

@@ -5,6 +5,7 @@ import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.view.PreviewView
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -39,8 +40,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
@@ -53,6 +56,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult
import io.nekohasekai.sfa.compose.screen.qrscan.QRScanViewModel
import io.nekohasekai.sfa.ui.profile.QRCodeCropArea
import kotlin.math.max
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -186,7 +191,8 @@ fun QRScanSheet(
CameraPreview(
modifier = Modifier.fillMaxSize(),
viewModel = viewModel,
lifecycleOwner = lifecycleOwner
lifecycleOwner = lifecycleOwner,
cropArea = uiState.cropArea,
)
}
@@ -261,6 +267,7 @@ private fun CameraPreview(
modifier: Modifier = Modifier,
viewModel: QRScanViewModel,
lifecycleOwner: LifecycleOwner,
cropArea: QRCodeCropArea?,
) {
var previewView by remember { mutableStateOf<PreviewView?>(null) }
@@ -272,20 +279,92 @@ private fun CameraPreview(
onDispose { }
}
AndroidView(
modifier = modifier,
factory = { ctx ->
PreviewView(ctx).apply {
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
previewView = this
Box(modifier = modifier) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
PreviewView(ctx).apply {
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
previewView = this
previewStreamState.observe(lifecycleOwner) { state ->
if (state == PreviewView.StreamState.STREAMING) {
viewModel.onPreviewStreamStateChanged(true)
implementationMode = PreviewView.ImplementationMode.PERFORMANCE
previewStreamState.observe(lifecycleOwner) { state ->
if (state == PreviewView.StreamState.STREAMING) {
viewModel.onPreviewStreamStateChanged(true)
implementationMode = PreviewView.ImplementationMode.PERFORMANCE
}
}
}
}
)
Canvas(modifier = Modifier.fillMaxSize()) {
val rect = cropArea?.let { mapCropAreaToPreview(it, size.width, size.height) } ?: return@Canvas
drawRect(
color = Color.White.copy(alpha = 0.85f),
topLeft = rect.topLeft,
size = rect.size,
style = Stroke(width = 2.dp.toPx()),
)
}
)
}
}
private fun mapCropAreaToPreview(
area: QRCodeCropArea,
viewWidth: Float,
viewHeight: Float,
): Rect? {
if (viewWidth <= 0f || viewHeight <= 0f) return null
val rotation = ((area.rotationDegrees % 360) + 360) % 360
var rotLeft = area.left.toFloat()
var rotTop = area.top.toFloat()
var rotRight = area.right.toFloat()
var rotBottom = area.bottom.toFloat()
var imageWidth = area.imageWidth.toFloat()
var imageHeight = area.imageHeight.toFloat()
when (rotation) {
90 -> {
rotLeft = (area.imageHeight - area.bottom).toFloat()
rotTop = area.left.toFloat()
rotRight = (area.imageHeight - area.top).toFloat()
rotBottom = area.right.toFloat()
imageWidth = area.imageHeight.toFloat()
imageHeight = area.imageWidth.toFloat()
}
180 -> {
rotLeft = (area.imageWidth - area.right).toFloat()
rotTop = (area.imageHeight - area.bottom).toFloat()
rotRight = (area.imageWidth - area.left).toFloat()
rotBottom = (area.imageHeight - area.top).toFloat()
}
270 -> {
rotLeft = area.top.toFloat()
rotTop = (area.imageWidth - area.right).toFloat()
rotRight = area.bottom.toFloat()
rotBottom = (area.imageWidth - area.left).toFloat()
imageWidth = area.imageHeight.toFloat()
imageHeight = area.imageWidth.toFloat()
}
}
if (imageWidth <= 0f || imageHeight <= 0f) return null
val scale = max(viewWidth / imageWidth, viewHeight / imageHeight)
val dx = (viewWidth - imageWidth * scale) / 2f
val dy = (viewHeight - imageHeight * scale) / 2f
val left = rotLeft * scale + dx
val top = rotTop * scale + dy
val right = rotRight * scale + dx
val bottom = rotBottom * scale + dy
val clampedLeft = left.coerceIn(0f, viewWidth)
val clampedTop = top.coerceIn(0f, viewHeight)
val clampedRight = right.coerceIn(0f, viewWidth)
val clampedBottom = bottom.coerceIn(0f, viewHeight)
if (clampedRight - clampedLeft < 4f || clampedBottom - clampedTop < 4f) return null
return Rect(clampedLeft, clampedTop, clampedRight, clampedBottom)
}

View File

@@ -16,6 +16,7 @@ import androidx.lifecycle.LifecycleOwner
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.qrs.QRSDecoder
import io.nekohasekai.sfa.qrs.readIntLE
import io.nekohasekai.sfa.ui.profile.QRCodeCropArea
import io.nekohasekai.sfa.ui.profile.ZxingQRCodeAnalyzer
import io.nekohasekai.sfa.vendor.Vendor
import kotlinx.coroutines.flow.MutableStateFlow
@@ -48,6 +49,7 @@ data class QRScanUiState(
val vendorAnalyzerAvailable: Boolean = false,
val qrsMode: Boolean = false,
val qrsProgress: Pair<Int, Int>? = null,
val cropArea: QRCodeCropArea? = null,
val errorMessage: String? = null,
val result: QRScanResult? = null,
val zoomRatio: Float = 1f,
@@ -75,7 +77,8 @@ class QRScanViewModel(application: Application) : AndroidViewModel(application)
private val vendorAnalyzer: ImageAnalysis.Analyzer? = Vendor.createQRCodeAnalyzer(
onSuccess = { rawValue -> handleScanSuccess(rawValue) },
onFailure = { exception -> handleScanFailure(exception) }
onFailure = { exception -> handleScanFailure(exception) },
onCropArea = ::updateCropArea,
)
init {
@@ -95,6 +98,16 @@ class QRScanViewModel(application: Application) : AndroidViewModel(application)
handleScanFailure(exception)
}
private fun updateCropArea(area: QRCodeCropArea?) {
_uiState.update { state ->
if (state.cropArea == area) {
state
} else {
state.copy(cropArea = area)
}
}
}
private fun handleScanSuccess(rawValue: String) {
Log.d(TAG, "Scanned: ${rawValue.take(100)}...")
val qrsPayload = extractQRSPayload(rawValue)
@@ -102,6 +115,7 @@ class QRScanViewModel(application: Application) : AndroidViewModel(application)
if (qrsPayload != null) {
handleQRSFrame(qrsPayload)
} else {
updateCropArea(null)
if (_uiState.value.qrsMode) {
resetQRSState()
}
@@ -114,6 +128,7 @@ class QRScanViewModel(application: Application) : AndroidViewModel(application)
if (_uiState.value.qrsMode) {
return
}
updateCropArea(null)
imageAnalysis?.clearAnalyzer()
if (showingError.compareAndSet(false, true)) {
resetAnalyzer()
@@ -125,7 +140,7 @@ class QRScanViewModel(application: Application) : AndroidViewModel(application)
if (_uiState.value.useVendorAnalyzer && vendorAnalyzer != null) {
_uiState.update { it.copy(useVendorAnalyzer = false) }
imageAnalysis?.clearAnalyzer()
imageAnalyzer = ZxingQRCodeAnalyzer(onSuccess, onFailure)
imageAnalyzer = ZxingQRCodeAnalyzer(onSuccess, onFailure, onCropArea = ::updateCropArea)
imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!)
}
}
@@ -154,7 +169,7 @@ class QRScanViewModel(application: Application) : AndroidViewModel(application)
imageAnalyzer = if (_uiState.value.useVendorAnalyzer && vendorAnalyzer != null) {
vendorAnalyzer
} else {
ZxingQRCodeAnalyzer(onSuccess, onFailure)
ZxingQRCodeAnalyzer(onSuccess, onFailure, onCropArea = ::updateCropArea)
}
imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!)
@@ -220,12 +235,13 @@ class QRScanViewModel(application: Application) : AndroidViewModel(application)
val newState = !_uiState.value.useVendorAnalyzer
_uiState.update { it.copy(useVendorAnalyzer = newState) }
updateCropArea(null)
imageAnalysis?.clearAnalyzer()
imageAnalyzer = if (newState) {
vendorAnalyzer
} else {
ZxingQRCodeAnalyzer(onSuccess, onFailure)
ZxingQRCodeAnalyzer(onSuccess, onFailure, onCropArea = ::updateCropArea)
}
imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!)
}
@@ -238,7 +254,7 @@ class QRScanViewModel(application: Application) : AndroidViewModel(application)
fun clearResult() {
resetQRSState()
_uiState.update { it.copy(result = null) }
_uiState.update { it.copy(result = null, cropArea = null) }
}
private fun extractQRSPayload(content: String): ByteArray? {

View File

@@ -13,7 +13,7 @@ object QRSConstants {
const val MAX_FPS = 60
// Slice Size settings
const val DEFAULT_SLICE_SIZE = 512
const val DEFAULT_SLICE_SIZE = 500
const val MIN_SLICE_SIZE = 100
const val MAX_SLICE_SIZE = 1500

View File

@@ -0,0 +1,286 @@
package io.nekohasekai.sfa.ui.profile
import kotlin.math.max
import kotlin.math.min
data class QRCodeCropArea(
val left: Int,
val top: Int,
val right: Int,
val bottom: Int,
val imageWidth: Int,
val imageHeight: Int,
val rotationDegrees: Int,
)
object QRCodeSmartCrop {
fun findCropArea(
yData: ByteArray,
width: Int,
height: Int,
rotationDegrees: Int,
): QRCodeCropArea? {
val minDim = min(width, height)
if (minDim <= 0) return null
val step = (minDim / 120).coerceIn(4, 16)
val samplesWide = (width + step - 1) / step
val samplesHigh = (height + step - 1) / step
val sampleCount = samplesWide * samplesHigh
if (sampleCount == 0) return null
val histogram = IntArray(256)
var sum = 0
var maxLuma = 0
var sy = 0
var y = 0
while (sy < samplesHigh) {
val rowOffset = y * width
var sx = 0
var x = 0
while (sx < samplesWide) {
val value = yData[rowOffset + x].toInt() and 0xFF
sum += value
histogram[value]++
if (value > maxLuma) maxLuma = value
sx++
x += step
}
sy++
y += step
}
val mean = sum / sampleCount
val contrast = maxLuma - mean
if (contrast < 30) return null
val p95 = percentile(histogram, sampleCount, 0.95f)
val p90 = percentile(histogram, sampleCount, 0.90f)
val p85 = percentile(histogram, sampleCount, 0.85f)
val thresholds = intArrayOf(
max((mean + contrast * 0.75f).toInt(), p95),
max((mean + contrast * 0.6f).toInt(), p90),
max((mean + contrast * 0.5f).toInt(), p85),
)
val minBrightSamples = max(12, sampleCount / 300)
var bestArea: QRCodeCropArea? = null
var bestRatio = 1f
for (i in thresholds.indices) {
val threshold = thresholds[i].coerceAtMost(250)
val component = findBestComponent(
yData,
width,
height,
step,
samplesWide,
samplesHigh,
threshold,
minBrightSamples,
) ?: continue
val area = buildCropArea(component, step, width, height, rotationDegrees) ?: continue
val areaRatio = ((area.right - area.left) * (area.bottom - area.top)).toFloat() / (width * height)
val maxRatio = if (i == thresholds.lastIndex) 0.9f else 0.82f
if (areaRatio <= maxRatio) return area
if (areaRatio < bestRatio) {
bestRatio = areaRatio
bestArea = area
}
}
return bestArea
}
private data class CropComponent(
val minX: Int,
val minY: Int,
val maxX: Int,
val maxY: Int,
val count: Int,
val score: Float,
)
private fun findBestComponent(
yData: ByteArray,
width: Int,
height: Int,
step: Int,
samplesWide: Int,
samplesHigh: Int,
threshold: Int,
minBrightSamples: Int,
): CropComponent? {
val totalSamples = samplesWide * samplesHigh
val bright = BooleanArray(totalSamples)
var brightCount = 0
var sy = 0
var y = 0
while (sy < samplesHigh) {
val rowOffset = y * width
var sx = 0
var x = 0
while (sx < samplesWide) {
val value = yData[rowOffset + x].toInt() and 0xFF
if (value >= threshold) {
bright[sy * samplesWide + sx] = true
brightCount++
}
sx++
x += step
}
sy++
y += step
}
if (brightCount < minBrightSamples) return null
val visited = BooleanArray(totalSamples)
val queue = IntArray(totalSamples)
val minComponentSamples = max(8, minBrightSamples / 3)
val centerX = width / 2f
val centerY = height / 2f
val maxDistSq = centerX * centerX + centerY * centerY
var best: CropComponent? = null
for (cy in 0 until samplesHigh) {
for (cx in 0 until samplesWide) {
val index = cy * samplesWide + cx
if (!bright[index] || visited[index]) continue
var head = 0
var tail = 0
queue[tail++] = index
visited[index] = true
var count = 0
var minX = cx
var maxX = cx
var minY = cy
var maxY = cy
while (head < tail) {
val current = queue[head++]
val x = current % samplesWide
val yIndex = current / samplesWide
count++
if (x < minX) minX = x
if (x > maxX) maxX = x
if (yIndex < minY) minY = yIndex
if (yIndex > maxY) maxY = yIndex
val startX = if (x > 0) x - 1 else x
val endX = if (x + 1 < samplesWide) x + 1 else x
val startY = if (yIndex > 0) yIndex - 1 else yIndex
val endY = if (yIndex + 1 < samplesHigh) yIndex + 1 else yIndex
var ny = startY
while (ny <= endY) {
val rowIndex = ny * samplesWide
var nx = startX
while (nx <= endX) {
if (nx != x || ny != yIndex) {
val neighbor = rowIndex + nx
if (bright[neighbor] && !visited[neighbor]) {
visited[neighbor] = true
queue[tail++] = neighbor
}
}
nx++
}
ny++
}
}
if (count < minComponentSamples) continue
val compWidth = maxX - minX + 1
val compHeight = maxY - minY + 1
val aspect = max(compWidth.toFloat() / compHeight, compHeight.toFloat() / compWidth)
val aspectPenalty = ((aspect - 1f) / 1.6f).coerceIn(0f, 1f)
val compCenterX = (minX + maxX + 1) * 0.5f * step
val compCenterY = (minY + maxY + 1) * 0.5f * step
val dx = compCenterX - centerX
val dy = compCenterY - centerY
val normDist = if (maxDistSq > 0f) (dx * dx + dy * dy) / maxDistSq else 0f
val edgeTouches = (if (minX == 0) 1 else 0) +
(if (minY == 0) 1 else 0) +
(if (maxX == samplesWide - 1) 1 else 0) +
(if (maxY == samplesHigh - 1) 1 else 0)
var score = count.toFloat()
score *= 1f - 0.5f * normDist.coerceIn(0f, 1f)
score *= 1f - 0.35f * aspectPenalty
score *= 1f - 0.15f * edgeTouches
if (best == null || score > best!!.score) {
best = CropComponent(
minX = minX,
minY = minY,
maxX = maxX,
maxY = maxY,
count = count,
score = score,
)
}
}
}
return best
}
private fun buildCropArea(
component: CropComponent,
step: Int,
width: Int,
height: Int,
rotationDegrees: Int,
): QRCodeCropArea? {
val left = component.minX * step
val top = component.minY * step
val right = min(width, (component.maxX + 1) * step)
val bottom = min(height, (component.maxY + 1) * step)
val cropWidth = right - left
val cropHeight = bottom - top
if (cropWidth <= 0 || cropHeight <= 0) return null
val frameArea = width * height
val cropArea = cropWidth * cropHeight
if (cropArea < frameArea / 96) return null
val aspect = cropWidth.toFloat() / cropHeight
if (aspect < 0.45f || aspect > 2.2f) return null
val padding = (max(cropWidth, cropHeight) * 0.14f).toInt().coerceAtLeast(step * 2)
val cropLeft = (left - padding).coerceAtLeast(0)
val cropTop = (top - padding).coerceAtLeast(0)
val cropRight = (right + padding).coerceAtMost(width)
val cropBottom = (bottom + padding).coerceAtMost(height)
if (cropRight <= cropLeft || cropBottom <= cropTop) return null
return QRCodeCropArea(
left = cropLeft,
top = cropTop,
right = cropRight,
bottom = cropBottom,
imageWidth = width,
imageHeight = height,
rotationDegrees = rotationDegrees,
)
}
private fun percentile(histogram: IntArray, count: Int, percentile: Float): Int {
if (count <= 0) return 0
val target = (count * percentile).toInt().coerceIn(0, count - 1)
var acc = 0
for (i in histogram.indices) {
acc += histogram[i]
if (acc > target) return i
}
return histogram.lastIndex
}
}

View File

@@ -15,6 +15,7 @@ import com.google.zxing.qrcode.QRCodeReader
class ZxingQRCodeAnalyzer(
private val onSuccess: ((String) -> Unit),
private val onFailure: ((Exception) -> Unit),
private val onCropArea: ((QRCodeCropArea?) -> Unit)? = null,
) : ImageAnalysis.Analyzer {
private val qrCodeReader = QRCodeReader()
private var yDataBuffer: ByteArray? = null
@@ -23,7 +24,11 @@ class ZxingQRCodeAnalyzer(
override fun analyze(image: ImageProxy) {
try {
val source = image.toYUVSource()
val yData = image.toYUVData()
val width = image.width
val height = image.height
val rotationDegrees = image.imageInfo.rotationDegrees
val source = PlanarYUVLuminanceSource(yData, width, height, 0, 0, width, height, false)
// Fast path: HybridBinarizer
tryDecode(BinaryBitmap(HybridBinarizer(source)))?.let {
@@ -31,6 +36,27 @@ class ZxingQRCodeAnalyzer(
return
}
val cropArea = QRCodeSmartCrop.findCropArea(yData, width, height, rotationDegrees)
onCropArea?.invoke(cropArea)
if (cropArea != null) {
val cropWidth = cropArea.right - cropArea.left
val cropHeight = cropArea.bottom - cropArea.top
val smartSource = PlanarYUVLuminanceSource(
yData,
width,
height,
cropArea.left,
cropArea.top,
cropWidth,
cropHeight,
false,
)
tryDecode(BinaryBitmap(HybridBinarizer(smartSource)))?.let {
onSuccess(it.text)
return
}
}
// In QRS mode, skip additional binarizer attempts for performance
if (qrsMode) return
@@ -65,7 +91,7 @@ class ZxingQRCodeAnalyzer(
}
}
private fun ImageProxy.toYUVSource(): PlanarYUVLuminanceSource {
private fun ImageProxy.toYUVData(): ByteArray {
val yPlane = planes[0]
val yBuffer = yPlane.buffer
val rowStride = yPlane.rowStride
@@ -80,7 +106,7 @@ class ZxingQRCodeAnalyzer(
yBuffer.get(yData, row * width, width)
}
}
return PlanarYUVLuminanceSource(yData, width, height, 0, 0, width, height, false)
return yData
}
private fun tryDecode(bitmap: BinaryBitmap): Result? {

View File

@@ -2,6 +2,7 @@ package io.nekohasekai.sfa.vendor
import android.app.Activity
import androidx.camera.core.ImageAnalysis
import io.nekohasekai.sfa.ui.profile.QRCodeCropArea
import io.nekohasekai.sfa.update.UpdateInfo
interface VendorInterface {
@@ -13,6 +14,7 @@ interface VendorInterface {
fun createQRCodeAnalyzer(
onSuccess: (String) -> Unit,
onFailure: (Exception) -> Unit,
onCropArea: ((QRCodeCropArea?) -> Unit)? = null,
): ImageAnalysis.Analyzer?
/**

View File

@@ -9,6 +9,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ui.profile.QRCodeCropArea
import io.nekohasekai.sfa.update.UpdateCheckException
import io.nekohasekai.sfa.update.UpdateInfo
import io.nekohasekai.sfa.update.UpdateState
@@ -91,6 +92,7 @@ object Vendor : VendorInterface {
override fun createQRCodeAnalyzer(
onSuccess: (String) -> Unit,
onFailure: (Exception) -> Unit,
onCropArea: ((QRCodeCropArea?) -> Unit)?,
): ImageAnalysis.Analyzer? {
return null
}

View File

@@ -9,6 +9,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ui.profile.QRCodeCropArea
import io.nekohasekai.sfa.update.UpdateCheckException
import io.nekohasekai.sfa.update.UpdateInfo
import io.nekohasekai.sfa.update.UpdateState
@@ -91,6 +92,7 @@ object Vendor : VendorInterface {
override fun createQRCodeAnalyzer(
onSuccess: (String) -> Unit,
onFailure: (Exception) -> Unit,
onCropArea: ((QRCodeCropArea?) -> Unit)?,
): ImageAnalysis.Analyzer? {
return null
}

View File

@@ -8,12 +8,15 @@ import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import io.nekohasekai.sfa.ui.profile.QRCodeCropArea
import io.nekohasekai.sfa.ui.profile.QRCodeSmartCrop
// kanged from: https://github.com/G00fY2/quickie/blob/main/quickie/src/main/kotlin/io/github/g00fy2/quickie/QRCodeAnalyzer.kt
class MLKitQRCodeAnalyzer(
private val onSuccess: ((String) -> Unit),
private val onFailure: ((Exception) -> Unit),
private val onCropArea: ((QRCodeCropArea?) -> Unit)? = null,
) : ImageAnalysis.Analyzer {
private val barcodeScanner =
BarcodeScanning.getClient(
@@ -26,6 +29,7 @@ class MLKitQRCodeAnalyzer(
private var failureOccurred = false
private var failureTimestamp = 0L
private var yDataBuffer: ByteArray? = null
private var pixelBuffer: IntArray? = null
@ExperimentalGetImage
@@ -38,6 +42,7 @@ class MLKitQRCodeAnalyzer(
val nowMills = System.currentTimeMillis()
if (failureOccurred && nowMills - failureTimestamp < 5000L) {
failureTimestamp = nowMills
onCropArea?.invoke(null)
image.close()
return
}
@@ -47,33 +52,75 @@ class MLKitQRCodeAnalyzer(
.addOnSuccessListener { codes ->
val rawValue = codes.firstOrNull()?.rawValue
if (rawValue != null) {
onCropArea?.invoke(null)
onSuccess(rawValue)
image.close()
} else {
tryInvertedScan(image)
val yData = image.toYData().copyOf()
val rotation = image.imageInfo.rotationDegrees
val cropArea = QRCodeSmartCrop.findCropArea(yData, image.width, image.height, rotation)
onCropArea?.invoke(cropArea)
if (cropArea == null) {
tryInvertedScan(yData, image.width, image.height, rotation) { image.close() }
} else {
val cropWidth = cropArea.right - cropArea.left
val cropHeight = cropArea.bottom - cropArea.top
val bitmap = toLumaBitmap(
yData,
image.width,
cropArea.left,
cropArea.top,
cropWidth,
cropHeight,
invert = false,
)
barcodeScanner.process(InputImage.fromBitmap(bitmap, rotation))
.addOnSuccessListener { cropCodes ->
val cropValue = cropCodes.firstOrNull()?.rawValue
if (cropValue != null) {
onSuccess(cropValue)
image.close()
} else {
tryInvertedScan(yData, image.width, image.height, rotation) { image.close() }
}
}
.addOnFailureListener {
tryInvertedScan(yData, image.width, image.height, rotation) { image.close() }
}
.addOnCompleteListener {
bitmap.recycle()
}
}
}
}
.addOnFailureListener {
failureOccurred = true
failureTimestamp = System.currentTimeMillis()
onCropArea?.invoke(null)
onFailure(it)
image.close()
}
}
private fun tryInvertedScan(image: ImageProxy) {
val inverted = image.toInvertedBitmap()
barcodeScanner.process(InputImage.fromBitmap(inverted, image.imageInfo.rotationDegrees))
private fun tryInvertedScan(
yData: ByteArray,
width: Int,
height: Int,
rotationDegrees: Int,
onComplete: () -> Unit,
) {
val inverted = toLumaBitmap(yData, width, 0, 0, width, height, invert = true)
barcodeScanner.process(InputImage.fromBitmap(inverted, rotationDegrees))
.addOnSuccessListener { codes ->
codes.firstOrNull()?.rawValue?.let { onSuccess(it) }
}
.addOnCompleteListener {
inverted.recycle()
image.close()
onComplete()
}
}
private fun ImageProxy.toInvertedBitmap(): Bitmap {
private fun ImageProxy.toYData(): ByteArray {
val yPlane = planes[0]
val yBuffer = yPlane.buffer.duplicate()
val rowStride = yPlane.rowStride
@@ -81,16 +128,41 @@ class MLKitQRCodeAnalyzer(
val height = height
val size = width * height
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val pixels = pixelBuffer?.takeIf { it.size >= size } ?: IntArray(size).also { pixelBuffer = it }
for (row in 0 until height) {
yBuffer.position(row * rowStride)
for (col in 0 until width) {
val y = 255 - (yBuffer.get().toInt() and 0xFF)
pixels[row * width + col] = (0xFF shl 24) or (y shl 16) or (y shl 8) or y
val yData = yDataBuffer?.takeIf { it.size >= size } ?: ByteArray(size).also { yDataBuffer = it }
if (rowStride == width) {
yBuffer.get(yData, 0, size)
} else {
for (row in 0 until height) {
yBuffer.position(row * rowStride)
yBuffer.get(yData, row * width, width)
}
}
return yData
}
private fun toLumaBitmap(
yData: ByteArray,
srcWidth: Int,
left: Int,
top: Int,
width: Int,
height: Int,
invert: Boolean,
): Bitmap {
val size = width * height
val pixels = pixelBuffer?.takeIf { it.size >= size } ?: IntArray(size).also { pixelBuffer = it }
var index = 0
for (row in 0 until height) {
val rowOffset = (top + row) * srcWidth + left
for (col in 0 until width) {
val y = yData[rowOffset + col].toInt() and 0xFF
val luma = if (invert) 255 - y else y
pixels[index++] = (0xFF shl 24) or (luma shl 16) or (luma shl 8) or luma
}
}
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
return bitmap
}

View File

@@ -12,6 +12,7 @@ import com.google.android.play.core.install.model.InstallStatus
import com.google.android.play.core.install.model.UpdateAvailability
import com.google.mlkit.common.MlKitException
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.ui.profile.QRCodeCropArea
import io.nekohasekai.sfa.update.UpdateInfo
import io.nekohasekai.sfa.update.UpdateState
@@ -82,9 +83,10 @@ object Vendor : VendorInterface {
override fun createQRCodeAnalyzer(
onSuccess: (String) -> Unit,
onFailure: (Exception) -> Unit,
onCropArea: ((QRCodeCropArea?) -> Unit)?,
): ImageAnalysis.Analyzer? {
try {
return MLKitQRCodeAnalyzer(onSuccess, onFailure)
return MLKitQRCodeAnalyzer(onSuccess, onFailure, onCropArea)
} catch (exception: Exception) {
if (exception !is MlKitException || exception.errorCode != MlKitException.UNAVAILABLE) {
Log.e(TAG, "failed to create MLKitQRCodeAnalyzer", exception)