Improve QRS scan
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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? {
|
||||
|
||||
@@ -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?
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user