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.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -39,8 +40,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource 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.R
import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult
import io.nekohasekai.sfa.compose.screen.qrscan.QRScanViewModel import io.nekohasekai.sfa.compose.screen.qrscan.QRScanViewModel
import io.nekohasekai.sfa.ui.profile.QRCodeCropArea
import kotlin.math.max
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -186,7 +191,8 @@ fun QRScanSheet(
CameraPreview( CameraPreview(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
viewModel = viewModel, viewModel = viewModel,
lifecycleOwner = lifecycleOwner lifecycleOwner = lifecycleOwner,
cropArea = uiState.cropArea,
) )
} }
@@ -261,6 +267,7 @@ private fun CameraPreview(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: QRScanViewModel, viewModel: QRScanViewModel,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
cropArea: QRCodeCropArea?,
) { ) {
var previewView by remember { mutableStateOf<PreviewView?>(null) } var previewView by remember { mutableStateOf<PreviewView?>(null) }
@@ -272,20 +279,92 @@ private fun CameraPreview(
onDispose { } onDispose { }
} }
AndroidView( Box(modifier = modifier) {
modifier = modifier, AndroidView(
factory = { ctx -> modifier = Modifier.fillMaxSize(),
PreviewView(ctx).apply { factory = { ctx ->
implementationMode = PreviewView.ImplementationMode.COMPATIBLE PreviewView(ctx).apply {
previewView = this implementationMode = PreviewView.ImplementationMode.COMPATIBLE
previewView = this
previewStreamState.observe(lifecycleOwner) { state -> previewStreamState.observe(lifecycleOwner) { state ->
if (state == PreviewView.StreamState.STREAMING) { if (state == PreviewView.StreamState.STREAMING) {
viewModel.onPreviewStreamStateChanged(true) viewModel.onPreviewStreamStateChanged(true)
implementationMode = PreviewView.ImplementationMode.PERFORMANCE 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.libbox.Libbox
import io.nekohasekai.sfa.qrs.QRSDecoder import io.nekohasekai.sfa.qrs.QRSDecoder
import io.nekohasekai.sfa.qrs.readIntLE import io.nekohasekai.sfa.qrs.readIntLE
import io.nekohasekai.sfa.ui.profile.QRCodeCropArea
import io.nekohasekai.sfa.ui.profile.ZxingQRCodeAnalyzer import io.nekohasekai.sfa.ui.profile.ZxingQRCodeAnalyzer
import io.nekohasekai.sfa.vendor.Vendor import io.nekohasekai.sfa.vendor.Vendor
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -48,6 +49,7 @@ data class QRScanUiState(
val vendorAnalyzerAvailable: Boolean = false, val vendorAnalyzerAvailable: Boolean = false,
val qrsMode: Boolean = false, val qrsMode: Boolean = false,
val qrsProgress: Pair<Int, Int>? = null, val qrsProgress: Pair<Int, Int>? = null,
val cropArea: QRCodeCropArea? = null,
val errorMessage: String? = null, val errorMessage: String? = null,
val result: QRScanResult? = null, val result: QRScanResult? = null,
val zoomRatio: Float = 1f, val zoomRatio: Float = 1f,
@@ -75,7 +77,8 @@ class QRScanViewModel(application: Application) : AndroidViewModel(application)
private val vendorAnalyzer: ImageAnalysis.Analyzer? = Vendor.createQRCodeAnalyzer( private val vendorAnalyzer: ImageAnalysis.Analyzer? = Vendor.createQRCodeAnalyzer(
onSuccess = { rawValue -> handleScanSuccess(rawValue) }, onSuccess = { rawValue -> handleScanSuccess(rawValue) },
onFailure = { exception -> handleScanFailure(exception) } onFailure = { exception -> handleScanFailure(exception) },
onCropArea = ::updateCropArea,
) )
init { init {
@@ -95,6 +98,16 @@ class QRScanViewModel(application: Application) : AndroidViewModel(application)
handleScanFailure(exception) 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) { private fun handleScanSuccess(rawValue: String) {
Log.d(TAG, "Scanned: ${rawValue.take(100)}...") Log.d(TAG, "Scanned: ${rawValue.take(100)}...")
val qrsPayload = extractQRSPayload(rawValue) val qrsPayload = extractQRSPayload(rawValue)
@@ -102,6 +115,7 @@ class QRScanViewModel(application: Application) : AndroidViewModel(application)
if (qrsPayload != null) { if (qrsPayload != null) {
handleQRSFrame(qrsPayload) handleQRSFrame(qrsPayload)
} else { } else {
updateCropArea(null)
if (_uiState.value.qrsMode) { if (_uiState.value.qrsMode) {
resetQRSState() resetQRSState()
} }
@@ -114,6 +128,7 @@ class QRScanViewModel(application: Application) : AndroidViewModel(application)
if (_uiState.value.qrsMode) { if (_uiState.value.qrsMode) {
return return
} }
updateCropArea(null)
imageAnalysis?.clearAnalyzer() imageAnalysis?.clearAnalyzer()
if (showingError.compareAndSet(false, true)) { if (showingError.compareAndSet(false, true)) {
resetAnalyzer() resetAnalyzer()
@@ -125,7 +140,7 @@ class QRScanViewModel(application: Application) : AndroidViewModel(application)
if (_uiState.value.useVendorAnalyzer && vendorAnalyzer != null) { if (_uiState.value.useVendorAnalyzer && vendorAnalyzer != null) {
_uiState.update { it.copy(useVendorAnalyzer = false) } _uiState.update { it.copy(useVendorAnalyzer = false) }
imageAnalysis?.clearAnalyzer() imageAnalysis?.clearAnalyzer()
imageAnalyzer = ZxingQRCodeAnalyzer(onSuccess, onFailure) imageAnalyzer = ZxingQRCodeAnalyzer(onSuccess, onFailure, onCropArea = ::updateCropArea)
imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!) imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!)
} }
} }
@@ -154,7 +169,7 @@ class QRScanViewModel(application: Application) : AndroidViewModel(application)
imageAnalyzer = if (_uiState.value.useVendorAnalyzer && vendorAnalyzer != null) { imageAnalyzer = if (_uiState.value.useVendorAnalyzer && vendorAnalyzer != null) {
vendorAnalyzer vendorAnalyzer
} else { } else {
ZxingQRCodeAnalyzer(onSuccess, onFailure) ZxingQRCodeAnalyzer(onSuccess, onFailure, onCropArea = ::updateCropArea)
} }
imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!) imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!)
@@ -220,12 +235,13 @@ class QRScanViewModel(application: Application) : AndroidViewModel(application)
val newState = !_uiState.value.useVendorAnalyzer val newState = !_uiState.value.useVendorAnalyzer
_uiState.update { it.copy(useVendorAnalyzer = newState) } _uiState.update { it.copy(useVendorAnalyzer = newState) }
updateCropArea(null)
imageAnalysis?.clearAnalyzer() imageAnalysis?.clearAnalyzer()
imageAnalyzer = if (newState) { imageAnalyzer = if (newState) {
vendorAnalyzer vendorAnalyzer
} else { } else {
ZxingQRCodeAnalyzer(onSuccess, onFailure) ZxingQRCodeAnalyzer(onSuccess, onFailure, onCropArea = ::updateCropArea)
} }
imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!) imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!)
} }
@@ -238,7 +254,7 @@ class QRScanViewModel(application: Application) : AndroidViewModel(application)
fun clearResult() { fun clearResult() {
resetQRSState() resetQRSState()
_uiState.update { it.copy(result = null) } _uiState.update { it.copy(result = null, cropArea = null) }
} }
private fun extractQRSPayload(content: String): ByteArray? { private fun extractQRSPayload(content: String): ByteArray? {

View File

@@ -13,7 +13,7 @@ object QRSConstants {
const val MAX_FPS = 60 const val MAX_FPS = 60
// Slice Size settings // Slice Size settings
const val DEFAULT_SLICE_SIZE = 512 const val DEFAULT_SLICE_SIZE = 500
const val MIN_SLICE_SIZE = 100 const val MIN_SLICE_SIZE = 100
const val MAX_SLICE_SIZE = 1500 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( class ZxingQRCodeAnalyzer(
private val onSuccess: ((String) -> Unit), private val onSuccess: ((String) -> Unit),
private val onFailure: ((Exception) -> Unit), private val onFailure: ((Exception) -> Unit),
private val onCropArea: ((QRCodeCropArea?) -> Unit)? = null,
) : ImageAnalysis.Analyzer { ) : ImageAnalysis.Analyzer {
private val qrCodeReader = QRCodeReader() private val qrCodeReader = QRCodeReader()
private var yDataBuffer: ByteArray? = null private var yDataBuffer: ByteArray? = null
@@ -23,7 +24,11 @@ class ZxingQRCodeAnalyzer(
override fun analyze(image: ImageProxy) { override fun analyze(image: ImageProxy) {
try { 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 // Fast path: HybridBinarizer
tryDecode(BinaryBitmap(HybridBinarizer(source)))?.let { tryDecode(BinaryBitmap(HybridBinarizer(source)))?.let {
@@ -31,6 +36,27 @@ class ZxingQRCodeAnalyzer(
return 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 // In QRS mode, skip additional binarizer attempts for performance
if (qrsMode) return if (qrsMode) return
@@ -65,7 +91,7 @@ class ZxingQRCodeAnalyzer(
} }
} }
private fun ImageProxy.toYUVSource(): PlanarYUVLuminanceSource { private fun ImageProxy.toYUVData(): ByteArray {
val yPlane = planes[0] val yPlane = planes[0]
val yBuffer = yPlane.buffer val yBuffer = yPlane.buffer
val rowStride = yPlane.rowStride val rowStride = yPlane.rowStride
@@ -80,7 +106,7 @@ class ZxingQRCodeAnalyzer(
yBuffer.get(yData, row * width, width) yBuffer.get(yData, row * width, width)
} }
} }
return PlanarYUVLuminanceSource(yData, width, height, 0, 0, width, height, false) return yData
} }
private fun tryDecode(bitmap: BinaryBitmap): Result? { private fun tryDecode(bitmap: BinaryBitmap): Result? {

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ui.profile.QRCodeCropArea
import io.nekohasekai.sfa.update.UpdateCheckException import io.nekohasekai.sfa.update.UpdateCheckException
import io.nekohasekai.sfa.update.UpdateInfo import io.nekohasekai.sfa.update.UpdateInfo
import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateState
@@ -91,6 +92,7 @@ object Vendor : VendorInterface {
override fun createQRCodeAnalyzer( override fun createQRCodeAnalyzer(
onSuccess: (String) -> Unit, onSuccess: (String) -> Unit,
onFailure: (Exception) -> Unit, onFailure: (Exception) -> Unit,
onCropArea: ((QRCodeCropArea?) -> Unit)?,
): ImageAnalysis.Analyzer? { ): ImageAnalysis.Analyzer? {
return null 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.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage 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 // kanged from: https://github.com/G00fY2/quickie/blob/main/quickie/src/main/kotlin/io/github/g00fy2/quickie/QRCodeAnalyzer.kt
class MLKitQRCodeAnalyzer( class MLKitQRCodeAnalyzer(
private val onSuccess: ((String) -> Unit), private val onSuccess: ((String) -> Unit),
private val onFailure: ((Exception) -> Unit), private val onFailure: ((Exception) -> Unit),
private val onCropArea: ((QRCodeCropArea?) -> Unit)? = null,
) : ImageAnalysis.Analyzer { ) : ImageAnalysis.Analyzer {
private val barcodeScanner = private val barcodeScanner =
BarcodeScanning.getClient( BarcodeScanning.getClient(
@@ -26,6 +29,7 @@ class MLKitQRCodeAnalyzer(
private var failureOccurred = false private var failureOccurred = false
private var failureTimestamp = 0L private var failureTimestamp = 0L
private var yDataBuffer: ByteArray? = null
private var pixelBuffer: IntArray? = null private var pixelBuffer: IntArray? = null
@ExperimentalGetImage @ExperimentalGetImage
@@ -38,6 +42,7 @@ class MLKitQRCodeAnalyzer(
val nowMills = System.currentTimeMillis() val nowMills = System.currentTimeMillis()
if (failureOccurred && nowMills - failureTimestamp < 5000L) { if (failureOccurred && nowMills - failureTimestamp < 5000L) {
failureTimestamp = nowMills failureTimestamp = nowMills
onCropArea?.invoke(null)
image.close() image.close()
return return
} }
@@ -47,33 +52,75 @@ class MLKitQRCodeAnalyzer(
.addOnSuccessListener { codes -> .addOnSuccessListener { codes ->
val rawValue = codes.firstOrNull()?.rawValue val rawValue = codes.firstOrNull()?.rawValue
if (rawValue != null) { if (rawValue != null) {
onCropArea?.invoke(null)
onSuccess(rawValue) onSuccess(rawValue)
image.close() image.close()
} else { } 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 { .addOnFailureListener {
failureOccurred = true failureOccurred = true
failureTimestamp = System.currentTimeMillis() failureTimestamp = System.currentTimeMillis()
onCropArea?.invoke(null)
onFailure(it) onFailure(it)
image.close() image.close()
} }
} }
private fun tryInvertedScan(image: ImageProxy) { private fun tryInvertedScan(
val inverted = image.toInvertedBitmap() yData: ByteArray,
barcodeScanner.process(InputImage.fromBitmap(inverted, image.imageInfo.rotationDegrees)) 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 -> .addOnSuccessListener { codes ->
codes.firstOrNull()?.rawValue?.let { onSuccess(it) } codes.firstOrNull()?.rawValue?.let { onSuccess(it) }
} }
.addOnCompleteListener { .addOnCompleteListener {
inverted.recycle() inverted.recycle()
image.close() onComplete()
} }
} }
private fun ImageProxy.toInvertedBitmap(): Bitmap { private fun ImageProxy.toYData(): ByteArray {
val yPlane = planes[0] val yPlane = planes[0]
val yBuffer = yPlane.buffer.duplicate() val yBuffer = yPlane.buffer.duplicate()
val rowStride = yPlane.rowStride val rowStride = yPlane.rowStride
@@ -81,16 +128,41 @@ class MLKitQRCodeAnalyzer(
val height = height val height = height
val size = width * height val size = width * height
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val yData = yDataBuffer?.takeIf { it.size >= size } ?: ByteArray(size).also { yDataBuffer = it }
val pixels = pixelBuffer?.takeIf { it.size >= size } ?: IntArray(size).also { pixelBuffer = it } if (rowStride == width) {
yBuffer.get(yData, 0, size)
for (row in 0 until height) { } else {
yBuffer.position(row * rowStride) for (row in 0 until height) {
for (col in 0 until width) { yBuffer.position(row * rowStride)
val y = 255 - (yBuffer.get().toInt() and 0xFF) yBuffer.get(yData, row * width, width)
pixels[row * width + col] = (0xFF shl 24) or (y shl 16) or (y shl 8) or y
} }
} }
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) bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
return bitmap 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.android.play.core.install.model.UpdateAvailability
import com.google.mlkit.common.MlKitException import com.google.mlkit.common.MlKitException
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.ui.profile.QRCodeCropArea
import io.nekohasekai.sfa.update.UpdateInfo import io.nekohasekai.sfa.update.UpdateInfo
import io.nekohasekai.sfa.update.UpdateState import io.nekohasekai.sfa.update.UpdateState
@@ -82,9 +83,10 @@ object Vendor : VendorInterface {
override fun createQRCodeAnalyzer( override fun createQRCodeAnalyzer(
onSuccess: (String) -> Unit, onSuccess: (String) -> Unit,
onFailure: (Exception) -> Unit, onFailure: (Exception) -> Unit,
onCropArea: ((QRCodeCropArea?) -> Unit)?,
): ImageAnalysis.Analyzer? { ): ImageAnalysis.Analyzer? {
try { try {
return MLKitQRCodeAnalyzer(onSuccess, onFailure) return MLKitQRCodeAnalyzer(onSuccess, onFailure, onCropArea)
} catch (exception: Exception) { } catch (exception: Exception) {
if (exception !is MlKitException || exception.errorCode != MlKitException.UNAVAILABLE) { if (exception !is MlKitException || exception.errorCode != MlKitException.UNAVAILABLE) {
Log.e(TAG, "failed to create MLKitQRCodeAnalyzer", exception) Log.e(TAG, "failed to create MLKitQRCodeAnalyzer", exception)