diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRScanSheet.kt b/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRScanSheet.kt index 9a63b7c..6de9561 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRScanSheet.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRScanSheet.kt @@ -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(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) } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRScanViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRScanViewModel.kt index 07d4aec..ab41f2b 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRScanViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRScanViewModel.kt @@ -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? = 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? { diff --git a/app/src/main/java/io/nekohasekai/sfa/qrs/QRSConstants.kt b/app/src/main/java/io/nekohasekai/sfa/qrs/QRSConstants.kt index a494ce8..8893873 100644 --- a/app/src/main/java/io/nekohasekai/sfa/qrs/QRSConstants.kt +++ b/app/src/main/java/io/nekohasekai/sfa/qrs/QRSConstants.kt @@ -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 diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRCodeSmartCrop.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRCodeSmartCrop.kt new file mode 100644 index 0000000..662d2a4 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRCodeSmartCrop.kt @@ -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 + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt index 6e9bc42..4014cdd 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt @@ -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? { diff --git a/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt b/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt index e27c4bf..81c1437 100644 --- a/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt +++ b/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt @@ -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? /** diff --git a/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt index 0525a7d..64b8b76 100644 --- a/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -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 } diff --git a/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt index 6d4706f..847649c 100644 --- a/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/otherLegacy/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -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 } diff --git a/app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt b/app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt index 15d49d2..4bda124 100644 --- a/app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt +++ b/app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt @@ -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 } diff --git a/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt index 6def77d..42e1310 100644 --- a/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -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)