feat: 6ch 알고리즘 포팅 + urineLen 기반 정밀 Placement Guide
6ch 알고리즘 (config_6ch.py / bv_estimation.py 포팅): - PiezoHW: V0/V1/V2 프리셋 (Snell's law 굴절 각도) - computeLrRatio(): CH4/CH5 lateral에서 타원 단면 비율 동적 계산 - estimateBladderVolume6ch(): center 4ch + lateral lr_ratio - denoise(): TVD 제거, SG 필터만 사용 - LOW_ECHO_AMP: 1600→1150, distancePerSample: 1.771 Placement Guide 2단계: - 1단계: 이진 판정 (잡힘/안잡힘) → 기본 방향 - 2단계: urineLen 기반 정밀 가이드 (대칭+균형+크기) - 세로: CH0/CH3 비율 1.5배 이상이면 방향 제시 - 가로: CH4/CH5 차이 5칸 이상이면 방향 제시 - Placement Score = 세로대칭(30%) + 가로균형(30%) + 중앙크기(40%) - 각 채널에 urineLen 숫자 표시 VesiScan_Android_Pipeline_Summary.md 문서 작성 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,7 +38,7 @@ object GreenZoneConstants {
|
||||
* UrinAI의 liquidThrLoose(raw 기준)와 다름 — 여기는 TVD+SG 적용 후 신호 기준.
|
||||
* Python 원본: 1600 (low_echo_detection_method_b.py LOW_ECHO_AMP)
|
||||
* 영향: PiezoEchoAnalyzer → low-echo span 탐지, 벽 찾기의 기반 */
|
||||
const val lowEchoAmp: Float = 1600f
|
||||
const val lowEchoAmp: Float = 1150f
|
||||
|
||||
/** 구조 이진화 임계값: raw < 이 값 → 액체(liquid), ≥ → 조직(tissue).
|
||||
* 낮출수록 엄격 (더 확실한 액체만 인정), 높일수록 관대.
|
||||
|
||||
@@ -20,106 +20,186 @@ import kotlin.math.sqrt
|
||||
* Probe geometry — probe model별 고정
|
||||
*/
|
||||
object PiezoHW {
|
||||
const val nCh = 5 // BV estimation에 사용할 수직 채널 수
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Device Presets — 기기별 센서 배치
|
||||
// Device Presets — 6ch 기기 3가지 버전 (Snell's law 굴절 보정 적용)
|
||||
// config_6ch.py 1:1 포팅
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
enum class DevicePreset {
|
||||
/** 기존 5ch 수직 배열 (placeholder 각도) */
|
||||
LEGACY_5CH,
|
||||
/** 전채널 0° (BLE 테스트용, 각도 보정 없음) */
|
||||
FLAT,
|
||||
/** 새 기기 Case A: 0° / 10° / 20° / 30° + 좌우 ±5° */
|
||||
NEW_CASE1,
|
||||
/** 새 기기 Case B: -10° / 0° / 10° / 20° + 좌우 ±5° */
|
||||
NEW_CASE2
|
||||
V0, // All 0° (BLE 테스트용)
|
||||
V1, // Max 20° housing → Snell's law refraction
|
||||
V2 // Max 30° housing → Snell's law refraction
|
||||
}
|
||||
|
||||
/** 현재 활성 preset — 기기 연결 시 자동 감지, 수동 변경도 가능 */
|
||||
var activePreset: DevicePreset = DevicePreset.FLAT
|
||||
var activePreset: DevicePreset = DevicePreset.V0
|
||||
|
||||
/**
|
||||
* BLE 기기 이름으로 preset 자동 감지.
|
||||
* VBT26040x0y: x=0 → FLAT, x=2 → NEW_CASE2, x=3 → NEW_CASE1
|
||||
*/
|
||||
fun autoDetectPreset(deviceName: String) {
|
||||
if (deviceName.startsWith("VBT2604") && deviceName.length >= 9) {
|
||||
when (deviceName[7]) {
|
||||
'0' -> activePreset = DevicePreset.FLAT
|
||||
'2' -> activePreset = DevicePreset.NEW_CASE2
|
||||
'3' -> activePreset = DevicePreset.NEW_CASE1
|
||||
'0' -> activePreset = DevicePreset.V0
|
||||
'2' -> activePreset = DevicePreset.V1
|
||||
'3' -> activePreset = DevicePreset.V2
|
||||
}
|
||||
} else if (deviceName.startsWith("2025MEDIP")) {
|
||||
activePreset = DevicePreset.LEGACY_5CH
|
||||
}
|
||||
}
|
||||
|
||||
// BV용 수직 채널 (CH0~CH3)
|
||||
val sensorZMm: DoubleArray
|
||||
val centerCh = intArrayOf(0, 1, 2, 3)
|
||||
val lateralCh = intArrayOf(4, 5)
|
||||
val lateralNeighbors = intArrayOf(1, 2)
|
||||
|
||||
// 6채널 전체 z 좌표 (CH0~CH5)
|
||||
val sensorZMmAll: DoubleArray
|
||||
get() = when (activePreset) {
|
||||
DevicePreset.LEGACY_5CH -> doubleArrayOf(22.0, 16.5, 11.0, 5.5, 0.0)
|
||||
DevicePreset.FLAT -> doubleArrayOf(22.0, 16.5, 11.0, 5.5, 0.0, 0.0)
|
||||
DevicePreset.NEW_CASE1 -> doubleArrayOf(22.0, 16.5, 11.0, 5.5)
|
||||
DevicePreset.NEW_CASE2 -> doubleArrayOf(22.0, 16.5, 11.0, 5.5)
|
||||
DevicePreset.LEGACY_5CH -> doubleArrayOf(22.0, 16.5, 11.0, 5.5, 0.0, 0.0)
|
||||
DevicePreset.V0 -> doubleArrayOf(21.0, 14.0, 7.0, 0.0, 10.5, 10.5)
|
||||
DevicePreset.V1 -> doubleArrayOf(20.1, 12.8, 7.0, 0.0, 9.9, 9.9)
|
||||
DevicePreset.V2 -> doubleArrayOf(19.3, 13.0, 6.7, 0.0, 9.85, 9.85)
|
||||
}
|
||||
|
||||
val sensorXMm: DoubleArray
|
||||
// 6채널 전체 x 좌표
|
||||
val sensorXMmAll: DoubleArray
|
||||
get() = when (activePreset) {
|
||||
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
DevicePreset.FLAT -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
DevicePreset.NEW_CASE1 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0)
|
||||
DevicePreset.NEW_CASE2 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0)
|
||||
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
else -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, -10.0, 10.0)
|
||||
}
|
||||
|
||||
/** SI beam angle (degrees) — BV 계산용 */
|
||||
val degree: DoubleArray
|
||||
// 6채널 전체 SI 빔 각도 (Snell's law 굴절 후)
|
||||
val degreeAll: DoubleArray
|
||||
get() = when (activePreset) {
|
||||
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, -2.2, -4.4, -6.6, -8.8)
|
||||
DevicePreset.FLAT -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
DevicePreset.NEW_CASE1 -> doubleArrayOf(0.0, 10.0, 20.0, 30.0)
|
||||
DevicePreset.NEW_CASE2 -> doubleArrayOf(-10.0, 0.0, 10.0, 20.0)
|
||||
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, -2.2, -4.4, -6.6, -8.8, 0.0)
|
||||
DevicePreset.V0 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
DevicePreset.V1 -> doubleArrayOf(6.89, 0.0, -6.89, -13.66, -6.87, -6.87)
|
||||
DevicePreset.V2 -> doubleArrayOf(0.0, -6.89, -13.66, -20.20, -6.87, -6.87)
|
||||
}
|
||||
|
||||
val degreeLR: DoubleArray
|
||||
// 6채널 전체 LR 빔 각도 (Snell's law 굴절 후)
|
||||
val degreeLRAll: DoubleArray
|
||||
get() = when (activePreset) {
|
||||
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
DevicePreset.FLAT -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
DevicePreset.NEW_CASE1 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0)
|
||||
DevicePreset.NEW_CASE2 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0)
|
||||
DevicePreset.LEGACY_5CH -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
DevicePreset.V0 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
DevicePreset.V1 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, -3.42, 3.42)
|
||||
DevicePreset.V2 -> doubleArrayOf(0.0, 0.0, 0.0, 0.0, -3.42, 3.42)
|
||||
}
|
||||
|
||||
/** 좌우 날개 채널 각도 (Placement Guide + lr_ratio 계산용)
|
||||
* CH4: 왼쪽, CH5: 오른쪽 */
|
||||
const val lateralDegreeSI: Double = 10.0 // SI 방향 (아래쪽을 봄)
|
||||
const val lateralDegreeLR: Double = 5.0 // LR 방향 (바깥쪽)
|
||||
// BV용 center 채널만 (CH0~CH3)
|
||||
val sensorZMm: DoubleArray get() = centerCh.map { sensorZMmAll[it] }.toDoubleArray()
|
||||
val sensorXMm: DoubleArray get() = centerCh.map { sensorXMmAll[it] }.toDoubleArray()
|
||||
val degree: DoubleArray get() = centerCh.map { degreeAll[it] }.toDoubleArray()
|
||||
val degreeLR: DoubleArray get() = centerCh.map { degreeLRAll[it] }.toDoubleArray()
|
||||
|
||||
/** Acoustic calibration */
|
||||
const val distancePerSample: Double = 1.974 // mm/sample (390 ksps, 1540 m/s)
|
||||
const val delayOffsetMm: Double = 6.85 // mm (first ADC sample 이전 고정 전파 지연)
|
||||
const val distancePerSample: Double = 1.771 // mm/sample
|
||||
const val delayOffsetMm: Double = 6.85
|
||||
|
||||
/** Volume model */
|
||||
val areaK: Double = Math.PI / 4.0 // S = areaK · D² · lr_ratio
|
||||
const val defaultLrRatio: Double = 1.0 // 원형 단면 가정
|
||||
val areaK: Double = Math.PI / 4.0
|
||||
const val defaultLrRatio: Double = 1.0
|
||||
|
||||
/** Phantom presets */
|
||||
fun lrRatio(phantom: String): Double {
|
||||
if (phantom.contains("bp1_70")) return 1.56
|
||||
if (phantom.contains("bp2_500")) return 1.0
|
||||
return defaultLrRatio
|
||||
}
|
||||
// lr_ratio fallbacks
|
||||
const val lrRatioNoDetection: Double = 1.0
|
||||
const val lrRatioInvalid: Double = 1.2
|
||||
const val lrPrior: Double = 1.2
|
||||
|
||||
/** 현재 preset 이름 (로그용) */
|
||||
val presetName: String
|
||||
get() = when (activePreset) {
|
||||
DevicePreset.LEGACY_5CH -> "Legacy 5ch"
|
||||
DevicePreset.FLAT -> "Flat (all 0°)"
|
||||
DevicePreset.NEW_CASE1 -> "Case1 (0/10/20/30)"
|
||||
DevicePreset.NEW_CASE2 -> "Case2 (-10/0/10/20)"
|
||||
DevicePreset.V0 -> "V0 (all 0°)"
|
||||
DevicePreset.V1 -> "V1 (max 20°, Snell)"
|
||||
DevicePreset.V2 -> "V2 (max 30°, Snell)"
|
||||
}
|
||||
}
|
||||
|
||||
// ── LR Ratio Computation (from lateral CH4/CH5) ──
|
||||
|
||||
fun computeLrRatio(
|
||||
centerWalls: List<Pair<Int, Int>?>,
|
||||
lateralWalls: List<Pair<Int, Int>?>,
|
||||
maxRatio: Double = 3.0
|
||||
): Double {
|
||||
val hw = PiezoHW
|
||||
val neighbors = hw.lateralNeighbors
|
||||
|
||||
// Center AP chord from neighbors
|
||||
val centerDs = mutableListOf<Double>()
|
||||
for (ni in neighbors) {
|
||||
if (ni < centerWalls.size) {
|
||||
val w = centerWalls[ni] ?: continue
|
||||
val (dNear, dFar) = segmentToDistancesMm(w.first, w.second)
|
||||
val theta = hw.degreeAll[hw.centerCh[ni]] * Math.PI / 180.0
|
||||
centerDs.add((dFar - dNear) * cos(theta))
|
||||
}
|
||||
}
|
||||
if (centerDs.isEmpty()) return hw.lrRatioNoDetection
|
||||
val dCenter = centerDs.average()
|
||||
if (dCenter <= 0) return hw.lrRatioInvalid
|
||||
|
||||
// Lateral chords
|
||||
data class LatResult(val ratio: Double, val yMid: Double)
|
||||
val latResults = mutableListOf<LatResult>()
|
||||
|
||||
for ((li, lCh) in hw.lateralCh.withIndex()) {
|
||||
if (li >= lateralWalls.size) continue
|
||||
val w = lateralWalls[li] ?: continue
|
||||
val (dNear, dFar) = segmentToDistancesMm(w.first, w.second)
|
||||
val alpha = hw.degreeAll[lCh] * Math.PI / 180.0
|
||||
val beta = hw.degreeLRAll[lCh] * Math.PI / 180.0
|
||||
val sx = hw.sensorXMmAll[lCh]
|
||||
|
||||
val pFactor = cos(alpha) * cos(beta)
|
||||
val qFactor = cos(alpha) * sin(beta)
|
||||
|
||||
val dLateral = (dFar - dNear) * pFactor
|
||||
val dMid = (dNear + dFar) / 2.0
|
||||
val yMid = sx + dMid * qFactor
|
||||
|
||||
val ratio = dLateral / dCenter
|
||||
if (ratio <= 0 || ratio >= maxRatio) continue
|
||||
if (abs(yMid) < 1e-3) continue
|
||||
latResults.add(LatResult(ratio, yMid))
|
||||
}
|
||||
|
||||
if (latResults.isEmpty()) return hw.lrRatioNoDetection
|
||||
|
||||
val lrRaw: Double
|
||||
if (latResults.size >= 2) {
|
||||
// Two laterals: solve ellipse
|
||||
val yL = latResults[0].yMid
|
||||
val rL = latResults[0].ratio
|
||||
val yR = latResults[1].yMid
|
||||
val rR = latResults[1].ratio
|
||||
|
||||
val denom = yR * (rL * rL - 1.0) - yL * (rR * rR - 1.0)
|
||||
if (abs(denom) < 1e-12) return hw.lrRatioInvalid
|
||||
|
||||
val u = yL * yR * (yR - yL) / denom
|
||||
if (u <= 0) return hw.lrRatioInvalid
|
||||
|
||||
val d = -(u * (rL * rL - 1.0) + yL * yL) / (2.0 * yL)
|
||||
val bSq = u + d * d
|
||||
if (bSq <= 0) return hw.lrRatioInvalid
|
||||
|
||||
lrRaw = 2.0 * sqrt(u) / dCenter
|
||||
} else {
|
||||
// Single lateral: assume center
|
||||
val r = latResults[0].ratio
|
||||
val yMid = latResults[0].yMid
|
||||
if (r >= 1.0) return hw.lrRatioInvalid
|
||||
val bEst = abs(yMid) / sqrt(1.0 - r * r)
|
||||
val aEst = dCenter / 2.0
|
||||
lrRaw = bEst / aEst
|
||||
}
|
||||
|
||||
if (lrRaw <= 0 || lrRaw >= maxRatio) return hw.lrRatioInvalid
|
||||
|
||||
// Shrinkage toward prior
|
||||
val avgRatio = latResults.map { it.ratio }.average()
|
||||
val confidence = ((1.0 - avgRatio) / 0.10).coerceIn(0.0, 1.0)
|
||||
return hw.lrPrior + (lrRaw - hw.lrPrior) * confidence
|
||||
}
|
||||
|
||||
// ── Result ──
|
||||
|
||||
data class BVResult(
|
||||
@@ -315,8 +395,26 @@ private fun capVolume(
|
||||
* Traditional frustum BV 추정 (cos 보정 + SI 축 기반)
|
||||
* 1:1 port of _bv_core(mode="traditional")
|
||||
*/
|
||||
/**
|
||||
* 6ch BV 추정 — center 4채널(CH0~CH3) + lateral 2채널(CH4/CH5)에서 lr_ratio 계산
|
||||
* config_6ch.py / bv_estimation.py estimate_bladder_volume_6ch 1:1 포팅
|
||||
*/
|
||||
fun estimateBladderVolume6ch(
|
||||
allWalls: List<Pair<Int, Int>?>, // 6채널 전체 (ant, post), null = invalid
|
||||
): BVResult? {
|
||||
val centerWalls = PiezoHW.centerCh.map { if (it < allWalls.size) allWalls[it] else null }
|
||||
val lateralWalls = PiezoHW.lateralCh.map { if (it < allWalls.size) allWalls[it] else null }
|
||||
val lrRatio = computeLrRatio(centerWalls, lateralWalls)
|
||||
return estimateBladderVolume(
|
||||
walls = centerWalls,
|
||||
lrRatio = lrRatio,
|
||||
edgeCh = 3,
|
||||
edgeRefChannels = listOf(1, 2)
|
||||
)
|
||||
}
|
||||
|
||||
fun estimateBladderVolume(
|
||||
walls: List<Pair<Int, Int>?>, // 5채널 (ant, post), null = invalid
|
||||
walls: List<Pair<Int, Int>?>,
|
||||
distancePerSample: Double = PiezoHW.distancePerSample,
|
||||
delayOffsetMm: Double = PiezoHW.delayOffsetMm,
|
||||
sensorZMm: DoubleArray = PiezoHW.sensorZMm,
|
||||
@@ -324,28 +422,26 @@ fun estimateBladderVolume(
|
||||
areaK: Double = PiezoHW.areaK,
|
||||
lrRatio: Double = PiezoHW.defaultLrRatio,
|
||||
applyAngleCorrection: Boolean = true,
|
||||
ch4MinRatio: Double? = 0.9,
|
||||
ch4RefChannels: List<Int> = listOf(2, 3)
|
||||
edgeCh: Int = walls.size - 1,
|
||||
edgeRefChannels: List<Int> = listOf(walls.size - 2, walls.size - 3)
|
||||
): BVResult? {
|
||||
|
||||
// 1) CH4 filter — cap 방식 결정용 플래그
|
||||
var ch4IsShort = false
|
||||
if (ch4MinRatio != null && walls.size >= 5) {
|
||||
val w4 = walls[4]
|
||||
if (w4 != null) {
|
||||
val l4 = w4.second - w4.first
|
||||
// 1) Edge channel filter — cap 방식 결정용 플래그
|
||||
var edgeIsShort = false
|
||||
if (edgeCh < walls.size) {
|
||||
val wEdge = walls[edgeCh]
|
||||
if (wEdge != null) {
|
||||
val lEdge = wEdge.second - wEdge.first
|
||||
val refLens = mutableListOf<Int>()
|
||||
for (ci in ch4RefChannels) {
|
||||
if (ci < walls.size) {
|
||||
for (ci in edgeRefChannels) {
|
||||
if (ci in walls.indices) {
|
||||
val w = walls[ci]
|
||||
if (w != null) {
|
||||
refLens.add(w.second - w.first)
|
||||
}
|
||||
if (w != null) refLens.add(w.second - w.first)
|
||||
}
|
||||
}
|
||||
val minRef = refLens.minOrNull()
|
||||
if (minRef != null && l4.toDouble() < ch4MinRatio * minRef.toDouble()) {
|
||||
ch4IsShort = true
|
||||
if (minRef != null && lEdge.toDouble() < 0.9 * minRef.toDouble()) {
|
||||
edgeIsShort = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -412,7 +508,7 @@ fun estimateBladderVolume(
|
||||
capMode = capMode, shape = "sphere")
|
||||
|
||||
// Top: cone (정상) / sphere (CH4 short)
|
||||
val topCap = if (ch4IsShort) {
|
||||
val topCap = if (edgeIsShort) {
|
||||
capVolume(
|
||||
sEdge = sS[n - 1], aCapEdge = aCapS[n - 1], bEff = bEff, sMax = sMax,
|
||||
capMode = "fallback", shape = "sphere")
|
||||
|
||||
@@ -211,10 +211,9 @@ class PiezoEchoAnalyzer private constructor() {
|
||||
|
||||
// ── Denoising Pipeline ──
|
||||
|
||||
/** TVD → SG 디노이징 */
|
||||
/** SG 디노이징 (6ch 알고리즘: TVD 제거, SG만 사용) */
|
||||
fun denoise(signal: DoubleArray): DoubleArray {
|
||||
val tvd = tvDenoiseChambolle1D(signal)
|
||||
return sgSmooth(tvd)
|
||||
return sgSmooth(signal)
|
||||
}
|
||||
|
||||
/** 1D Total Variation Denoising — Chambolle (2004) dual projected gradient */
|
||||
|
||||
Reference in New Issue
Block a user