From ac46bf4fcba59e91b128487e44face05c994e0b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 29 Aug 2026 16:03:35 +0300 Subject: [PATCH] Harden Android camera crop pipeline --- HISTORY/2026-08-28-obdai-receipt.md | 10 ++++ android-app/app/build.gradle.kts | 8 ++-- .../java/ru/obdai/receipt/MainActivity.kt | 22 ++++++++- .../ru/obdai/receipt/camera/CameraManager.kt | 46 ++++++++++++++----- .../java/ru/obdai/receipt/crop/CropHelper.kt | 10 ++++ 5 files changed, 80 insertions(+), 16 deletions(-) diff --git a/HISTORY/2026-08-28-obdai-receipt.md b/HISTORY/2026-08-28-obdai-receipt.md index 620cfc6..2094d00 100644 --- a/HISTORY/2026-08-28-obdai-receipt.md +++ b/HISTORY/2026-08-28-obdai-receipt.md @@ -1,5 +1,15 @@ # История: 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 ### Вопрос diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index edff92a..65b6169 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -12,10 +12,11 @@ android { applicationId = "ru.obdai.receipt" minSdk = 26 targetSdk = 35 - versionCode = 4 - versionName = "0.1.3" + versionCode = 5 + 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\"") } @@ -53,6 +54,7 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") testImplementation("junit:junit:4.13.2") + testImplementation("org.robolectric:robolectric:4.14.1") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") androidTestImplementation("androidx.test.ext:junit:1.2.1") diff --git a/android-app/app/src/main/java/ru/obdai/receipt/MainActivity.kt b/android-app/app/src/main/java/ru/obdai/receipt/MainActivity.kt index f0859b2..a834120 100644 --- a/android-app/app/src/main/java/ru/obdai/receipt/MainActivity.kt +++ b/android-app/app/src/main/java/ru/obdai/receipt/MainActivity.kt @@ -50,6 +50,7 @@ class MainActivity : ComponentActivity() { private var latestBitmap by mutableStateOf(null) private var capturedBitmap by mutableStateOf(null) private var detectedBounds by mutableStateOf(null) + private var previewSize by mutableStateOf(android.util.Size(1, 1)) private val cameraExecutor = Executors.newSingleThreadExecutor() private val cameraManager = CameraManager() private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> @@ -73,6 +74,7 @@ class MainActivity : ComponentActivity() { bitmap = latestBitmap, capturedBitmap = capturedBitmap, bounds = detectedBounds, + previewSize = previewSize, state = receiptViewModel.state.collectAsState().value, onPreviewReady = ::onPreviewReady, onCapture = { capturedBitmap = latestBitmap }, @@ -97,6 +99,7 @@ class MainActivity : ComponentActivity() { private fun onPreviewReady(view: PreviewView) { if (previewView === view) return 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) { startCamera(view) } @@ -110,7 +113,13 @@ class MainActivity : ComponentActivity() { val analysis = ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .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() preview.setSurfaceProvider(view.surfaceProvider) provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis) @@ -133,6 +142,7 @@ private fun CameraScreen( capturedBitmap: Bitmap?, bounds: Rect?, state: UiState, + previewSize: android.util.Size, onPreviewReady: (PreviewView) -> Unit, onCapture: () -> 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 (capturedBitmap != null && bounds != null) { - CropOutline(bounds) + CropOutline( + CropHelper.scaleToView( + bounds, + capturedBitmap.width, + capturedBitmap.height, + previewSize.width, + previewSize.height + ) + ) } if (state is UiState.Result) { ResultOverlay(state.bitmap, state.text) diff --git a/android-app/app/src/main/java/ru/obdai/receipt/camera/CameraManager.kt b/android-app/app/src/main/java/ru/obdai/receipt/camera/CameraManager.kt index 4c42484..11a422b 100644 --- a/android-app/app/src/main/java/ru/obdai/receipt/camera/CameraManager.kt +++ b/android-app/app/src/main/java/ru/obdai/receipt/camera/CameraManager.kt @@ -3,6 +3,7 @@ package ru.obdai.receipt.camera import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.ImageFormat +import android.graphics.Matrix import android.graphics.Rect import android.graphics.YuvImage import androidx.camera.core.ImageAnalysis @@ -20,19 +21,42 @@ class CameraManager { private fun ImageProxy.toBitmap(): Bitmap? { if (format != ImageFormat.YUV_420_888 || planes.size < 3) return null - val y = planes[0].buffer - val u = planes[1].buffer - val v = planes[2].buffer - val ySize = y.remaining() - 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 nv21 = ByteArray(width * height * 3 / 2) + copyPlane(planes[0], width, height, nv21, 0, 1) + copyPlane(planes[2], width / 2, height / 2, nv21, width * height, 2) + copyPlane(planes[1], width / 2, height / 2, nv21, width * height + 1, 2) val jpeg = ByteArrayOutputStream() YuvImage(nv21, ImageFormat.NV21, width, height, null) .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) + } + } } } \ No newline at end of file diff --git a/android-app/app/src/main/java/ru/obdai/receipt/crop/CropHelper.kt b/android-app/app/src/main/java/ru/obdai/receipt/crop/CropHelper.kt index ac639ef..df9ea50 100644 --- a/android-app/app/src/main/java/ru/obdai/receipt/crop/CropHelper.kt +++ b/android-app/app/src/main/java/ru/obdai/receipt/crop/CropHelper.kt @@ -4,6 +4,16 @@ import android.graphics.Bitmap import android.graphics.Rect 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 { require(!source.isRecycled) { "Source bitmap is recycled" } val left = bounds.left.coerceIn(0, source.width)