chore(cleanup): YOLO/CameraX/OCR 세트 완전 제거 (Category B · 사용자 요청)
배경: 소변컵 사진 촬영 → YOLO 검출 → MLKit OCR 눈금 인식 기능 폐기.
현재 미사용. Piezo 방광 측정 flow 만 실사용.
완전 제거 (4 파일 + 1 asset):
· measure/YoloDetector.kt (130 L · ONNX YOLO)
· measure/SimpleMeasureService.kt (287 L · MLKit OCR + 3-zone)
· measure/CCPosition.kt (8 L)
· ui/views/monitoring/UrineCameraScreen.kt (285 L · CameraX 프리뷰)
· assets/urinecup_best.onnx (~10.6 MB · APK 크기 절감)
Gradle deps 삭제 (build.gradle.kts):
· com.microsoft.onnxruntime:onnxruntime-android:1.17.0
· com.google.mlkit:text-recognition:16.0.1
· androidx.camera:camera-{core,camera2,lifecycle,view}:1.3.4
AndroidManifest.xml 삭제:
· CAMERA permission
· android.hardware.camera uses-feature
부분 편집:
· AppState.kt — URINE_CAMERA enum value 삭제
· MainActivity.kt — import + back-handler branch + Crossfade branch 삭제
· PiezoMonitoringView.kt — showUrineCamera state · 카메라 카드 (16L) ·
LaunchedEffect(showUrineCamera) 삭제
· strings.xml × 2 — 카메라 전용 문자열 15개 삭제
(camera_input 은 CatheterizeSheet 사용 중이라 유지)
측정용 VoidingRecord.kt 는 유지 (CatheterizeSheet · VoidingDiaryView 등에서 사용).
okhttp 는 speech/HfVolumeExtractor 에서 사용 중이라 유지.
합계: 약 780 LOC + 10.6 MB APK 크기 절감.
빌드: BUILD SUCCESSFUL 30s (첫 시도 통과).
This commit is contained in:
@@ -81,13 +81,7 @@ dependencies {
|
|||||||
implementation(libs.rive.android)
|
implementation(libs.rive.android)
|
||||||
implementation(libs.androidx.startup.runtime)
|
implementation(libs.androidx.startup.runtime)
|
||||||
|
|
||||||
// 소변컵 측정 모듈
|
// 2026-08-12: YOLO/CameraX/MLKit 제거 (소변컵 사진 인식 기능 폐기).
|
||||||
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.17.0") // YOLO
|
|
||||||
implementation("com.google.mlkit:text-recognition:16.0.1") // OCR
|
|
||||||
implementation("androidx.camera:camera-core:1.3.4") // CameraX
|
|
||||||
implementation("androidx.camera:camera-camera2:1.3.4")
|
|
||||||
implementation("androidx.camera:camera-lifecycle:1.3.4")
|
|
||||||
implementation("androidx.camera:camera-view:1.3.4")
|
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0") // HF API
|
implementation("com.squareup.okhttp3:okhttp:4.12.0") // HF API
|
||||||
implementation(libs.mcumgr.ble) // Firmware DFU (SMP-over-BLE)
|
implementation(libs.mcumgr.ble) // Firmware DFU (SMP-over-BLE)
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,6 @@
|
|||||||
<!-- Notification Permission (Android 13+) -->
|
<!-- Notification Permission (Android 13+) -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<!-- Camera (소변컵 촬영) -->
|
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
|
||||||
|
|
||||||
<!-- Voice Recognition -->
|
<!-- Voice Recognition -->
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -14,7 +14,7 @@ enum class AppScreen {
|
|||||||
PIEZO_PERSONALIZATION,
|
PIEZO_PERSONALIZATION,
|
||||||
PLACEMENT_GUIDE, PIN_SETUP, PIN_ENTRY,
|
PLACEMENT_GUIDE, PIN_SETUP, PIN_ENTRY,
|
||||||
PIEZO_MONITORING, MEASUREMENT_HISTORY,
|
PIEZO_MONITORING, MEASUREMENT_HISTORY,
|
||||||
VOIDING_DIARY, URINE_CAMERA, REMINDER_SETTINGS,
|
VOIDING_DIARY, REMINDER_SETTINGS,
|
||||||
CLINICAL_HOME, // ★ Dev 모드 한정 — 임상 R&D 측정 세션 화면 (입력 폼)
|
CLINICAL_HOME, // ★ Dev 모드 한정 — 임상 R&D 측정 세션 화면 (입력 폼)
|
||||||
CLINICAL_LIVE, // ★ Dev 모드 한정 — 6채널 라이브 시각화 + Capture
|
CLINICAL_LIVE, // ★ Dev 모드 한정 — 6채널 라이브 시각화 + Capture
|
||||||
FIRMWARE_UPDATE // ★ 2026-08-03 (cloud-mvp 672e679 이식) — VBT 프로브 firmware DFU
|
FIRMWARE_UPDATE // ★ 2026-08-03 (cloud-mvp 672e679 이식) — VBT 프로브 firmware DFU
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import com.medithings.vesiscan.ui.views.history.MeasurementHistoryView
|
|||||||
import com.medithings.vesiscan.ui.views.home.HomeView
|
import com.medithings.vesiscan.ui.views.home.HomeView
|
||||||
import com.medithings.vesiscan.ui.views.monitoring.PlacementGuideView
|
import com.medithings.vesiscan.ui.views.monitoring.PlacementGuideView
|
||||||
import com.medithings.vesiscan.ui.views.monitoring.PiezoMonitoringView
|
import com.medithings.vesiscan.ui.views.monitoring.PiezoMonitoringView
|
||||||
import com.medithings.vesiscan.ui.views.monitoring.UrineCameraScreen
|
|
||||||
import com.medithings.vesiscan.ui.views.monitoring.VoidingDiaryView
|
import com.medithings.vesiscan.ui.views.monitoring.VoidingDiaryView
|
||||||
import com.medithings.vesiscan.ui.views.onboarding.OnboardingView
|
import com.medithings.vesiscan.ui.views.onboarding.OnboardingView
|
||||||
import com.medithings.vesiscan.ui.views.personalization.PiezoPersonalizationView
|
import com.medithings.vesiscan.ui.views.personalization.PiezoPersonalizationView
|
||||||
@@ -149,7 +148,6 @@ fun MedilightApp(modifier: Modifier = Modifier) {
|
|||||||
AppScreen.MEASUREMENT_HISTORY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
AppScreen.MEASUREMENT_HISTORY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
||||||
AppScreen.VOIDING_DIARY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
AppScreen.VOIDING_DIARY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
||||||
AppScreen.REMINDER_SETTINGS -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
AppScreen.REMINDER_SETTINGS -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
||||||
AppScreen.URINE_CAMERA -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
|
||||||
AppScreen.FIRMWARE_UPDATE -> appState.currentScreen = AppScreen.HOME
|
AppScreen.FIRMWARE_UPDATE -> appState.currentScreen = AppScreen.HOME
|
||||||
// 기타
|
// 기타
|
||||||
AppScreen.SENSOR_SELECT -> appState.currentScreen = AppScreen.HOME
|
AppScreen.SENSOR_SELECT -> appState.currentScreen = AppScreen.HOME
|
||||||
@@ -178,13 +176,6 @@ fun MedilightApp(modifier: Modifier = Modifier) {
|
|||||||
AppScreen.PIEZO_MONITORING -> PiezoMonitoringView(appState)
|
AppScreen.PIEZO_MONITORING -> PiezoMonitoringView(appState)
|
||||||
AppScreen.MEASUREMENT_HISTORY -> MeasurementHistoryView(appState)
|
AppScreen.MEASUREMENT_HISTORY -> MeasurementHistoryView(appState)
|
||||||
AppScreen.VOIDING_DIARY -> VoidingDiaryView(onBack = { appState.currentScreen = AppScreen.HOME })
|
AppScreen.VOIDING_DIARY -> VoidingDiaryView(onBack = { appState.currentScreen = AppScreen.HOME })
|
||||||
AppScreen.URINE_CAMERA -> UrineCameraScreen(
|
|
||||||
onDone = { volume ->
|
|
||||||
if (volume != null && volume > 0) appState.resetLevel()
|
|
||||||
appState.currentScreen = AppScreen.PIEZO_MONITORING
|
|
||||||
},
|
|
||||||
onCancel = { appState.currentScreen = AppScreen.PIEZO_MONITORING }
|
|
||||||
)
|
|
||||||
AppScreen.REMINDER_SETTINGS -> ReminderSettingsView(
|
AppScreen.REMINDER_SETTINGS -> ReminderSettingsView(
|
||||||
onBack = { appState.backToMonitoring() }
|
onBack = { appState.backToMonitoring() }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
package com.medithings.vesiscan.measure
|
|
||||||
|
|
||||||
data class CCPosition(
|
|
||||||
val cc: Int,
|
|
||||||
val x: Int,
|
|
||||||
val y: Int,
|
|
||||||
val predicted: Boolean = false
|
|
||||||
)
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
package com.medithings.vesiscan.measure
|
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.util.Log
|
|
||||||
import com.google.mlkit.vision.common.InputImage
|
|
||||||
import com.google.mlkit.vision.text.TextRecognition
|
|
||||||
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
|
|
||||||
import com.medithings.vesiscan.measure.CCPosition
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
/**
|
|
||||||
* V2 측정 서비스 — 순수 Kotlin, C++/OpenCV 불필요.
|
|
||||||
* iOS 3-zone Center 2-peak 방식.
|
|
||||||
*
|
|
||||||
* YOLO crop → OCR → 3-zone gradient (Center 2-peak) → yToCC
|
|
||||||
*/
|
|
||||||
object SimpleMeasureService {
|
|
||||||
private const val TAG = "SimpleMeasure"
|
|
||||||
|
|
||||||
data class MeasureResult(
|
|
||||||
val volume: Int?,
|
|
||||||
val isSmallVolume: Boolean = false,
|
|
||||||
val timeMs: Long = 0,
|
|
||||||
val log: String = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun measure(bitmap: Bitmap): MeasureResult = withContext(Dispatchers.Default) {
|
|
||||||
val log = StringBuilder()
|
|
||||||
val t0 = System.currentTimeMillis()
|
|
||||||
|
|
||||||
// 밝기 체크
|
|
||||||
val brightness = checkBrightness(bitmap)
|
|
||||||
log.appendLine("밝기: ${"%.0f".format(brightness)}/255")
|
|
||||||
if (brightness < 80) {
|
|
||||||
log.appendLine("⚠ 이미지가 너무 어둡습니다")
|
|
||||||
return@withContext MeasureResult(null, log = log.toString(), timeMs = System.currentTimeMillis() - t0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// OCR
|
|
||||||
val rawOcr = runOCR(bitmap)
|
|
||||||
val t1 = System.currentTimeMillis()
|
|
||||||
val ocrPositions = filterOcrPositions(rawOcr)
|
|
||||||
log.appendLine("OCR: ${t1 - t0}ms → ${ocrPositions.size}개 눈금")
|
|
||||||
|
|
||||||
if (ocrPositions.size < 2) {
|
|
||||||
return@withContext MeasureResult(null, log = log.toString() + "눈금 부족", timeMs = System.currentTimeMillis() - t0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 스캔 영역: 0~500cc 전체 범위로 외삽
|
|
||||||
val sorted = ocrPositions.sortedBy { it.y }
|
|
||||||
val avgGap = (sorted.last().y - sorted.first().y).toFloat() / (sorted.size - 1)
|
|
||||||
val pxPer50cc = avgGap
|
|
||||||
val topOcrCC = sorted.first().cc.toFloat() // e.g., 450
|
|
||||||
val botOcrCC = sorted.last().cc.toFloat() // e.g., 100
|
|
||||||
val gapsAbove = (500f - topOcrCC) / 50f
|
|
||||||
val gapsBelow = botOcrCC / 50f
|
|
||||||
val scanTop = (sorted.first().y - pxPer50cc * gapsAbove - 10).toInt().coerceAtLeast(0)
|
|
||||||
val scanBot = (sorted.last().y + pxPer50cc * gapsBelow + 10).toInt().coerceAtMost(bitmap.height - 1)
|
|
||||||
val width = bitmap.width
|
|
||||||
|
|
||||||
// OCR 좌표 기반 zone 설정
|
|
||||||
val ocrMaxX = ocrPositions.maxOf { it.x }
|
|
||||||
val ocrOnLeft = ocrPositions.map { it.x }.average() < width / 2.0
|
|
||||||
|
|
||||||
val zoneALeft: Int; val zoneARight: Int
|
|
||||||
val zoneBLeft: Int; val zoneBRight: Int
|
|
||||||
val zoneCLeft: Int; val zoneCRight: Int
|
|
||||||
|
|
||||||
if (ocrOnLeft) {
|
|
||||||
zoneALeft = (width * 0.15).toInt(); zoneARight = (width * 0.33).toInt()
|
|
||||||
zoneBLeft = (width * 0.35).toInt(); zoneBRight = (width * 0.55).toInt()
|
|
||||||
zoneCLeft = (width * 0.60).toInt(); zoneCRight = (width * 0.85).toInt()
|
|
||||||
} else {
|
|
||||||
zoneALeft = (width * 0.15).toInt(); zoneARight = (width * 0.40).toInt()
|
|
||||||
zoneBLeft = (width * 0.45).toInt(); zoneBRight = (width * 0.65).toInt()
|
|
||||||
zoneCLeft = (width * 0.67).toInt(); zoneCRight = (width * 0.85).toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3-zone: Center(B) primary + Left(A)/Right(C) cross-zone 확인
|
|
||||||
val peaksB = computeZonePeaks(bitmap, scanTop, scanBot, zoneBLeft, zoneBRight)
|
|
||||||
val peaksA = computeZonePeaks(bitmap, scanTop, scanBot, zoneALeft, zoneARight)
|
|
||||||
val peaksC = computeZonePeaks(bitmap, scanTop, scanBot, zoneCLeft, zoneCRight)
|
|
||||||
val t2 = System.currentTimeMillis()
|
|
||||||
|
|
||||||
val surfaceY: Int
|
|
||||||
if (peaksB.isEmpty()) {
|
|
||||||
val allSide = peaksA + peaksC
|
|
||||||
if (allSide.isEmpty()) {
|
|
||||||
return@withContext MeasureResult(null, log = log.toString() + "감지 실패", timeMs = System.currentTimeMillis() - t0)
|
|
||||||
}
|
|
||||||
surfaceY = allSide.maxByOrNull { it.second }!!.first
|
|
||||||
} else {
|
|
||||||
val maxVal = peaksB.maxOf { it.second }
|
|
||||||
val rawSigPeaks = peaksB.filter { it.second >= maxVal * 0.3f }.sortedByDescending { it.second }
|
|
||||||
|
|
||||||
// 근접 peak 병합: 0.3*avgGap 이내면 건너뜀
|
|
||||||
val minPeakDist = (avgGap * 0.3f).toInt()
|
|
||||||
val sigPeaks = mutableListOf<Pair<Int, Float>>()
|
|
||||||
for (sp in rawSigPeaks) {
|
|
||||||
val tooClose = sigPeaks.any { kotlin.math.abs(it.first - sp.first) < minPeakDist }
|
|
||||||
if (!tooClose) sigPeaks.add(sp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cross-zone 확인: Left(A)/Right(C)에서도 비슷한 y에 peak가 있는지 확인
|
|
||||||
// Center peak가 OCR 텍스트 피처일 수 있으므로, 확인되는 peak를 우선 승격
|
|
||||||
val confirmRadius = (avgGap * 0.5f).toInt()
|
|
||||||
fun countConfirming(peakY: Int): Int {
|
|
||||||
var count = 0
|
|
||||||
if (peaksA.any { kotlin.math.abs(it.first - peakY) <= confirmRadius && it.second > 0.01f }) count++
|
|
||||||
if (peaksC.any { kotlin.math.abs(it.first - peakY) <= confirmRadius && it.second > 0.01f }) count++
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
// 상위 5개 중 첫 번째로 확인되는 peak를 승격
|
|
||||||
val topCandidates = sigPeaks.take(5)
|
|
||||||
val confirmedPeak = topCandidates.firstOrNull { countConfirming(it.first) >= 1 }
|
|
||||||
val orderedPeaks = if (confirmedPeak != null && confirmedPeak != topCandidates.firstOrNull()) {
|
|
||||||
log.appendLine("승격: y=${confirmedPeak.first} (확인됨) → 최강 y=${sigPeaks[0].first} 대체")
|
|
||||||
val reordered = mutableListOf(confirmedPeak)
|
|
||||||
reordered.addAll(sigPeaks.filter { it != confirmedPeak })
|
|
||||||
reordered
|
|
||||||
} else {
|
|
||||||
sigPeaks
|
|
||||||
}
|
|
||||||
|
|
||||||
if (orderedPeaks.size >= 2) {
|
|
||||||
val ellipseTop = minOf(orderedPeaks[0].first, orderedPeaks[1].first)
|
|
||||||
val ellipseBot = maxOf(orderedPeaks[0].first, orderedPeaks[1].first)
|
|
||||||
val peakDistance = ellipseBot - ellipseTop
|
|
||||||
val maxEllipseSpan = (avgGap * 1.0f).toInt()
|
|
||||||
|
|
||||||
if (peakDistance > maxEllipseSpan) {
|
|
||||||
surfaceY = orderedPeaks[0].first
|
|
||||||
log.appendLine("타원 거부: 거리 ${peakDistance}px > 한계 ${maxEllipseSpan}px → peak y=$surfaceY")
|
|
||||||
} else {
|
|
||||||
surfaceY = ellipseBot
|
|
||||||
log.appendLine("타원: top=$ellipseTop, bot=$ellipseBot, 거리=${peakDistance}px")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
surfaceY = orderedPeaks[0].first
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// yToCC 보간
|
|
||||||
val volume = interpolate(surfaceY, sorted)
|
|
||||||
val totalMs = System.currentTimeMillis() - t0
|
|
||||||
log.appendLine("수면: y=$surfaceY → ${volume}ml (${totalMs}ms)")
|
|
||||||
Log.d(TAG, "V2: ${volume}ml, ${totalMs}ms")
|
|
||||||
|
|
||||||
MeasureResult(volume = volume, timeMs = totalMs, log = log.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun computeZonePeaks(bitmap: Bitmap, scanTop: Int, scanBot: Int, leftX: Int, rightX: Int): List<Pair<Int, Float>> {
|
|
||||||
val height = bitmap.height
|
|
||||||
val width = bitmap.width
|
|
||||||
val satProfile = FloatArray(height)
|
|
||||||
|
|
||||||
for (y in scanTop..scanBot) {
|
|
||||||
var satSum = 0f; var count = 0
|
|
||||||
for (x in leftX until rightX) {
|
|
||||||
if (x < 0 || x >= width) continue
|
|
||||||
val pixel = bitmap.getPixel(x, y)
|
|
||||||
val r = Color.red(pixel) / 255f; val g = Color.green(pixel) / 255f; val b = Color.blue(pixel) / 255f
|
|
||||||
val max = maxOf(r, g, b); val min = minOf(r, g, b)
|
|
||||||
satSum += if (max > 0) (max - min) / max else 0f
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
satProfile[y] = if (count > 0) satSum / count else 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
val smoothed = FloatArray(height)
|
|
||||||
for (y in scanTop..scanBot) {
|
|
||||||
var sum = 0f; var cnt = 0
|
|
||||||
for (k in -10..10) { val idx = y + k; if (idx in scanTop..scanBot) { sum += satProfile[idx]; cnt++ } }
|
|
||||||
smoothed[y] = if (cnt > 0) sum / cnt else 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
val step = 15
|
|
||||||
val grad = FloatArray(height)
|
|
||||||
for (y in (scanTop + step)..(scanBot - step)) { grad[y] = smoothed[y + step] - smoothed[y] }
|
|
||||||
|
|
||||||
val gradSmooth = FloatArray(height)
|
|
||||||
for (y in scanTop..scanBot) {
|
|
||||||
var sum = 0f; var cnt = 0
|
|
||||||
for (k in -5..5) { val idx = y + k; if (idx in scanTop..scanBot) { sum += grad[idx]; cnt++ } }
|
|
||||||
gradSmooth[y] = if (cnt > 0) sum / cnt else 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
val peaks = mutableListOf<Pair<Int, Float>>()
|
|
||||||
for (y in (scanTop + 1)..(scanBot - 1)) {
|
|
||||||
if (gradSmooth[y] > 0 && gradSmooth[y] >= gradSmooth[y - 1] && gradSmooth[y] >= gradSmooth[y + 1]) {
|
|
||||||
peaks.add(Pair(y, gradSmooth[y]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return peaks
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun interpolate(surfaceY: Int, sorted: List<CCPosition>): Int? {
|
|
||||||
if (sorted.size < 2) return null
|
|
||||||
|
|
||||||
// 외삽: OCR 최상단 위 (450cc 이상 → 500cc 방향)
|
|
||||||
if (surfaceY <= sorted.first().y) {
|
|
||||||
val pxPerCC = (sorted[1].y - sorted[0].y).toFloat() / (sorted[1].cc - sorted[0].cc)
|
|
||||||
val extrapolated = sorted.first().cc + (surfaceY - sorted.first().y) / pxPerCC
|
|
||||||
return extrapolated.coerceIn(0f, 500f).roundToInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 외삽: OCR 최하단 아래 (100cc 이하 → 0cc 방향)
|
|
||||||
if (surfaceY >= sorted.last().y) {
|
|
||||||
val n = sorted.size
|
|
||||||
val pxPerCC = (sorted[n - 1].y - sorted[n - 2].y).toFloat() / (sorted[n - 1].cc - sorted[n - 2].cc)
|
|
||||||
val extrapolated = sorted.last().cc + (surfaceY - sorted.last().y) / pxPerCC
|
|
||||||
return extrapolated.coerceIn(0f, 500f).roundToInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 보간: 인접한 두 마크 사이
|
|
||||||
for (i in 0 until sorted.size - 1) {
|
|
||||||
val upper = sorted[i]; val lower = sorted[i + 1]
|
|
||||||
if (surfaceY in upper.y..lower.y) {
|
|
||||||
val t = (surfaceY - upper.y).toFloat() / (lower.y - upper.y).toFloat()
|
|
||||||
return (upper.cc + t * (lower.cc - upper.cc)).roundToInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkBrightness(bitmap: Bitmap): Float {
|
|
||||||
val w = bitmap.width; val h = bitmap.height
|
|
||||||
var sum = 0L; var count = 0
|
|
||||||
var y = h / 6
|
|
||||||
while (y < h / 3) {
|
|
||||||
var x = w / 4
|
|
||||||
while (x < w * 3 / 4) {
|
|
||||||
val p = bitmap.getPixel(x, y)
|
|
||||||
sum += (Color.red(p) * 299 + Color.green(p) * 587 + Color.blue(p) * 114) / 1000
|
|
||||||
count++; x += 4
|
|
||||||
}; y += 4
|
|
||||||
}
|
|
||||||
return if (count > 0) sum.toFloat() / count else 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun runOCR(bitmap: Bitmap): List<CCPosition> =
|
|
||||||
suspendCancellableCoroutine { cont ->
|
|
||||||
val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
|
|
||||||
recognizer.process(InputImage.fromBitmap(bitmap, 0))
|
|
||||||
.addOnSuccessListener { visionText ->
|
|
||||||
val positions = mutableListOf<CCPosition>()
|
|
||||||
val regex = Regex("""(\d{2,3})\s*(?:cc|ml|CC|ML|Cc|mL)?""")
|
|
||||||
for (block in visionText.textBlocks) {
|
|
||||||
for (line in block.lines) {
|
|
||||||
val match = regex.find(line.text) ?: continue
|
|
||||||
val ccVal = match.groupValues[1].toIntOrNull() ?: continue
|
|
||||||
if (ccVal !in 50..500 || ccVal % 50 != 0) continue
|
|
||||||
val bbox = line.boundingBox ?: continue
|
|
||||||
positions.add(CCPosition(cc = ccVal, x = bbox.centerX(), y = bbox.centerY()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cont.resume(positions.distinctBy { it.cc }.sortedBy { it.y })
|
|
||||||
}
|
|
||||||
.addOnFailureListener { cont.resume(emptyList()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun filterOcrPositions(raw: List<CCPosition>): List<CCPosition> {
|
|
||||||
if (raw.size < 2) return raw
|
|
||||||
val sortedByY = raw.sortedBy { it.y }
|
|
||||||
val valid = mutableListOf<CCPosition>()
|
|
||||||
for (pos in sortedByY) {
|
|
||||||
if (valid.isEmpty() || pos.cc < valid.last().cc) valid.add(pos)
|
|
||||||
}
|
|
||||||
if (valid.size >= 3) {
|
|
||||||
val gaps = (0 until valid.size - 1).map { valid[it + 1].y - valid[it].y }
|
|
||||||
val medianGap = gaps.sorted()[gaps.size / 2]
|
|
||||||
val filtered = mutableListOf(valid.first())
|
|
||||||
for (i in 1 until valid.size) {
|
|
||||||
val gap = valid[i].y - filtered.last().y
|
|
||||||
if (gap > medianGap * 0.3 && gap < medianGap * 3.0) filtered.add(valid[i])
|
|
||||||
}
|
|
||||||
return filtered
|
|
||||||
}
|
|
||||||
return valid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
package com.medithings.vesiscan.measure
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.RectF
|
|
||||||
import android.util.Log
|
|
||||||
import ai.onnxruntime.OnnxTensor
|
|
||||||
import ai.onnxruntime.OrtEnvironment
|
|
||||||
import ai.onnxruntime.OrtSession
|
|
||||||
import java.nio.FloatBuffer
|
|
||||||
|
|
||||||
class YoloDetector(private val context: Context) {
|
|
||||||
|
|
||||||
private var session: OrtSession? = null
|
|
||||||
private var ortEnv: OrtEnvironment? = null
|
|
||||||
private val inputSize = 640
|
|
||||||
private var isLoaded = false
|
|
||||||
|
|
||||||
data class DetectionResult(
|
|
||||||
val boundingBox: RectF, // Normalized [0,1], top-left origin
|
|
||||||
val confidence: Float,
|
|
||||||
val label: String
|
|
||||||
)
|
|
||||||
|
|
||||||
fun loadModel(): Boolean {
|
|
||||||
return try {
|
|
||||||
ortEnv = OrtEnvironment.getEnvironment()
|
|
||||||
val modelBytes = context.assets.open("urinecup_best.onnx").readBytes()
|
|
||||||
session = ortEnv!!.createSession(modelBytes)
|
|
||||||
isLoaded = true
|
|
||||||
Log.d(TAG, "ONNX YOLO model loaded successfully")
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to load ONNX model", e)
|
|
||||||
isLoaded = false
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isModelLoaded(): Boolean = isLoaded
|
|
||||||
|
|
||||||
fun detect(bitmap: Bitmap): DetectionResult? {
|
|
||||||
val session = this.session ?: return null
|
|
||||||
val env = this.ortEnv ?: return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
val resized = Bitmap.createScaledBitmap(bitmap, inputSize, inputSize, true)
|
|
||||||
|
|
||||||
// Bitmap → float array [1, 3, 640, 640] NCHW, normalized 0-1
|
|
||||||
val floatBuffer = FloatBuffer.allocate(1 * 3 * inputSize * inputSize)
|
|
||||||
val pixels = IntArray(inputSize * inputSize)
|
|
||||||
resized.getPixels(pixels, 0, inputSize, 0, 0, inputSize, inputSize)
|
|
||||||
|
|
||||||
for (c in 0 until 3) {
|
|
||||||
for (i in pixels.indices) {
|
|
||||||
val pixel = pixels[i]
|
|
||||||
val value = when (c) {
|
|
||||||
0 -> ((pixel shr 16) and 0xFF) / 255f // R
|
|
||||||
1 -> ((pixel shr 8) and 0xFF) / 255f // G
|
|
||||||
2 -> (pixel and 0xFF) / 255f // B
|
|
||||||
else -> 0f
|
|
||||||
}
|
|
||||||
floatBuffer.put(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
floatBuffer.rewind()
|
|
||||||
|
|
||||||
val shape = longArrayOf(1, 3, inputSize.toLong(), inputSize.toLong())
|
|
||||||
val inputTensor = OnnxTensor.createTensor(env, floatBuffer, shape)
|
|
||||||
|
|
||||||
val inputName = session.inputNames.first()
|
|
||||||
val results = session.run(mapOf(inputName to inputTensor))
|
|
||||||
|
|
||||||
// YOLOv8 output: [1, 5, 8400]
|
|
||||||
val outputTensor = results[0] as OnnxTensor
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val output = (outputTensor.value as Array<Array<FloatArray>>)[0]
|
|
||||||
|
|
||||||
inputTensor.close()
|
|
||||||
results.close()
|
|
||||||
|
|
||||||
return parseYoloOutput(output)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "YOLO detection failed", e)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseYoloOutput(output: Array<FloatArray>): DetectionResult? {
|
|
||||||
// output: [5, 8400] → rows = cx, cy, w, h, conf
|
|
||||||
val numDetections = output[0].size
|
|
||||||
var bestConf = 0.25f
|
|
||||||
var bestBox: FloatArray? = null
|
|
||||||
|
|
||||||
for (i in 0 until numDetections) {
|
|
||||||
val conf = output[4][i]
|
|
||||||
if (conf > bestConf) {
|
|
||||||
bestConf = conf
|
|
||||||
val cx = output[0][i] / inputSize
|
|
||||||
val cy = output[1][i] / inputSize
|
|
||||||
val w = output[2][i] / inputSize
|
|
||||||
val h = output[3][i] / inputSize
|
|
||||||
bestBox = floatArrayOf(cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestBox?.let {
|
|
||||||
DetectionResult(
|
|
||||||
boundingBox = RectF(
|
|
||||||
it[0].coerceIn(0f, 1f),
|
|
||||||
it[1].coerceIn(0f, 1f),
|
|
||||||
it[2].coerceIn(0f, 1f),
|
|
||||||
it[3].coerceIn(0f, 1f)
|
|
||||||
),
|
|
||||||
confidence = bestConf,
|
|
||||||
label = "urinecup"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun close() {
|
|
||||||
session?.close()
|
|
||||||
session = null
|
|
||||||
isLoaded = false
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "YoloDetector"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -74,7 +74,6 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
var showCatheterizeSheet by remember { mutableStateOf(false) }
|
var showCatheterizeSheet by remember { mutableStateOf(false) }
|
||||||
var showManualInput by remember { mutableStateOf(false) }
|
var showManualInput by remember { mutableStateOf(false) }
|
||||||
var showVoiceInput by remember { mutableStateOf(false) }
|
var showVoiceInput by remember { mutableStateOf(false) }
|
||||||
var showUrineCamera by remember { mutableStateOf(false) }
|
|
||||||
var showAddCatheterDialog by remember { mutableStateOf(false) }
|
var showAddCatheterDialog by remember { mutableStateOf(false) }
|
||||||
var isAutoMeasuring by remember { mutableStateOf(false) }
|
var isAutoMeasuring by remember { mutableStateOf(false) }
|
||||||
// 회의 #3: dev mode 진입 시 로그/패널 자동 펼침 (default false였던 게 사용자 입장에서 사라진 것처럼 보임).
|
// 회의 #3: dev mode 진입 시 로그/패널 자동 펼침 (default false였던 게 사용자 입장에서 사라진 것처럼 보임).
|
||||||
@@ -1018,22 +1017,6 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
Icon(Icons.Default.ChevronRight, contentDescription = null, tint = MlSecondaryText)
|
Icon(Icons.Default.ChevronRight, contentDescription = null, tint = MlSecondaryText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 카메라 입력
|
|
||||||
Surface(
|
|
||||||
onClick = { showCatheterizeSheet = false; showUrineCamera = true },
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
color = Color(0xFFF3E5F5)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.CameraAlt, contentDescription = null, tint = Color(0xFF9C27B0))
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Text(stringResource(R.string.camera_input), fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
|
||||||
Icon(Icons.Default.ChevronRight, contentDescription = null, tint = MlSecondaryText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Quick Save — 현재 측정값으로 저장 + volume 0 리셋
|
// Quick Save — 현재 측정값으로 저장 + volume 0 리셋
|
||||||
val quickVol = if (lastVolumeMl > 0) lastVolumeMl.toInt() else appState.estimatedVolume
|
val quickVol = if (lastVolumeMl > 0) lastVolumeMl.toInt() else appState.estimatedVolume
|
||||||
Surface(
|
Surface(
|
||||||
@@ -1163,13 +1146,6 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카메라 → AppScreen으로 전환
|
|
||||||
LaunchedEffect(showUrineCamera) {
|
|
||||||
if (showUrineCamera) {
|
|
||||||
showUrineCamera = false
|
|
||||||
appState.currentScreen = AppScreen.URINE_CAMERA
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -1,285 +0,0 @@
|
|||||||
package com.medithings.vesiscan.ui.views.monitoring
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.Matrix
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.camera.core.*
|
|
||||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
|
||||||
import androidx.camera.view.PreviewView
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import com.medithings.vesiscan.R
|
|
||||||
import com.medithings.vesiscan.measure.SimpleMeasureService
|
|
||||||
import com.medithings.vesiscan.measure.VoidingRecord
|
|
||||||
import com.medithings.vesiscan.measure.VoidingRecordStore
|
|
||||||
import com.medithings.vesiscan.measure.YoloDetector
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 소변컵 카메라 촬영 + V2 측정 화면 (medilightv2용 간소화 버전)
|
|
||||||
* YOLO 자동감지 → 2초 자동촬영 → V2 측정 → 로컬 저장
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun UrineCameraScreen(
|
|
||||||
onDone: (Int?) -> Unit, // 측정 완료 콜백 (volume)
|
|
||||||
onCancel: () -> Unit
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
var hasCameraPerm by remember {
|
|
||||||
mutableStateOf(ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
|
|
||||||
}
|
|
||||||
val permLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { hasCameraPerm = it }
|
|
||||||
LaunchedEffect(Unit) { if (!hasCameraPerm) permLauncher.launch(Manifest.permission.CAMERA) }
|
|
||||||
|
|
||||||
val yoloDetector = remember { YoloDetector(context) }
|
|
||||||
var isModelLoaded by remember { mutableStateOf(false) }
|
|
||||||
var currentDetection by remember { mutableStateOf<YoloDetector.DetectionResult?>(null) }
|
|
||||||
LaunchedEffect(Unit) { withContext(Dispatchers.IO) { isModelLoaded = yoloDetector.loadModel() } }
|
|
||||||
DisposableEffect(Unit) { onDispose { yoloDetector.close() } }
|
|
||||||
|
|
||||||
var capturedBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
|
||||||
var originalBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
|
||||||
var isAnalyzing by remember { mutableStateOf(false) }
|
|
||||||
var resultVolume by remember { mutableStateOf<Int?>(null) }
|
|
||||||
var analysisComplete by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// 자동 촬영
|
|
||||||
var detectionStartTime by remember { mutableStateOf<Long?>(null) }
|
|
||||||
var autoCaptureProgress by remember { mutableFloatStateOf(0f) }
|
|
||||||
var isAutoCapturing by remember { mutableStateOf(false) }
|
|
||||||
val autoCaptureDelay = 2000L
|
|
||||||
|
|
||||||
val imageCapture = remember {
|
|
||||||
ImageCapture.Builder().setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun doCapture() {
|
|
||||||
isAutoCapturing = true
|
|
||||||
imageCapture.takePicture(ContextCompat.getMainExecutor(context), object : ImageCapture.OnImageCapturedCallback() {
|
|
||||||
override fun onCaptureSuccess(image: ImageProxy) {
|
|
||||||
val bmp = image.toBitmap()
|
|
||||||
val rotation = image.imageInfo.rotationDegrees.toFloat()
|
|
||||||
image.close()
|
|
||||||
val rotated = if (rotation != 0f) {
|
|
||||||
val m = Matrix().apply { postRotate(rotation) }
|
|
||||||
Bitmap.createBitmap(bmp, 0, 0, bmp.width, bmp.height, m, true)
|
|
||||||
} else bmp
|
|
||||||
originalBitmap = rotated
|
|
||||||
val det = currentDetection
|
|
||||||
if (det != null) {
|
|
||||||
val box = det.boundingBox
|
|
||||||
val padH = 0.10f; val padTop = 0.10f; val padBot = 0.05f
|
|
||||||
val cropX = ((box.left - box.width() * padH).coerceAtLeast(0f) * rotated.width).toInt()
|
|
||||||
val cropY = ((box.top - box.height() * padTop).coerceAtLeast(0f) * rotated.height).toInt()
|
|
||||||
val cropR = ((box.right + box.width() * padH).coerceAtMost(1f) * rotated.width).toInt()
|
|
||||||
val cropB = ((box.bottom + box.height() * padBot).coerceAtMost(1f) * rotated.height).toInt()
|
|
||||||
val cropW = (cropR - cropX).coerceAtMost(rotated.width - cropX)
|
|
||||||
val cropH = (cropB - cropY).coerceAtMost(rotated.height - cropY)
|
|
||||||
capturedBitmap = if (cropW > 50 && cropH > 50) Bitmap.createBitmap(rotated, cropX, cropY, cropW, cropH) else rotated
|
|
||||||
} else {
|
|
||||||
capturedBitmap = rotated
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 자동 촬영 로직.
|
|
||||||
// 2026-07-07 fix: currentDetection 은 mutableStateOf delegate — smart cast 안 되어 !! 로 unwrap
|
|
||||||
// 하는데, coroutine 안에서 첫 null-check 와 !! 사이에 다른 recomposition 이 detection 을
|
|
||||||
// null 로 만들 수 있음 → NullPointerException. Local val 로 snapshot 후 사용.
|
|
||||||
LaunchedEffect(currentDetection, isAutoCapturing, capturedBitmap) {
|
|
||||||
if (capturedBitmap != null || isAutoCapturing) return@LaunchedEffect
|
|
||||||
val initialDet = currentDetection
|
|
||||||
if (initialDet != null && initialDet.confidence >= 0.90f) {
|
|
||||||
if (detectionStartTime == null) detectionStartTime = System.currentTimeMillis()
|
|
||||||
while (true) {
|
|
||||||
val det = currentDetection ?: break
|
|
||||||
if (det.confidence < 0.90f || isAutoCapturing || capturedBitmap != null) break
|
|
||||||
val elapsed = System.currentTimeMillis() - (detectionStartTime ?: System.currentTimeMillis())
|
|
||||||
autoCaptureProgress = (elapsed.toFloat() / autoCaptureDelay).coerceIn(0f, 1f)
|
|
||||||
if (elapsed >= autoCaptureDelay) { doCapture(); break }
|
|
||||||
delay(50)
|
|
||||||
}
|
|
||||||
} else { detectionStartTime = null; autoCaptureProgress = 0f }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 자동 분석
|
|
||||||
LaunchedEffect(capturedBitmap) {
|
|
||||||
val bmp = capturedBitmap ?: return@LaunchedEffect
|
|
||||||
isAnalyzing = true
|
|
||||||
try {
|
|
||||||
val result = SimpleMeasureService.measure(bmp)
|
|
||||||
resultVolume = result.volume
|
|
||||||
} catch (_: Exception) { resultVolume = null }
|
|
||||||
isAnalyzing = false
|
|
||||||
analysisComplete = true
|
|
||||||
}
|
|
||||||
|
|
||||||
val borderColor by animateColorAsState(
|
|
||||||
if (currentDetection != null && currentDetection!!.confidence >= 0.90f) Color.Green else Color.Transparent,
|
|
||||||
tween(300), label = "border"
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!hasCameraPerm) {
|
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(stringResource(R.string.camera_permission_needed))
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Button(onClick = { permLauncher.launch(Manifest.permission.CAMERA) }) { Text(stringResource(R.string.grant_permission)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (capturedBitmap != null) {
|
|
||||||
// === 분석 결과 화면 ===
|
|
||||||
Column(
|
|
||||||
Modifier.fillMaxSize().background(Color.White).padding(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) {
|
|
||||||
IconButton(onClick = onCancel) { Icon(Icons.Default.ArrowBack, stringResource(R.string.back)) }
|
|
||||||
Text(stringResource(R.string.urine_measurement), fontWeight = FontWeight.SemiBold)
|
|
||||||
Spacer(Modifier.size(48.dp))
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Image(bitmap = capturedBitmap!!.asImageBitmap(), contentDescription = null, modifier = Modifier.fillMaxWidth().weight(1f))
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
if (isAnalyzing) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
CircularProgressIndicator(Modifier.size(24.dp))
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(stringResource(R.string.analyzing), color = Color.Gray)
|
|
||||||
}
|
|
||||||
} else if (analysisComplete) {
|
|
||||||
if (resultVolume != null && resultVolume!! > 0) {
|
|
||||||
Text(stringResource(R.string.measurement_result), color = Color.Gray, fontSize = 14.sp)
|
|
||||||
Row(verticalAlignment = Alignment.Bottom) {
|
|
||||||
Text("$resultVolume", fontSize = 48.sp, fontWeight = FontWeight.Bold, color = Color(0xFF007AFF))
|
|
||||||
Text(" ml", fontSize = 20.sp, color = Color.Gray, modifier = Modifier.padding(bottom = 8.dp))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text(stringResource(R.string.measurement_fail), fontSize = 20.sp, color = Color.Red)
|
|
||||||
Text(stringResource(R.string.retry_in_bright), color = Color.Gray, fontSize = 14.sp)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { capturedBitmap = null; originalBitmap = null; analysisComplete = false; resultVolume = null; isAutoCapturing = false; detectionStartTime = null; autoCaptureProgress = 0f },
|
|
||||||
Modifier.weight(1f).height(48.dp), shape = RoundedCornerShape(12.dp)
|
|
||||||
) { Text(stringResource(R.string.retake_photo)) }
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
val vol = resultVolume
|
|
||||||
if (vol != null && vol > 0) {
|
|
||||||
VoidingRecordStore.add(context, VoidingRecord(volume = vol, method = "camera"))
|
|
||||||
Toast.makeText(context, context.getString(R.string.registered_diary), Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
onDone(vol)
|
|
||||||
},
|
|
||||||
Modifier.weight(1f).height(48.dp), shape = RoundedCornerShape(12.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50))
|
|
||||||
) { Text(stringResource(R.string.save_to_diary), fontWeight = FontWeight.SemiBold) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// === 카메라 프리뷰 ===
|
|
||||||
Box(
|
|
||||||
Modifier.fillMaxSize().border(
|
|
||||||
width = if (borderColor == Color.Transparent) 0.dp else 4.dp, color = borderColor
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
val previewView = remember { PreviewView(context) }
|
|
||||||
val executor = remember { Executors.newSingleThreadExecutor() }
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
val future = ProcessCameraProvider.getInstance(context)
|
|
||||||
future.addListener({
|
|
||||||
val provider = future.get()
|
|
||||||
val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) }
|
|
||||||
val analysis = ImageAnalysis.Builder().setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST).build().also { a ->
|
|
||||||
a.setAnalyzer(executor) { proxy ->
|
|
||||||
if (isModelLoaded) {
|
|
||||||
val bmp = proxy.toBitmap()
|
|
||||||
val rot = proxy.imageInfo.rotationDegrees.toFloat()
|
|
||||||
val rotated = if (rot != 0f) { val m = Matrix().apply { postRotate(rot) }; Bitmap.createBitmap(bmp, 0, 0, bmp.width, bmp.height, m, true) } else bmp
|
|
||||||
currentDetection = yoloDetector.detect(rotated)
|
|
||||||
}
|
|
||||||
proxy.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try { provider.unbindAll(); provider.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageCapture, analysis) } catch (_: Exception) {}
|
|
||||||
}, ContextCompat.getMainExecutor(context))
|
|
||||||
}
|
|
||||||
|
|
||||||
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
|
|
||||||
|
|
||||||
// 상단
|
|
||||||
Row(Modifier.fillMaxWidth().statusBarsPadding().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
IconButton(onClick = onCancel) { Icon(Icons.Default.Close, stringResource(R.string.close), tint = Color.White) }
|
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
val statusText = when {
|
|
||||||
!isModelLoaded -> stringResource(R.string.model_loading)
|
|
||||||
isAutoCapturing -> stringResource(R.string.auto_capturing)
|
|
||||||
autoCaptureProgress > 0 -> stringResource(R.string.hold_still, (autoCaptureProgress * 100).toInt())
|
|
||||||
currentDetection != null -> stringResource(R.string.cup_detected)
|
|
||||||
else -> stringResource(R.string.searching_cup)
|
|
||||||
}
|
|
||||||
Text(statusText, color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
|
|
||||||
modifier = Modifier.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(20.dp)).padding(horizontal = 12.dp, vertical = 6.dp))
|
|
||||||
Spacer(Modifier.weight(1f)); Spacer(Modifier.size(48.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 가이드
|
|
||||||
Column(Modifier.align(Alignment.Center).offset(y = 120.dp).background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(10.dp)).padding(horizontal = 16.dp, vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(stringResource(R.string.camera_guide_1), color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
|
|
||||||
Text(stringResource(R.string.camera_guide_2), color = Color(0xFFFFD54F), fontSize = 12.sp)
|
|
||||||
if (autoCaptureProgress > 0) {
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
LinearProgressIndicator(progress = { autoCaptureProgress }, Modifier.fillMaxWidth(0.6f).height(4.dp).clip(RoundedCornerShape(2.dp)), color = Color.Green, trackColor = Color.White.copy(alpha = 0.3f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 수동 촬영 버튼
|
|
||||||
Box(Modifier.align(Alignment.BottomCenter).padding(bottom = 48.dp)) {
|
|
||||||
Button(onClick = { if (!isAutoCapturing) doCapture() }, Modifier.size(80.dp), shape = CircleShape,
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color.White), contentPadding = PaddingValues(0.dp)
|
|
||||||
) { Box(Modifier.size(64.dp).background(Color.White, CircleShape)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -41,28 +41,13 @@
|
|||||||
<string name="urine_volume_ml">소변량 %d ml</string>
|
<string name="urine_volume_ml">소변량 %d ml</string>
|
||||||
|
|
||||||
<!-- Urine Camera -->
|
<!-- Urine Camera -->
|
||||||
<string name="camera_permission_needed">카메라 권한이 필요합니다</string>
|
|
||||||
<string name="grant_permission">권한 허용</string>
|
|
||||||
<string name="urine_measurement">소변량 측정</string>
|
|
||||||
<string name="analyzing">분석 중…</string>
|
|
||||||
<string name="measurement_result">측정 결과</string>
|
|
||||||
<string name="measurement_fail">측정 실패</string>
|
|
||||||
<string name="retry_in_bright">밝은 곳에서 다시 촬영해주세요</string>
|
|
||||||
<string name="retake_photo">다시 촬영</string>
|
|
||||||
<string name="save_to_diary">저장하기</string>
|
<string name="save_to_diary">저장하기</string>
|
||||||
<string name="camera_guide_1">눈금이 보이는 면을 정면으로</string>
|
|
||||||
<string name="camera_guide_2">밝은 곳에서 촬영해주세요</string>
|
|
||||||
|
|
||||||
<!-- Catheterize Sheet -->
|
<!-- Catheterize Sheet -->
|
||||||
<string name="save_complete">저장 완료</string>
|
<string name="save_complete">저장 완료</string>
|
||||||
<string name="tap_to_speak">탭하여 음성 입력</string>
|
<string name="tap_to_speak">탭하여 음성 입력</string>
|
||||||
|
|
||||||
<!-- Camera Status -->
|
<!-- Camera Status -->
|
||||||
<string name="model_loading">모델 로딩 중…</string>
|
|
||||||
<string name="auto_capturing">자동 촬영 중…</string>
|
|
||||||
<string name="hold_still">유지하세요… %d%%</string>
|
|
||||||
<string name="cup_detected">소변컵 감지됨</string>
|
|
||||||
<string name="searching_cup">소변컵을 찾는 중…</string>
|
|
||||||
|
|
||||||
<!-- Measurement -->
|
<!-- Measurement -->
|
||||||
<string name="too_dark">이미지가 너무 어둡습니다</string>
|
<string name="too_dark">이미지가 너무 어둡습니다</string>
|
||||||
|
|||||||
@@ -41,28 +41,13 @@
|
|||||||
<string name="urine_volume_ml">Volume %d ml</string>
|
<string name="urine_volume_ml">Volume %d ml</string>
|
||||||
|
|
||||||
<!-- Urine Camera -->
|
<!-- Urine Camera -->
|
||||||
<string name="camera_permission_needed">Camera permission required</string>
|
|
||||||
<string name="grant_permission">Grant Permission</string>
|
|
||||||
<string name="urine_measurement">Urine Measurement</string>
|
|
||||||
<string name="analyzing">Analyzing…</string>
|
|
||||||
<string name="measurement_result">Measurement Result</string>
|
|
||||||
<string name="measurement_fail">Measurement Failed</string>
|
|
||||||
<string name="retry_in_bright">Try again in a brighter area</string>
|
|
||||||
<string name="retake_photo">Retake Photo</string>
|
|
||||||
<string name="save_to_diary">Save</string>
|
<string name="save_to_diary">Save</string>
|
||||||
<string name="camera_guide_1">Face the graduated side forward</string>
|
|
||||||
<string name="camera_guide_2">Take photo in a bright area</string>
|
|
||||||
|
|
||||||
<!-- Catheterize Sheet -->
|
<!-- Catheterize Sheet -->
|
||||||
<string name="save_complete">Saved</string>
|
<string name="save_complete">Saved</string>
|
||||||
<string name="tap_to_speak">Tap to speak</string>
|
<string name="tap_to_speak">Tap to speak</string>
|
||||||
|
|
||||||
<!-- Camera Status -->
|
<!-- Camera Status -->
|
||||||
<string name="model_loading">Loading model…</string>
|
|
||||||
<string name="auto_capturing">Auto capturing…</string>
|
|
||||||
<string name="hold_still">Hold still… %d%%</string>
|
|
||||||
<string name="cup_detected">Cup detected</string>
|
|
||||||
<string name="searching_cup">Searching for cup…</string>
|
|
||||||
|
|
||||||
<!-- Measurement -->
|
<!-- Measurement -->
|
||||||
<string name="too_dark">Image is too dark</string>
|
<string name="too_dark">Image is too dark</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user