Compare commits

...
11 changed files with 415 additions and 26 deletions
+124
View File
@@ -1,5 +1,24 @@
# История: 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-29: Code review fixes
- Добавлено разрешение `INTERNET`; для OkHttp заданы connect/read/call timeouts.
- Аналитические Bitmap больше не рисуются поверх live preview на каждом кадре.
- YUV-конвертация учитывает `rowStride`, `pixelStride` и rotation; старые live-кадры освобождаются.
- Добавлен локальный privacy gate: признаки ФИО, пациента или даты блокируют отправку.
- ML Kit различает найденную зону, отсутствие текста и `PrivacyBlocked`.
- Unit-тесты и `assembleDebug` для версии `0.1.5` завершились успешно.
## 2026-08-28 ## 2026-08-28
### Вопрос ### Вопрос
@@ -240,6 +259,65 @@ thoughts 477, total 1627. Стоимость по тарифу Gemini 3.6 Flash
candidate output как выход: $0.00092, то есть около 9 копеек при курсе 95 candidate output как выход: $0.00092, то есть около 9 копеек при курсе 95
рублей за доллар. рублей за доллар.
## 2026-08-31: Критическая валидация отчета Opus
Пользователь запросил документировать анализ Opus, выполнить критическую
сверку по коду и сформулировать вопросы до внесения изменений.
### Что подтверждено по коду
- `recipe_service/app.py`: есть дублирование вызовов `record(...)` по веткам
ответа; единый финализатор уменьшит риск расхождений полей метрик.
- `recipe_service/app.py`: `duration_ms` считается только в ветке `401`,
в остальных ветках передается `0`.
- `recipe_service/app.py`: используется `__import__('time').monotonic()`
вместо обычного `import time`.
- `recipe_service/app.py`: сравнение Bearer-токена выполнено через `==`,
без constant-time сравнения.
- `recipe_service/metrics.py`: `record()` вызывает `initialize()` на каждый
запрос, что приводит к повторной инициализации схемы БД.
- `gemini_proxy/app.py`: endpoint принимает `api_key_override` из формы.
- `android-app/app/build.gradle.kts`: `RECEIPT_API_TOKEN` попадает в
`BuildConfig`, значит секрет присутствует в клиентском APK.
- `android-app/app/src/main/java/ru/obdai/receipt/camera/CameraManager.kt`:
на каждом кадре выполняется цепочка YUV -> JPEG -> Bitmap.
- `android-app/app/src/main/java/ru/obdai/receipt/MainActivity.kt`:
текст результата выводится и в `Column`, и в `ResultOverlay`.
- `android-app/app/src/main/java/ru/obdai/receipt/crop/MedicationZoneDetector.kt`:
дата в формате `dd.mm.yyyy` сама по себе триггерит `PatientDataDetected`.
### Что уточнено по замечаниям Opus
- Замечание про `client_ip=127.0.0.1` корректно как риск достоверности
метрик за nginx. Для надежного учета реального IP требуется явная политика
доверия прокси и корректная обработка заголовков.
- Замечание про детализацию `502` корректно как вопрос баланса между
диагностикой и минимизацией утечки деталей провайдера.
- Замечание про отсутствие тестов для `recipe_service` подтверждено: в
текущем дереве есть `gemini_proxy/test_app.py`, но нет тестового файла
для `recipe_service`.
### Вопросы к Opus (блокеры перед правками)
1. По `gemini_proxy/app.py`: удаляем `api_key_override` полностью или
оставляем только в debug-режиме под отдельным флагом окружения?
2. По `recipe_service/app.py` ветке `502`: какой контракт ошибки нужен
клиенту - единое сообщение, коды причин, или ограниченный whitelist
провайдерских причин без сырого текста?
3. По учету IP за nginx: доверяем строго одному прокси (`x_for=1`) или
нужен более строгий trust chain по сети/подсети?
4. По Android токену: подтверждаем переход на краткоживущие серверные
токены (token exchange), или остается текущая схема MVP с явной
фиксацией риска?
5. По privacy-gate: дата в medication-зоне должна блокировать отправку,
или только снижать confidence и требовать подтверждение пользователя?
### Итог
Отчет Opus в значительной части подтвержден фактами по текущему коду.
Критичные изменения backend можно готовить после ответов на вопросы выше,
чтобы не сломать контракт клиента и не ослабить security-policy.
Вывод: лимит 500 токенов оказался недостаточным; следующий независимый тест Вывод: лимит 500 токенов оказался недостаточным; следующий независимый тест
проводится с существенно большим лимитом и ещё более короткой JSON-схемой. проводится с существенно большим лимитом и ещё более короткой JSON-схемой.
@@ -622,3 +700,49 @@ nginx-маршруты без redirect и health `/receipt/health`. Старые
IP `127.0.0.1`, MIME `image/png`, размер 531101 байт, длину prompt 88 и IP `127.0.0.1`, MIME `image/png`, размер 531101 байт, длину prompt 88 и
непустой usage JSON. Тестовый image-файл удалён после запроса; приложение непустой usage JSON. Тестовый image-файл удалён после запроса; приложение
читает изображение в память и не пишет его на диск. читает изображение в память и не пишет его на диск.
## 2026-08-31: критическая валидация отчета Opus и обновление плана
Проведена проверка утверждений Opus по фактическому коду и конфигурации.
Подтверждено по backend:
- В `recipe_service/app.py` есть многократное дублирование блоков `record(...)` по веткам обработки.
- В `recipe_service/app.py` длительность `duration_ms` в большинстве веток фиксируется как `0`; фактический расчет есть только для `401`.
- В `recipe_service/app.py` используется `__import__('time').monotonic()` вместо нормального `import time`.
- В `recipe_service/app.py` сравнение bearer-токена выполнено через `==`, без constant-time сравнения.
- В `recipe_service/app.py` в `502` наружу пробрасывается provider detail, что может раскрывать внутренние детали upstream-ошибок.
- В `recipe_service/metrics.py` функция `record()` вызывает `initialize()` на каждый запрос.
- `recipe_service/nginx-recipe.conf` передает `X-Real-IP` и `X-Forwarded-For`, но в Flask используется `request.remote_addr`; без `ProxyFix` это адрес прокси.
Подтверждено по инфраструктуре:
- `recipe_service/recipe.service` слушает только `127.0.0.1:8770`.
- `gemini_proxy/gemini-proxy.service` слушает только `127.0.0.1:8768`.
Подтверждено по Android:
- `android-app/app/build.gradle.kts` задает `BuildConfig.RECEIPT_API_TOKEN`, токен попадает в клиентское приложение.
- `android-app/app/src/main/java/ru/obdai/receipt/network/ApiClient.kt` отправляет `Authorization: Bearer ...` из `BuildConfig`.
- `android-app/app/src/main/java/ru/obdai/receipt/camera/CameraManager.kt` выполняет YUV->JPEG->Bitmap на кадр в analyzer.
- `android-app/app/src/main/java/ru/obdai/receipt/MainActivity.kt` выводит результат и в `Column`, и в `ResultOverlay`.
- `android-app/app/src/main/java/ru/obdai/receipt/crop/MedicationZoneDetector.kt` блокирует при любой дате по regex, что дает ложные privacy-срабатывания.
- `android-app/app/src/main/AndroidManifest.xml` содержит `usesCleartextTraffic="false"`.
Подтверждено по тестам и зависимостям:
- Тесты присутствуют только в `gemini_proxy/test_app.py`.
- Для `recipe_service` тестов нет.
- Корневой `requirements.txt` и `recipe_service/requirements.txt` расходятся по version bounds.
Дополнительные риски, подтвержденные параллельным аудитом секретов:
- В локальных репозиториях обнаружены реальные токены/JWT и tracked secret-like файлы; часть секретов не отслеживается Git, но присутствует в рабочих каталогах.
- В `tf_provider` обнаружен tracked `secrets/llm.key`; в истории есть многочисленные совпадения по маркерам `sk-`.
- В `nifi_fork` история содержит совпадения по маркерам `sk-` и `AKIA`; часть совпадений относится к документации/тестам и требует ручной triage по значениям.
Статус:
- Отчет Opus по backend и Android в критических пунктах в основном подтвержден.
- Для части исторических совпадений по секретам нужна ручная классификация: реальный ключ, тестовый маркер или документационный пример.
- Код в этой итерации не менялся; выполнено документирование и валидация.
+35
View File
@@ -182,3 +182,38 @@ crop лекарственной строки
- начать с SQLite и перейти на отдельный PostgreSQL только при появлении требований к параллельной записи, админке или нескольким экземплярам сервиса. - начать с SQLite и перейти на отдельный PostgreSQL только при появлении требований к параллельной записи, админке или нескольким экземплярам сервиса.
Перед фиксацией порогов и коэффициентов scoring провести эксперимент на 50–200 обезличенных рецептах. Перед фиксацией порогов и коэффициентов scoring провести эксперимент на 50–200 обезличенных рецептах.
## 12. Критический план по результатам валидации Opus (2026-08-31)
### Подтвержденные проблемы и приоритет
1. `recipe_service/metrics.py`: убрать `initialize()` из `record()`; инициализация должна быть только на старте сервиса.
2. `recipe_service/app.py`: устранить дублирование `record(...)` через единый финализатор/обертку завершения запроса.
3. `recipe_service/app.py`: считать `duration_ms` во всех ветках, а не только при `401`.
4. `recipe_service/app.py`: заменить сравнение токена на constant-time (`hmac.compare_digest`).
5. `recipe_service/app.py`: минимизировать детализацию `502` для клиента, подробности оставлять в логах.
6. `recipe_service/app.py`: добавить учет заголовков прокси (`ProxyFix`) для корректного client IP.
7. Тесты: добавить `recipe_service/test_app.py` с кейсами `401/400/415/413/200`.
8. `requirements.txt`: выровнять root и `recipe_service/requirements.txt` по version bounds.
### Android-блок (после подтверждения модели безопасности)
1. Убрать долговременный статический секрет из APK или перейти на схему краткоживущих токенов.
2. Оптимизировать pipeline камеры: снизить частоту тяжелой конвертации или заменить способ получения bitmap.
3. Убрать двойной вывод результата (`Column` и `ResultOverlay`) и оставить один источник отображения.
4. Пересмотреть `containsPatientData` в `MedicationZoneDetector`: дата сама по себе не должна блокировать распознавание.
### Вопросы к Opus (требуют уточнения до правок)
1. `api_key_override` в `gemini_proxy/app.py`: удалять полностью или оставить только для локального debug-режима за флагом окружения?
2. По `502`: какой клиентский формат ошибки нужен — единое сообщение или кодизированные причины без деталей провайдера?
3. Для `ProxyFix`: доверяем ровно одному прокси (`x_for=1`) или нужен более строгий trust chain?
4. Для Android: подтверждаем перенос от статического API-токена к короткоживущему серверному token exchange?
5. По privacy-гейту: дата рецепта в medication-зоне допустима или должна только понижать confidence без блокировки?
### План выполнения после подтверждения
1. Backend фаза A: `metrics.py` + `app.py` (инициализация, duration, record-finalizer, compare_digest, ProxyFix).
2. Backend фаза B: тесты `recipe_service/test_app.py` + проверка pytest.
3. Backend фаза C: harmonize requirements.
4. Android фаза D: безопасность токена + оптимизация камеры + privacy-gate.
+6 -3
View File
@@ -12,10 +12,11 @@ android {
applicationId = "ru.obdai.receipt" applicationId = "ru.obdai.receipt"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 2 versionCode = 6
versionName = "0.1.1" versionName = "0.1.5"
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\"")
} }
@@ -47,11 +48,13 @@ dependencies {
implementation("androidx.camera:camera-camera2:1.4.1") implementation("androidx.camera:camera-camera2:1.4.1")
implementation("androidx.camera:camera-lifecycle:1.4.1") implementation("androidx.camera:camera-lifecycle:1.4.1")
implementation("androidx.camera:camera-view:1.4.1") implementation("androidx.camera:camera-view:1.4.1")
implementation("com.google.mlkit:text-recognition:16.0.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
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")
@@ -1,5 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:allowBackup="false" android:allowBackup="false"
@@ -4,6 +4,7 @@ import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Rect
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -19,9 +20,11 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -34,10 +37,20 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import ru.obdai.receipt.camera.CameraManager import ru.obdai.receipt.camera.CameraManager
import ru.obdai.receipt.crop.CropHelper
import ru.obdai.receipt.network.ApiClient
import ru.obdai.receipt.viewmodel.ReceiptViewModel
import ru.obdai.receipt.viewmodel.UiState
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import java.util.concurrent.Executors import java.util.concurrent.Executors
class MainActivity : ComponentActivity() { 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 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 ->
@@ -46,7 +59,38 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { CameraScreen(latestBitmap, ::onPreviewReady) } setContent {
val receiptViewModel: ReceiptViewModel = viewModel(
factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ReceiptViewModel(
ApiClient(BuildConfig.RECEIPT_API_URL, BuildConfig.RECEIPT_API_TOKEN)
) as T
}
}
)
CameraScreen(
bitmap = latestBitmap,
capturedBitmap = capturedBitmap,
bounds = detectedBounds,
previewSize = previewSize,
state = receiptViewModel.state.collectAsState().value,
onPreviewReady = ::onPreviewReady,
onCapture = { capturedBitmap = latestBitmap },
onRecognize = { bitmap ->
receiptViewModel.detectZone(bitmap) { bounds ->
detectedBounds = bounds
if (bounds != null) {
receiptViewModel.recognize(
CropHelper.crop(bitmap, bounds),
"Recognize only medicine names, dosage, quantity and schedule. Return concise text."
)
}
}
}
)
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
permissionLauncher.launch(Manifest.permission.CAMERA) permissionLauncher.launch(Manifest.permission.CAMERA)
} }
@@ -55,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)
} }
@@ -68,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)
@@ -79,13 +130,23 @@ class MainActivity : ComponentActivity() {
override fun onDestroy() { override fun onDestroy() {
latestBitmap?.let { if (!it.isRecycled) it.recycle() } latestBitmap?.let { if (!it.isRecycled) it.recycle() }
if (capturedBitmap !== latestBitmap) capturedBitmap?.let { if (!it.isRecycled) it.recycle() }
cameraExecutor.shutdown() cameraExecutor.shutdown()
super.onDestroy() super.onDestroy()
} }
} }
@Composable @Composable
private fun CameraScreen(bitmap: Bitmap?, onPreviewReady: (PreviewView) -> Unit = {}) { private fun CameraScreen(
bitmap: Bitmap?,
capturedBitmap: Bitmap?,
bounds: Rect?,
state: UiState,
previewSize: android.util.Size,
onPreviewReady: (PreviewView) -> Unit,
onCapture: () -> Unit,
onRecognize: (Bitmap) -> Unit
) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
AndroidView( AndroidView(
factory = { context -> PreviewView(context).also(onPreviewReady) }, factory = { context -> PreviewView(context).also(onPreviewReady) },
@@ -95,11 +156,47 @@ private fun CameraScreen(bitmap: Bitmap?, onPreviewReady: (PreviewView) -> Unit
modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(16.dp), modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Button(onClick = { /* Crop and recognize are wired by ReceiptViewModel. */ }, modifier = Modifier.fillMaxWidth()) { Button(onClick = onCapture, modifier = Modifier.fillMaxWidth()) {
Text("Распознать") Text("Зафиксировать кадр")
} }
Button(
onClick = { capturedBitmap?.let(onRecognize) },
enabled = capturedBitmap != null && state !is UiState.Analyzing,
modifier = Modifier.fillMaxWidth()
) {
if (state is UiState.Analyzing) CircularProgressIndicator()
else Text(if (bounds == null) "Найти и распознать препараты" else "Распознать crop")
}
if (state is UiState.Error) Text(state.message, color = Color.Red)
if (state is UiState.PrivacyBlocked) {
Text("Обнаружены данные пациента. Отправка заблокирована.", color = Color.Red)
}
if (state is UiState.Result) Text(state.text, color = Color.Red, modifier = Modifier.padding(24.dp))
} }
if (bitmap != null) ResultOverlay(bitmap, "") if (capturedBitmap != null && bounds != null) {
CropOutline(
CropHelper.scaleToView(
bounds,
capturedBitmap.width,
capturedBitmap.height,
previewSize.width,
previewSize.height
)
)
}
if (state is UiState.Result) ResultOverlay(state.bitmap, state.text)
}
}
@Composable
private fun CropOutline(bounds: Rect) {
Canvas(Modifier.fillMaxSize()) {
drawRect(
color = Color.Green,
topLeft = androidx.compose.ui.geometry.Offset(bounds.left.toFloat(), bounds.top.toFloat()),
size = androidx.compose.ui.geometry.Size(bounds.width().toFloat(), bounds.height().toFloat()),
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 4f)
)
} }
} }
@@ -108,5 +205,7 @@ private fun ResultOverlay(bitmap: Bitmap, text: String) {
Canvas(Modifier.fillMaxSize()) { Canvas(Modifier.fillMaxSize()) {
drawImage(bitmap.asImageBitmap(), dstSize = IntSize(size.width.toInt(), size.height.toInt())) drawImage(bitmap.asImageBitmap(), dstSize = IntSize(size.width.toInt(), size.height.toInt()))
} }
Text(text, color = Color.Red, modifier = Modifier.padding(24.dp)) if (text.isNotBlank()) {
Text(text, color = Color.Red, modifier = Modifier.padding(24.dp))
}
} }
@@ -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)
@@ -0,0 +1,51 @@
package ru.obdai.receipt.crop
import android.graphics.Bitmap
import android.graphics.Rect
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.text.TextRecognition
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import java.util.regex.Pattern
sealed interface ZoneDetection {
data class Found(val bounds: Rect) : ZoneDetection
data object NoText : ZoneDetection
data object PatientDataDetected : ZoneDetection
}
class MedicationZoneDetector {
private val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
suspend fun detect(bitmap: Bitmap): ZoneDetection = suspendCancellableCoroutine { continuation ->
recognizer.process(InputImage.fromBitmap(bitmap, 0))
.addOnSuccessListener { result ->
val text = result.text
if (containsPatientData(text)) {
continuation.resume(ZoneDetection.PatientDataDetected)
return@addOnSuccessListener
}
val blocks = result.textBlocks
.map { it.boundingBox }
.filterNotNull()
.filter { it.top > bitmap.height / 5 }
val bounds = blocks.reduceOrNull { first, next ->
Rect(first).apply { union(next) }
}
continuation.resume(bounds?.let(ZoneDetection::Found) ?: ZoneDetection.NoText)
}
.addOnFailureListener { continuation.resume(ZoneDetection.NoText) }
}
fun close() {
recognizer.close()
}
private fun containsPatientData(text: String): Boolean {
val normalized = text.lowercase()
val date = Pattern.compile("""\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b""").matcher(normalized).find()
val labels = listOf("ф.и.о", "фамилия", "имя", "отчество", "дата рождения", "пациент")
return date || labels.any(normalized::contains)
}
}
@@ -7,6 +7,7 @@ import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
@Serializable @Serializable
data class ReceiptResponse(val text: String? = null, val usage: Usage? = null) data class ReceiptResponse(val text: String? = null, val usage: Usage? = null)
@@ -17,7 +18,11 @@ data class Usage(val promptTokens: Int? = null, val candidatesTokens: Int? = nul
class ApiClient( class ApiClient(
private val endpoint: String, private val endpoint: String,
private val token: String, private val token: String,
private val client: OkHttpClient = OkHttpClient() private val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.callTimeout(150, TimeUnit.SECONDS)
.build()
) { ) {
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@@ -7,16 +7,22 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.obdai.receipt.crop.MedicationZoneDetector
import ru.obdai.receipt.crop.ZoneDetection
import ru.obdai.receipt.network.ApiClient import ru.obdai.receipt.network.ApiClient
sealed interface UiState { sealed interface UiState {
data object Idle : UiState data object Idle : UiState
data object Analyzing : UiState data object Analyzing : UiState
data object PrivacyBlocked : UiState
data class Result(val bitmap: Bitmap, val text: String) : UiState data class Result(val bitmap: Bitmap, val text: String) : UiState
data class Error(val message: String) : UiState data class Error(val message: String) : UiState
} }
class ReceiptViewModel(private val apiClient: ApiClient) : ViewModel() { class ReceiptViewModel(
private val apiClient: ApiClient,
private val zoneDetector: MedicationZoneDetector = MedicationZoneDetector()
) : ViewModel() {
private val _state = MutableStateFlow<UiState>(UiState.Idle) private val _state = MutableStateFlow<UiState>(UiState.Idle)
val state: StateFlow<UiState> = _state val state: StateFlow<UiState> = _state
@@ -35,17 +41,32 @@ class ReceiptViewModel(private val apiClient: ApiClient) : ViewModel() {
} }
} }
fun detectZone(bitmap: Bitmap, onDetected: (android.graphics.Rect?) -> Unit) {
viewModelScope.launch(Dispatchers.Default) {
when (val detection = zoneDetector.detect(bitmap)) {
is ZoneDetection.Found -> onDetected(detection.bounds)
ZoneDetection.PatientDataDetected -> {
_state.value = UiState.PrivacyBlocked
onDetected(null)
}
ZoneDetection.NoText -> onDetected(null)
}
}
}
override fun onCleared() { override fun onCleared() {
(_state.value as? UiState.Result)?.bitmap?.let { bitmap -> (_state.value as? UiState.Result)?.bitmap?.let { bitmap ->
if (!bitmap.isRecycled) bitmap.recycle() if (!bitmap.isRecycled) bitmap.recycle()
} }
_state.value = UiState.Idle _state.value = UiState.Idle
zoneDetector.close()
super.onCleared() super.onCleared()
} }
private fun Bitmap.toJpegBytes(): ByteArray { private fun Bitmap.toJpegBytes(): ByteArray {
val output = java.io.ByteArrayOutputStream() return java.io.ByteArrayOutputStream().use { output ->
compress(Bitmap.CompressFormat.JPEG, 92, output) compress(Bitmap.CompressFormat.JPEG, 92, output)
return output.toByteArray() output.toByteArray()
}
} }
} }
@@ -0,0 +1,16 @@
package ru.obdai.receipt.crop
import android.graphics.Rect
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class CropHelperTest {
@Test
fun scalesBitmapBoundsToViewBounds() {
val result = CropHelper.scaleToView(Rect(100, 200, 500, 600), 1000, 1000, 500, 1000)
assertEquals(Rect(50, 200, 250, 600), result)
}
}