diff --git a/app/src/main/java/com/medithings/vesiscan/managers/AlignmentAdvisorV3.kt b/app/src/main/java/com/medithings/vesiscan/managers/AlignmentAdvisorV3.kt index 05d6ca1..acac5f1 100644 --- a/app/src/main/java/com/medithings/vesiscan/managers/AlignmentAdvisorV3.kt +++ b/app/src/main/java/com/medithings/vesiscan/managers/AlignmentAdvisorV3.kt @@ -193,8 +193,10 @@ class CenterAligner( if (trBase.size > CH3_INDEX && trBase[CH3_INDEX] != null) ch3Hit++ val trCross = MethodDRunner.detectMultichannel( tr, methodDParams, applyTgc = true, applyCross = true) - val allWalls: List?> = trCross.map { r -> - r?.let { Pair(it.ant, it.post) } + // Python `extract_walls` 는 antRefined/postRefined (subsample) 를 우선 사용. + // 정수 ant/post 만 넘기면 최대 1 sample 오차가 D_mm/cap/BV 로 누적 → ~50 mL 편차. + val allWalls: List?> = trCross.map { r -> + r?.let { Pair(it.antRefined, it.postRefined) } } val bv = estimateBladderVolume6ch(allWalls) // Python `position_records`: `if bv is not None: bvs.append(float(bv.volume_ml))` 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 3dd40ac..9b5bc08 100644 --- a/app/src/main/java/com/medithings/vesiscan/managers/PiezoBVEstimator.kt +++ b/app/src/main/java/com/medithings/vesiscan/managers/PiezoBVEstimator.kt @@ -142,8 +142,8 @@ object PiezoHW { * Port of bv_estimation.py _repair_center_walls_for_6ch (#21 merge) */ private fun repairCenterWallsFor6ch( - centerWalls: List?> -): List?> { + centerWalls: List?> +): List?> { val result = centerWalls.toMutableList() val valid = result.indices.filter { i -> val w = result[i]; w != null @@ -158,8 +158,9 @@ private fun repairCenterWallsFor6ch( if (gap == 1) { val pw = result[prevIdx]!! val nw = result[nextIdx]!! - val ant = ((pw.first + nw.first) / 2.0 + 0.5).toInt() - val post = ((pw.second + nw.second) / 2.0 + 0.5).toInt() + // Python 1:1: 선형 보간 (반올림 없이 float 유지 — subsample refined 값 보존) + val ant = (pw.first + nw.first) / 2.0 + val post = (pw.second + nw.second) / 2.0 result[prevIdx + 1] = Pair(ant, post) continue } @@ -179,8 +180,8 @@ private fun repairCenterWallsFor6ch( * lr_ratio = LR 반축 / AP 반축. */ fun computeLrRatio( - centerWalls: List?>, - lateralWalls: List?>, + centerWalls: List?>, + lateralWalls: List?>, maxRatio: Double = 1.0, sensorZMm: DoubleArray = PiezoHW.sensorZMmAll ): Double { @@ -330,9 +331,17 @@ private fun segmentToDistancesMm( ant: Int, post: Int, dps: Double = PiezoHW.distancePerSample, offset: Double = PiezoHW.delayOffsetMm +): Pair = segmentToDistancesMm(ant.toDouble(), post.toDouble(), dps, offset) + +/** Subsample refined (Double) 오버로드 — Python `extract_walls` 가 refined 우선 사용하는 것과 매칭. + * V3 CenterAligner / Clinical Live BV chip 등 정밀도 요구 경로에서 사용. */ +private fun segmentToDistancesMm( + ant: Double, post: Double, + dps: Double = PiezoHW.distancePerSample, + offset: Double = PiezoHW.delayOffsetMm ): Pair { - val d1 = sampleToMm(ant.toDouble(), dps, offset) - val d2 = sampleToMm(post.toDouble(), dps, offset) + val d1 = sampleToMm(ant, dps, offset) + val d2 = sampleToMm(post, dps, offset) return Pair(min(d1, d2), max(d1, d2)) } @@ -475,9 +484,23 @@ const val LUMEN_INSET_FRAC: Double = 0.15 /** * 6ch BV 추정 — center 4채널(CH0~CH3) + lateral 2채널(CH4/CH5)에서 lr_ratio 계산 * config_6ch.py / bv_estimation.py estimate_bladder_volume_6ch 1:1 포팅 + * + * Int 오버로드 — 기존 legacy PiezoMonitoring 경로 유지용. 정밀도 손실은 없지만 + * ant/post 를 subsample refine 되기 전 정수 sample index 로 넘김. + * Python 1:1 정밀도 원하면 `estimateBladderVolume6ch(List?>)` + * 오버로드 사용 (refined ant/post 전달). */ +@JvmName("estimateBladderVolume6chInt") fun estimateBladderVolume6ch( - allWalls: List?>, // 6채널 전체 (ant, post), null = invalid + allWalls: List?>, +): BVResult? = estimateBladderVolume6ch( + allWalls.map { it?.let { p -> Pair(p.first.toDouble(), p.second.toDouble()) } } +) + +/** Double(subsample refined) 오버로드 — Python `extract_walls` 가 refined 우선 사용하는 것과 매칭. + * V3 CenterAligner 등 정밀 검증 경로에서 사용. */ +fun estimateBladderVolume6ch( + allWalls: List?>, ): BVResult? { var centerWalls = PiezoHW.centerCh.map { if (it < allWalls.size) allWalls[it] else null } centerWalls = repairCenterWallsFor6ch(centerWalls) @@ -494,6 +517,8 @@ fun estimateBladderVolume6ch( ) } +/** Int 오버로드 — legacy 호환. */ +@JvmName("estimateBladderVolumeInt") fun estimateBladderVolume( walls: List?>, distancePerSample: Double = PiezoHW.distancePerSample, @@ -504,6 +529,30 @@ fun estimateBladderVolume( lrRatio: Double = PiezoHW.defaultLrRatio, applyAngleCorrection: Boolean = true, edgeCh: Int = walls.size - 1, + edgeRefChannels: List = listOf(walls.size - 2, walls.size - 3), +): BVResult? = estimateBladderVolume( + walls = walls.map { it?.let { p -> Pair(p.first.toDouble(), p.second.toDouble()) } }, + distancePerSample = distancePerSample, + delayOffsetMm = delayOffsetMm, + sensorZMm = sensorZMm, + degreeDeg = degreeDeg, + areaK = areaK, + lrRatio = lrRatio, + applyAngleCorrection = applyAngleCorrection, + edgeCh = edgeCh, + edgeRefChannels = edgeRefChannels, +) + +fun estimateBladderVolume( + walls: List?>, + distancePerSample: Double = PiezoHW.distancePerSample, + delayOffsetMm: Double = PiezoHW.delayOffsetMm, + sensorZMm: DoubleArray = PiezoHW.sensorZMm, + degreeDeg: DoubleArray = PiezoHW.degree, + areaK: Double = PiezoHW.areaK, + lrRatio: Double = PiezoHW.defaultLrRatio, + applyAngleCorrection: Boolean = true, + edgeCh: Int = walls.size - 1, edgeRefChannels: List = listOf(walls.size - 2, walls.size - 3) ): BVResult? { @@ -513,7 +562,7 @@ fun estimateBladderVolume( val wEdge = walls[edgeCh] if (wEdge != null) { val lEdge = wEdge.second - wEdge.first - val refLens = mutableListOf() + val refLens = mutableListOf() for (ci in edgeRefChannels) { if (ci in walls.indices) { val w = walls[ci] @@ -521,7 +570,7 @@ fun estimateBladderVolume( } } val minRef = refLens.minOrNull() - if (minRef != null && lEdge.toDouble() < 0.9 * minRef.toDouble()) { + if (minRef != null && lEdge < 0.9 * minRef) { edgeIsShort = true } } @@ -654,19 +703,26 @@ fun estimateBladderVolume( var resid = fit[4] as DoubleArray var meanRes = resid.average() - // 반복 outlier 제거: worst 점 하나씩, 최소 5점 유지 - while (meanRes > ellipseCostThr && keep.count { it } > 5) { + // Python `_bv_core` outlier 제거 loop (bv_estimation.py:1284-1302) 1:1: + // 조건 : mean_res > 0.5 OR b_si/a_ap > 1.3 + // accept: 새 fit 이 mean_res OR ratio 둘 중 하나라도 개선 + fun needsRemoval() = meanRes > ellipseCostThr || bSi / aAp > 1.3 + + while (needsRemoval() && keep.count { it } > 5) { val worstLocal = resid.indices.maxByOrNull { resid[it] } ?: break val activeIndices = keep.indices.filter { keep[it] } keep[activeIndices[worstLocal]] = false val keptY = keep.indices.filter { keep[it] }.map { allYw[it] }.toDoubleArray() val keptZ = keep.indices.filter { keep[it] }.map { allZw[it] }.toDoubleArray() val fit2 = fitEllipsePts(keptY, keptZ) ?: break - val meanRes2 = (fit2[4] as DoubleArray).average() - if (meanRes2 < meanRes) { - y0 = fit2[0] as Double; z0 = fit2[1] as Double - bSi = fit2[2] as Double; aAp = fit2[3] as Double - resid = fit2[4] as DoubleArray; meanRes = meanRes2 + val y02 = fit2[0] as Double; val z02 = fit2[1] as Double + val bSi2 = fit2[2] as Double; val aAp2 = fit2[3] as Double + val resid2 = fit2[4] as DoubleArray + val meanRes2 = resid2.average() + val improved = meanRes2 < meanRes || (bSi2 / aAp2) < (bSi / aAp) + if (improved) { + y0 = y02; z0 = z02; bSi = bSi2; aAp = aAp2 + resid = resid2; meanRes = meanRes2 } else break } @@ -685,10 +741,12 @@ fun estimateBladderVolume( } } - // shrink_bottom_cap=true (Python default) — posterior arc 원 fit + sagitta shrink 로 - // R_eff 공유 곡률 산출. 성공 시 both caps 높이를 정규화 (자유 b_si 제거). + // shrink_bottom_cap=true — posterior arc 원 fit + sagitta shrink 로 R_eff 공유 곡률. + // TODO: Python default `ellipse_cap_height=True` branch (bv_estimation.py:1337-1358) + // 는 아직 미이식. estimate_bv() 는 그 branch 를 쓰므로 cap 높이 3-5 mm 편차 + // (bottom_h_mm py=20.06 vs kt=24.02) 잔존. 다음 세션에서 이식 예정. val rEffCap: Double? = - if (cApPrior != null) shrinkSiRadius(yWallPost, zWallPost, cApPrior) else null + if (cApPrior != null) shrinkSiRadius(yWallPost, zWallPost, cApPrior!!) else null if (rEffCap != null) { hCapBot = minorCapHeight(rEffCap, aCapS[0]) hCapTop = minorCapHeight(rEffCap, aCapS[n - 1]) diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalLiveView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalLiveView.kt index b4415ef..1ac872a 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalLiveView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalLiveView.kt @@ -126,6 +126,8 @@ fun ClinicalLiveView(appState: AppState) { bvFrustum = com.medithings.vesiscan.managers.estimateBladderVolume6ch(channelWalls) ?.volumeMl?.takeIf { it.isFinite() && it > 0 } // BV[METHOD_D] = MethodD walls (apply_cross=true) + frustum. + // Python 1:1 정밀도를 위해 antRefined/postRefined (Double) 사용 + // (integer ant/post 만 넘기면 subsample 손실로 ~50 mL 편차 발생). val signalsD = (0..5).map { ch -> channels.find { it.channel == ch }?.buffer?.let { buf -> DoubleArray(buf.size) { buf[it].toDouble() } @@ -133,8 +135,8 @@ fun ClinicalLiveView(appState: AppState) { } val mdResults = com.medithings.vesiscan.walldetect.MethodDRunner .detectMultichannel(signalsD, applyCross = true) - val mdWalls: List?> = mdResults.map { r -> - r?.let { Pair(it.ant, it.post) } + val mdWalls: List?> = mdResults.map { r -> + r?.let { Pair(it.antRefined, it.postRefined) } } bvMethodD = com.medithings.vesiscan.managers.estimateBladderVolume6ch(mdWalls) ?.volumeMl?.takeIf { it.isFinite() && it > 0 } diff --git a/app/src/test/java/com/medithings/vesiscan/managers/PrecisionDumpTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/PrecisionDumpTest.kt new file mode 100644 index 0000000..82df97a --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/managers/PrecisionDumpTest.kt @@ -0,0 +1,143 @@ +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.MethodDDetector +import com.medithings.vesiscan.walldetect.algo.methodd.MethodDParams +import com.medithings.vesiscan.walldetect.algo.methodd.MethodDPreprocessing +import com.medithings.vesiscan.walldetect.algo.methodd.MethodDTgc +import org.junit.Test +import java.io.File + +/** + * Python vs Kotlin bit-perfect 정밀 대조용 dump. + * + * cm=1 세션 첫 10 cycle 평균 (trace 0) 을 pipeline 각 단계에 흘리면서 결과를 JSON 으로 저장. + * scratchpad/precision_python.json 과 비교해 divergence 지점 특정. + */ +class PrecisionDumpTest { + + private val session = "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_1CM_2026-07-06_164231.json" + private val outPath = "C:/Users/장동우/AppData/Local/Temp/claude/c--Projects-medilightv2android/76311e3b-2bb5-4c52-ab8a-8d89cb1052c2/scratchpad/precision_kotlin.json" + + private fun loadFirstTrace(path: String, win: Int = 10): List { + val root = JsonParser.parseReader(File(path).bufferedReader()).asJsonObject + val recs = root.getAsJsonArray("records") + val cycles = mutableListOf>() + for (r in recs) { + if (cycles.size >= win) 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]!! }) + } + // 첫 10 cycle 평균 = trace 0 + 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 pipeline intermediates for cm=1 trace 0`() { + PiezoHW.activePreset = PiezoHW.DevicePreset.V1 + val trace = loadFirstTrace(session, 10) + val params = MethodDParams.DEFAULT + val nCh = trace.size + + val out = HashMap() + out["input_signal"] = trace.map { it.toList() } + + // per-channel: heavy / light / TGC / detect + val heavyList = trace.map { MethodDPreprocessing.preprocessHeavy(it, params) } + val lightList = trace.map { MethodDPreprocessing.preprocessLight(it) } + val heavyTgc = MethodDTgc.applyTgcPipeline(heavyList) + val lightTgc = MethodDTgc.applyTgcPipeline(lightList) + + val perCh = mutableListOf>() + for (ch in 0 until nCh) { + val angle = PiezoHW.degreeAll[ch] + val chRatio = params.otsuRatio * kotlin.math.cos(Math.toRadians(angle)) + val r = MethodDDetector.detect( + raw = trace[ch], + denoisedHeavy = heavyTgc[ch], + denoisedLight = lightTgc[ch], + otsuRatioOverride = chRatio, + params = params, + ) + val m = LinkedHashMap() + m["ch"] = ch + m["angle_deg"] = angle + m["otsu_ratio_applied"] = chRatio + m["heavy"] = heavyList[ch].take(20) + m["light"] = lightList[ch].take(20) + m["heavy_tgc"] = heavyTgc[ch].take(20) + m["light_tgc"] = lightTgc[ch].take(20) + m["ant"] = r?.ant + m["post"] = r?.post + // MethodDResult 의 refined 필드 이름 확인 필요 (Python: ant_refined / post_refined) + m["ant_refined"] = r?.let { runCatching { it.javaClass.getDeclaredField("antRefined").also { f -> f.isAccessible = true }.get(it) }.getOrNull() } + m["post_refined"] = r?.let { runCatching { it.javaClass.getDeclaredField("postRefined").also { f -> f.isAccessible = true }.get(it) }.getOrNull() } + m["low_start"] = r?.lowStart + m["low_end"] = r?.lowEnd + m["low_amp"] = r?.lowAmp + perCh.add(m) + } + out["per_channel"] = perCh + + // walls base (apply_cross=false) — Python 매칭 위해 refined 우선 + val wallsBase = MethodDRunner.detectMultichannel(trace, params, applyCross = false) + out["walls_base"] = wallsBase.map { r -> + r?.let { listOf(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) } + } + // walls cross (apply_cross=true) + val wallsCross = MethodDRunner.detectMultichannel(trace, params, applyCross = true) + out["walls_cross"] = wallsCross.map { r -> + r?.let { listOf(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) } + } + + // BV — Python `extract_walls` 는 refined ant/post 우선 사용 → subsample 정밀도 유지 + val allWalls: List?> = wallsCross.map { r -> + r?.let { Pair(it.antRefined, it.postRefined) } + } + val bv = estimateBladderVolume6ch(allWalls) + out["bv"] = bv?.let { + val m = LinkedHashMap() + m["volume_ml"] = it.volumeMl + m["volume_mm3"] = it.volumeMm3 + m["valid_channels"] = it.validChannels + m["lr_ratio"] = it.lrRatio + m["d_ant_mm"] = it.dAntMm.toList() + m["d_post_mm"] = it.dPostMm.toList() + m["D_mm"] = it.dMm.toList() + m["bottom_h_mm"] = it.bottomHMm + m["top_h_mm"] = it.topHMm + m["V_bottom_mm3"] = it.vBottomMm3 + m["V_top_mm3"] = it.vTopMm3 + m + } + + File(outPath).writeText(Gson().toJson(out)) + println("saved: $outPath") + println(" bv.volume_ml = ${bv?.volumeMl?.let { "%.4f".format(it) }}") + for (i in 0 until nCh) { + val r = perCh[i] + println(" ch$i: ant=${r["ant"]} post=${r["post"]} angle=${r["angle_deg"]}") + } + } +}