Harden Android camera crop pipeline
This commit is contained in:
@@ -1,5 +1,15 @@
|
|||||||
# История: obdai.ru/receipt
|
# История: obdai.ru/receipt
|
||||||
|
|
||||||
|
## 2026-08-29: Android MVP camera pipeline
|
||||||
|
|
||||||
|
- Установлены пользовательские Android SDK 35, Build Tools 35.0.0 и Gradle 8.11.1.
|
||||||
|
- Добавлен Android-проект `android-app` с CameraX `ImageAnalysis`, ML Kit Text Recognition, RAM-only crop и multipart-клиентом `/receipt`.
|
||||||
|
- Исправлена конвертация `YUV_420_888` с учетом `rowStride`, `pixelStride` и поворота кадра.
|
||||||
|
- Старые кадры освобождаются при замене; запрещенные storage API в `app/src` не обнаружены.
|
||||||
|
- Добавлено масштабирование координат crop и JVM unit-тест `CropHelperTest` с Robolectric.
|
||||||
|
- Проверка `:app:testDebugUnitTest :app:assembleDebug` завершилась `BUILD SUCCESSFUL`.
|
||||||
|
- Версия Android-приложения повышена до `0.1.4`.
|
||||||
|
|
||||||
## 2026-08-28
|
## 2026-08-28
|
||||||
|
|
||||||
### Вопрос
|
### Вопрос
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ android {
|
|||||||
applicationId = "ru.obdai.receipt"
|
applicationId = "ru.obdai.receipt"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 4
|
versionCode = 5
|
||||||
versionName = "0.1.3"
|
versionName = "0.1.4"
|
||||||
|
|
||||||
buildConfigField("String", "RECEIPT_API_TOKEN", "\"\"")
|
val apiToken = providers.environmentVariable("RECEIPT_API_TOKEN").orNull ?: ""
|
||||||
|
buildConfigField("String", "RECEIPT_API_TOKEN", "\"${apiToken.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
|
||||||
buildConfigField("String", "RECEIPT_API_URL", "\"https://obdai.ru/receipt\"")
|
buildConfigField("String", "RECEIPT_API_URL", "\"https://obdai.ru/receipt\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ dependencies {
|
|||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||||
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
testImplementation("org.robolectric:robolectric:4.14.1")
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
||||||
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
private var latestBitmap by mutableStateOf<Bitmap?>(null)
|
private var latestBitmap by mutableStateOf<Bitmap?>(null)
|
||||||
private var capturedBitmap by mutableStateOf<Bitmap?>(null)
|
private var capturedBitmap by mutableStateOf<Bitmap?>(null)
|
||||||
private var detectedBounds by mutableStateOf<Rect?>(null)
|
private var detectedBounds by mutableStateOf<Rect?>(null)
|
||||||
|
private var previewSize by mutableStateOf(android.util.Size(1, 1))
|
||||||
private val cameraExecutor = Executors.newSingleThreadExecutor()
|
private val cameraExecutor = Executors.newSingleThreadExecutor()
|
||||||
private val cameraManager = CameraManager()
|
private val cameraManager = CameraManager()
|
||||||
private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
@@ -73,6 +74,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
bitmap = latestBitmap,
|
bitmap = latestBitmap,
|
||||||
capturedBitmap = capturedBitmap,
|
capturedBitmap = capturedBitmap,
|
||||||
bounds = detectedBounds,
|
bounds = detectedBounds,
|
||||||
|
previewSize = previewSize,
|
||||||
state = receiptViewModel.state.collectAsState().value,
|
state = receiptViewModel.state.collectAsState().value,
|
||||||
onPreviewReady = ::onPreviewReady,
|
onPreviewReady = ::onPreviewReady,
|
||||||
onCapture = { capturedBitmap = latestBitmap },
|
onCapture = { capturedBitmap = latestBitmap },
|
||||||
@@ -97,6 +99,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
private fun onPreviewReady(view: PreviewView) {
|
private fun onPreviewReady(view: PreviewView) {
|
||||||
if (previewView === view) return
|
if (previewView === view) return
|
||||||
previewView = view
|
previewView = view
|
||||||
|
view.post { previewSize = android.util.Size(view.width.coerceAtLeast(1), view.height.coerceAtLeast(1)) }
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||||||
startCamera(view)
|
startCamera(view)
|
||||||
}
|
}
|
||||||
@@ -110,7 +113,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
val analysis = ImageAnalysis.Builder()
|
val analysis = ImageAnalysis.Builder()
|
||||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||||
.build()
|
.build()
|
||||||
analysis.setAnalyzer(cameraExecutor, cameraManager.analyzer { bitmap -> latestBitmap = bitmap })
|
analysis.setAnalyzer(cameraExecutor, cameraManager.analyzer { bitmap ->
|
||||||
|
runOnUiThread {
|
||||||
|
val previous = latestBitmap
|
||||||
|
latestBitmap = bitmap
|
||||||
|
if (previous != null && previous !== capturedBitmap && !previous.isRecycled) previous.recycle()
|
||||||
|
}
|
||||||
|
})
|
||||||
provider.unbindAll()
|
provider.unbindAll()
|
||||||
preview.setSurfaceProvider(view.surfaceProvider)
|
preview.setSurfaceProvider(view.surfaceProvider)
|
||||||
provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
||||||
@@ -133,6 +142,7 @@ private fun CameraScreen(
|
|||||||
capturedBitmap: Bitmap?,
|
capturedBitmap: Bitmap?,
|
||||||
bounds: Rect?,
|
bounds: Rect?,
|
||||||
state: UiState,
|
state: UiState,
|
||||||
|
previewSize: android.util.Size,
|
||||||
onPreviewReady: (PreviewView) -> Unit,
|
onPreviewReady: (PreviewView) -> Unit,
|
||||||
onCapture: () -> Unit,
|
onCapture: () -> Unit,
|
||||||
onRecognize: (Bitmap) -> Unit
|
onRecognize: (Bitmap) -> Unit
|
||||||
@@ -161,7 +171,15 @@ private fun CameraScreen(
|
|||||||
if (state is UiState.Result) Text(state.text, color = Color.Red, modifier = Modifier.padding(24.dp))
|
if (state is UiState.Result) Text(state.text, color = Color.Red, modifier = Modifier.padding(24.dp))
|
||||||
}
|
}
|
||||||
if (capturedBitmap != null && bounds != null) {
|
if (capturedBitmap != null && bounds != null) {
|
||||||
CropOutline(bounds)
|
CropOutline(
|
||||||
|
CropHelper.scaleToView(
|
||||||
|
bounds,
|
||||||
|
capturedBitmap.width,
|
||||||
|
capturedBitmap.height,
|
||||||
|
previewSize.width,
|
||||||
|
previewSize.height
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (state is UiState.Result) {
|
if (state is UiState.Result) {
|
||||||
ResultOverlay(state.bitmap, state.text)
|
ResultOverlay(state.bitmap, state.text)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ru.obdai.receipt.camera
|
|||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.ImageFormat
|
import android.graphics.ImageFormat
|
||||||
|
import android.graphics.Matrix
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.graphics.YuvImage
|
import android.graphics.YuvImage
|
||||||
import androidx.camera.core.ImageAnalysis
|
import androidx.camera.core.ImageAnalysis
|
||||||
@@ -20,19 +21,42 @@ class CameraManager {
|
|||||||
|
|
||||||
private fun ImageProxy.toBitmap(): Bitmap? {
|
private fun ImageProxy.toBitmap(): Bitmap? {
|
||||||
if (format != ImageFormat.YUV_420_888 || planes.size < 3) return null
|
if (format != ImageFormat.YUV_420_888 || planes.size < 3) return null
|
||||||
val y = planes[0].buffer
|
val nv21 = ByteArray(width * height * 3 / 2)
|
||||||
val u = planes[1].buffer
|
copyPlane(planes[0], width, height, nv21, 0, 1)
|
||||||
val v = planes[2].buffer
|
copyPlane(planes[2], width / 2, height / 2, nv21, width * height, 2)
|
||||||
val ySize = y.remaining()
|
copyPlane(planes[1], width / 2, height / 2, nv21, width * height + 1, 2)
|
||||||
val uSize = u.remaining()
|
|
||||||
val vSize = v.remaining()
|
|
||||||
val nv21 = ByteArray(ySize + uSize + vSize)
|
|
||||||
y.get(nv21, 0, ySize)
|
|
||||||
v.get(nv21, ySize, vSize)
|
|
||||||
u.get(nv21, ySize + vSize, uSize)
|
|
||||||
val jpeg = ByteArrayOutputStream()
|
val jpeg = ByteArrayOutputStream()
|
||||||
YuvImage(nv21, ImageFormat.NV21, width, height, null)
|
YuvImage(nv21, ImageFormat.NV21, width, height, null)
|
||||||
.compressToJpeg(Rect(0, 0, width, height), 92, jpeg)
|
.compressToJpeg(Rect(0, 0, width, height), 92, jpeg)
|
||||||
return BitmapFactory.decodeByteArray(jpeg.toByteArray(), 0, jpeg.size())
|
val decoded = BitmapFactory.decodeByteArray(jpeg.toByteArray(), 0, jpeg.size()) ?: return null
|
||||||
|
if (imageInfo.rotationDegrees == 0) return decoded
|
||||||
|
return Bitmap.createBitmap(
|
||||||
|
decoded,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
decoded.width,
|
||||||
|
decoded.height,
|
||||||
|
Matrix().apply { postRotate(imageInfo.rotationDegrees.toFloat()) },
|
||||||
|
true
|
||||||
|
).also { if (it !== decoded) decoded.recycle() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyPlane(
|
||||||
|
plane: ImageProxy.PlaneProxy,
|
||||||
|
planeWidth: Int,
|
||||||
|
planeHeight: Int,
|
||||||
|
output: ByteArray,
|
||||||
|
outputOffset: Int,
|
||||||
|
outputPixelStride: Int
|
||||||
|
) {
|
||||||
|
val buffer = plane.buffer.duplicate()
|
||||||
|
val rowStride = plane.rowStride
|
||||||
|
val pixelStride = plane.pixelStride
|
||||||
|
for (row in 0 until planeHeight) {
|
||||||
|
for (column in 0 until planeWidth) {
|
||||||
|
val sourceIndex = row * rowStride + column * pixelStride
|
||||||
|
output[outputOffset + row * planeWidth * outputPixelStride + column * outputPixelStride] = buffer.get(sourceIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,16 @@ import android.graphics.Bitmap
|
|||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
|
|
||||||
object CropHelper {
|
object CropHelper {
|
||||||
|
fun scaleToView(bounds: Rect, bitmapWidth: Int, bitmapHeight: Int, viewWidth: Int, viewHeight: Int): Rect {
|
||||||
|
require(bitmapWidth > 0 && bitmapHeight > 0 && viewWidth > 0 && viewHeight > 0)
|
||||||
|
return Rect(
|
||||||
|
bounds.left * viewWidth / bitmapWidth,
|
||||||
|
bounds.top * viewHeight / bitmapHeight,
|
||||||
|
bounds.right * viewWidth / bitmapWidth,
|
||||||
|
bounds.bottom * viewHeight / bitmapHeight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun crop(source: Bitmap, bounds: Rect): Bitmap {
|
fun crop(source: Bitmap, bounds: Rect): Bitmap {
|
||||||
require(!source.isRecycled) { "Source bitmap is recycled" }
|
require(!source.isRecycled) { "Source bitmap is recycled" }
|
||||||
val left = bounds.left.coerceIn(0, source.width)
|
val left = bounds.left.coerceIn(0, source.width)
|
||||||
|
|||||||
Reference in New Issue
Block a user