diff --git a/app/src/main/java/com/medithings/vesiscan/managers/PiezoBVEstimator.kt b/app/src/main/java/com/medithings/vesiscan/managers/PiezoBVEstimator.kt index 21a19b6..2c9424d 100644 --- a/app/src/main/java/com/medithings/vesiscan/managers/PiezoBVEstimator.kt +++ b/app/src/main/java/com/medithings/vesiscan/managers/PiezoBVEstimator.kt @@ -631,7 +631,8 @@ private fun evalOnce( } } -/** Int 오버로드 — legacy 호환. */ +/** Int 오버로드 — legacy 호환. 기본 postOutlierFilter=true 로 기존 PiezoMonitoring 경로 + * 동작 보존 (Python default 는 false 지만 legacy Kotlin 은 무조건 실행이었음). */ @JvmName("estimateBladderVolumeInt") fun estimateBladderVolume( walls: List?>, @@ -644,6 +645,7 @@ fun estimateBladderVolume( applyAngleCorrection: Boolean = true, edgeCh: Int = walls.size - 1, edgeRefChannels: List = listOf(walls.size - 2, walls.size - 3), + postOutlierFilter: Boolean = true, ): BVResult? = estimateBladderVolume( walls = walls.map { it?.let { p -> Pair(p.first.toDouble(), p.second.toDouble()) } }, distancePerSample = distancePerSample, @@ -655,6 +657,7 @@ fun estimateBladderVolume( applyAngleCorrection = applyAngleCorrection, edgeCh = edgeCh, edgeRefChannels = edgeRefChannels, + postOutlierFilter = postOutlierFilter, ) fun estimateBladderVolume( @@ -672,6 +675,10 @@ fun estimateBladderVolume( bSiFloorRatio: Double? = null, /** Python `b_si_floor_edge_min`. edge 채널이 이 비율 이상 넓어야 floor 발동. */ bSiFloorEdgeMin: Double = 0.5, + /** Python `post_outlier_filter` (bv_estimation.py:1138). **default false** — Python + * 기본 동작과 매칭. true 였을 때 CH0 이 median 25% 벗어나면 부당하게 dropped 됨 + * (data123 cm=3 trace 0 실증). */ + postOutlierFilter: Boolean = false, ): BVResult? { // 1) Edge channel filter — cap 방식 결정용 플래그 @@ -735,7 +742,9 @@ fun estimateBladderVolume( var n = validChannels.size // 2-1) Post median outlier 제거 (#21 merge) - if (n >= 3) { + // Python `_bv_core` (bv_estimation.py:1199) — `post_outlier_filter=False` default. + // 기본으론 실행 안 함. flag true 일 때만. + if (postOutlierFilter && n >= 3) { val posts = dPost.toDoubleArray() val med = posts.sorted()[posts.size / 2] val postTol = max(med * 0.25, 5.0 * distancePerSample) diff --git a/app/src/test/java/com/medithings/vesiscan/managers/CenterAlignerValidationTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/CenterAlignerValidationTest.kt index b6869c5..c095d33 100644 --- a/app/src/test/java/com/medithings/vesiscan/managers/CenterAlignerValidationTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/managers/CenterAlignerValidationTest.kt @@ -98,22 +98,19 @@ class CenterAlignerValidationTest { assert(recs[3]?.ch3Hit == 10) { "cm=3 ch3_hit expected 10, got ${recs[3]?.ch3Hit}" } // bv_cv 허용 오차 : - // Adaptive_large_bladder_relax + Halir-Flusser + ellipse_cap_height + lumen_inset - // 4-단계 이식 완료 → single-trace BV Python 과 완전 bit-perfect 일치 - // (data123 cm=1 trace 0 : py=421.028293482 == kt=421.028293482, Δ=0.000 mL). - // cm=0, cm=1 은 bv_cv 도 Python 완전 일치 (0.183, 0.260). - // cm=3 만 0.048 vs 0.130 — 특정 trace 의 adaptive_large_bladder_relax 조건 - // 판정에서 Kotlin/Python 이 미묘히 다른 branch 를 타는 것으로 추정 (multi-trace - // aggregation edge case). 최종 선택 (cm=3) 은 여전히 일치. - assert(recs[1]?.bvCv != null && kotlin.math.abs((recs[1]!!.bvCv!!) - 0.260) < 0.02) { - "cm=1 bv_cv expected 0.260±0.02, got ${recs[1]?.bvCv}" + // Python 완전 bit-perfect 일치 달성 (data123 cm=3 11-trace 모두 Δ=0.000 mL). + // post_outlier_filter fix (Kotlin 이 무조건 실행하던 것을 Python default false 로 + // 맞춤) 로 CH0 부당 drop 해소. + // 3 위치 (0/1/3 cm) bv_cv 모두 Python 과 소수점 3자리 이상 일치 → tolerance + // 0.001 로 강화. + assert(recs[0]?.bvCv != null && kotlin.math.abs((recs[0]!!.bvCv!!) - 0.183) < 0.001) { + "cm=0 bv_cv expected 0.183±0.001, got ${recs[0]?.bvCv}" } - assert(recs[0]?.bvCv != null && kotlin.math.abs((recs[0]!!.bvCv!!) - 0.183) < 0.02) { - "cm=0 bv_cv expected 0.183±0.02, got ${recs[0]?.bvCv}" + assert(recs[1]?.bvCv != null && kotlin.math.abs((recs[1]!!.bvCv!!) - 0.260) < 0.001) { + "cm=1 bv_cv expected 0.260±0.001, got ${recs[1]?.bvCv}" } - // cm=3 tolerance 완화 — adaptive relax multi-trace edge case (후속 조사) - assert(recs[3]?.bvCv != null && kotlin.math.abs((recs[3]!!.bvCv!!) - 0.130) < 0.10) { - "cm=3 bv_cv expected 0.130±0.10 (adaptive relax edge case), got ${recs[3]?.bvCv}" + assert(recs[3]?.bvCv != null && kotlin.math.abs((recs[3]!!.bvCv!!) - 0.130) < 0.001) { + "cm=3 bv_cv expected 0.130±0.001, got ${recs[3]?.bvCv}" } // 선택 위치: Rule A → cm=3 (80% 임계 통과한 유일 위치) diff --git a/app/src/test/java/com/medithings/vesiscan/managers/Cm3PerTraceDumpTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/Cm3PerTraceDumpTest.kt new file mode 100644 index 0000000..c03768b --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/managers/Cm3PerTraceDumpTest.kt @@ -0,0 +1,119 @@ +package com.medithings.vesiscan.managers + +import com.google.gson.Gson +import com.google.gson.JsonParser +import com.medithings.vesiscan.walldetect.MethodDRunner +import com.medithings.vesiscan.walldetect.algo.methodd.MethodDParams +import org.junit.Test +import java.io.File +import kotlin.math.sqrt + +/** + * cm=3 세션의 11 trace 각각 BV + 중간값을 dump 해서 Python (cm3_python.json) 과 대조. + * divergent trace 특정용. + */ +class Cm3PerTraceDumpTest { + + private val session = "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26040302_SUPINE_ALIGN_3CM_2026-07-06_164842.json" + private val outPath = "C:/Users/장동우/AppData/Local/Temp/claude/c--Projects-medilightv2android/76311e3b-2bb5-4c52-ab8a-8d89cb1052c2/scratchpad/cm3_kotlin.json" + + private fun loadCycles(path: String, maxN: Int = 20): List> { + val root = JsonParser.parseReader(File(path).bufferedReader()).asJsonObject + val recs = root.getAsJsonArray("records") + val cycles = mutableListOf>() + for (r in recs) { + if (cycles.size >= maxN) break + val obj = r.asJsonObject + val chs = obj.getAsJsonArray("channels") ?: continue + if (chs.size() != 6) continue + val byCh = HashMap() + for (ce in chs) { + val co = ce.asJsonObject + val ch = co.get("ch").asInt + val data = co.getAsJsonArray("data") ?: continue + if (data.size() != 100) continue + byCh[ch] = DoubleArray(100) { data[it].asDouble } + } + if (byCh.size != 6) continue + cycles.add((0..5).map { byCh[it]!! }) + } + return cycles + } + + private fun meanScan(cycles: List>): List { + val nCh = cycles[0].size + return List(nCh) { ch -> + val len = cycles[0][ch].size + DoubleArray(len) { i -> + var s = 0.0 + for (c in cycles) s += c[ch][i] + s / cycles.size + } + } + } + + @Test + fun `dump cm=3 per-trace BV for python comparison`() { + PiezoHW.activePreset = PiezoHW.DevicePreset.V1 + val cycles = loadCycles(session, 20) + val params = MethodDParams.DEFAULT + val win = 10 + val out = mutableListOf>() + + for (i in 0..cycles.size - win) { + val tr = meanScan(cycles.subList(i, i + win)) + val results = MethodDRunner.detectMultichannel(tr, params, applyCross = true) + + val walls = results.map { r -> + if (r == null) null + else WallWithSpan(r.antRefined, r.postRefined, r.lowStart, r.lowEnd) + } + val bv = estimateBv(walls) + + val rec = LinkedHashMap() + rec["trace_idx"] = i + rec["walls"] = walls.map { w -> + w?.let { listOf(it.ant, it.post, it.lowStart, it.lowEnd) } + } + if (bv == null) { + rec["bv"] = null + } else { + val bvMap = LinkedHashMap() + bvMap["volume_ml"] = bv.volumeMl + bvMap["bottom_h_mm"] = bv.bottomHMm + bvMap["top_h_mm"] = bv.topHMm + bvMap["cap_b_si_mm"] = bv.capBSiMm + bvMap["cap_c_ap_mm"] = bv.capCApMm + bvMap["cap_mean_residual"] = bv.capMeanResidual + bvMap["V_bottom_mm3"] = bv.vBottomMm3 + bvMap["V_top_mm3"] = bv.vTopMm3 + bvMap["V_core_mm3"] = bv.vCoreMm3 + bvMap["valid_channels"] = bv.validChannels + bvMap["d_mm"] = bv.dMm.toList() + rec["bv"] = bvMap + } + out.add(rec) + } + + File(outPath).writeText(Gson().toJson(out)) + println("saved: $outPath") + println() + println("trace | BV(mL) | bot_h | top_h | b_si | c_ap | nch") + for (r in out) { + @Suppress("UNCHECKED_CAST") + val bv = r["bv"] as? Map + if (bv == null) { + println(" ${r["trace_idx"]} ") + } else { + val vol = (bv["volume_ml"] as? Double) ?: 0.0 + val bh = (bv["bottom_h_mm"] as? Double) ?: 0.0 + val th = (bv["top_h_mm"] as? Double) ?: 0.0 + val bs = (bv["cap_b_si_mm"] as? Double) ?: 0.0 + val ca = (bv["cap_c_ap_mm"] as? Double) ?: 0.0 + val n = (bv["valid_channels"] as? List<*>)?.size ?: 0 + println(" %3d %10.3f %8.3f %8.3f %8.3f %8.3f %3d".format( + r["trace_idx"], vol, bh, th, bs, ca, n)) + } + } + } +}