Integrate voiding diary into Piezo catheterization flow
Catheterize button now opens a voiding record sheet with 3 input modes: - Manual: number TextField (1-1000ml) - Voice: SpeechRecognizer + VolumeParser (Korean/English) - Camera: placeholder for YOLO cup measurement (TODO) On save: "배뇨일지에 등록되었습니다" toast, bladder level resets to 0 New modules (from Uridiary): - measure/: SimpleMeasureService, YoloDetector, CCPosition, VoidingRecord - speech/: SpeechRecognizerManager, VolumeParser, HfVolumeExtractor - assets/urinecup_best.onnx: YOLO model for cup detection VoidingRecordStore: local SharedPreferences+JSON storage for voiding records Dependencies added: ONNX Runtime, ML Kit OCR, CameraX, OkHttp Permissions added: CAMERA, RECORD_AUDIO, INTERNET Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
package com.example.medilightv2android.measure
|
||||
|
||||
data class CCPosition(
|
||||
val cc: Int,
|
||||
val x: Int,
|
||||
val y: Int,
|
||||
val predicted: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,287 @@
|
||||
package com.example.medilightv2android.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.example.medilightv2android.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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.example.medilightv2android.measure
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* 배뇨 기록 데이터
|
||||
*/
|
||||
data class VoidingRecord(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val volume: Int?, // ml (null이면 기상/취침)
|
||||
val activity: String = "none", // "none", "awake", "sleep"
|
||||
val method: String = "manual", // "manual", "voice", "camera"
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
) {
|
||||
val timeString: String
|
||||
get() = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
|
||||
|
||||
val dateString: String
|
||||
get() = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date(timestamp))
|
||||
|
||||
val displayText: String
|
||||
get() = when {
|
||||
volume != null -> "$timeString 소변량 $volume ml"
|
||||
activity == "awake" -> "$timeString 기상"
|
||||
activity == "sleep" -> "$timeString 취침"
|
||||
else -> "$timeString 기록"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로컬 배뇨 기록 저장소 (SharedPreferences + JSON)
|
||||
*/
|
||||
object VoidingRecordStore {
|
||||
private const val PREFS_NAME = "voiding_records"
|
||||
private const val KEY_RECORDS = "records"
|
||||
private val gson = Gson()
|
||||
|
||||
private fun prefs(context: Context): SharedPreferences =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun getAll(context: Context): List<VoidingRecord> {
|
||||
val json = prefs(context).getString(KEY_RECORDS, null) ?: return emptyList()
|
||||
val type = object : TypeToken<List<VoidingRecord>>() {}.type
|
||||
return try { gson.fromJson(json, type) } catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun add(context: Context, record: VoidingRecord) {
|
||||
val records = getAll(context).toMutableList()
|
||||
records.add(record)
|
||||
save(context, records)
|
||||
}
|
||||
|
||||
fun delete(context: Context, id: String) {
|
||||
val records = getAll(context).filter { it.id != id }
|
||||
save(context, records)
|
||||
}
|
||||
|
||||
fun clear(context: Context) {
|
||||
prefs(context).edit().remove(KEY_RECORDS).apply()
|
||||
}
|
||||
|
||||
private fun save(context: Context, records: List<VoidingRecord>) {
|
||||
prefs(context).edit().putString(KEY_RECORDS, gson.toJson(records)).apply()
|
||||
}
|
||||
|
||||
fun getToday(context: Context): List<VoidingRecord> {
|
||||
val todayStart = Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
return getAll(context).filter { it.timestamp >= todayStart }.sortedBy { it.timestamp }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.example.medilightv2android.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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user