Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
614c00edea | ||
|
|
3d846d0be5 | ||
|
|
c88224104e | ||
|
|
ac46bf4fcb | ||
|
|
3909650177 |
@@ -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 в критических пунктах в основном подтвержден.
|
||||||
|
- Для части исторических совпадений по секретам нужна ручная классификация: реальный ключ, тестовый маркер или документационный пример.
|
||||||
|
- Код в этой итерации не менялся; выполнено документирование и валидация.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 (bitmap != null) ResultOverlay(bitmap, "")
|
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 (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()))
|
||||||
}
|
}
|
||||||
|
if (text.isNotBlank()) {
|
||||||
Text(text, color = Color.Red, modifier = Modifier.padding(24.dp))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user