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.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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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? {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
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? {
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user