fix(bv): subsample refined wall 이식 + cap outlier loop Python 1:1
Python `extract_walls` (piezophantomtest runners.py:143) 은 MethodDResult 의 antRefined / postRefined (float) 를 우선 사용. 기존 Kotlin 은 정수 ant / post 만 estimateBladderVolume6ch 에 넘겨 1 sample 정밀도 손실 → V3 CenterAligner per-trace BV 편차 → bv_cv 최대 2% 편차. 주요 변경: - estimateBladderVolume6ch / estimateBladderVolume: Pair<Double, Double>? 오버로드 추가. Int 오버로드는 legacy PiezoMonitoring 경로 호환용 (내부에서 Double 로 toDouble). @JvmName 으로 JVM 시그니처 충돌 회피. - segmentToDistancesMm(Double, Double) 오버로드 추가. - repairCenterWallsFor6ch / computeLrRatio 시그니처 Double 로 통일. 인접 채널 gap=1 선형 보간은 반올림 없이 float 유지 (Python 1:1). - AlignmentAdvisorV3.buildRecord: Pair(it.antRefined, it.postRefined) 사용 - ClinicalLiveView BV chip: METHOD_D 경로도 refined 사용 - cap ellipse outlier 제거 loop: Python `_bv_core` (bv_estimation.py:1284-1302) 에 있는 b_si/a_ap>1.3 조건 및 개선 검사 추가 — 이전엔 mean_res 만 봤음 - PrecisionDumpTest: Python vs Kotlin bit-perfect 대조 unit test 검증 (data123 VBT26050202 1CM 첫 trace): - walls_cross: py=(12.275,50.050,...) == kt=(12.275,50.050,...) 완전 일치 - BV: 50 mL → 43 mL (Python 421 vs Kotlin 464) - V3 CenterAligner cm=1 bv_cv: 0.265 → 0.260 (Python 0.260 완전 일치) - cm=3 bv_cv: 0.131 → 0.122 (Python 0.130, 소폭 변경) - 최종 선택: cm=3 (변함 없음) 남은 43 mL 편차: cap 높이 (bottom_h_mm py=20.06 vs kt=24.02, top_h_mm py=10.44 vs kt=13.59) — Python 의 `ellipse_cap_height=True` branch (bv_estimation.py:1337-1358) 아직 미이식. estimate_bv() default 는 이 branch 사용. sagitta 기반 b_si_eff 가중 + _ellipse_cap_h 공식 이식 필요. 후속 세션.
This commit is contained in:
@@ -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<DoubleArray> {
|
||||
val root = JsonParser.parseReader(File(path).bufferedReader()).asJsonObject
|
||||
val recs = root.getAsJsonArray("records")
|
||||
val cycles = mutableListOf<List<DoubleArray>>()
|
||||
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<Int, DoubleArray>()
|
||||
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<String, Any?>()
|
||||
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<Map<String, Any?>>()
|
||||
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<String, Any?>()
|
||||
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<Pair<Double, Double>?> = wallsCross.map { r ->
|
||||
r?.let { Pair(it.antRefined, it.postRefined) }
|
||||
}
|
||||
val bv = estimateBladderVolume6ch(allWalls)
|
||||
out["bv"] = bv?.let {
|
||||
val m = LinkedHashMap<String, Any?>()
|
||||
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"]}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user