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:
@@ -193,8 +193,10 @@ class CenterAligner(
|
|||||||
if (trBase.size > CH3_INDEX && trBase[CH3_INDEX] != null) ch3Hit++
|
if (trBase.size > CH3_INDEX && trBase[CH3_INDEX] != null) ch3Hit++
|
||||||
val trCross = MethodDRunner.detectMultichannel(
|
val trCross = MethodDRunner.detectMultichannel(
|
||||||
tr, methodDParams, applyTgc = true, applyCross = true)
|
tr, methodDParams, applyTgc = true, applyCross = true)
|
||||||
val allWalls: List<Pair<Int, Int>?> = trCross.map { r ->
|
// Python `extract_walls` 는 antRefined/postRefined (subsample) 를 우선 사용.
|
||||||
r?.let { Pair(it.ant, it.post) }
|
// 정수 ant/post 만 넘기면 최대 1 sample 오차가 D_mm/cap/BV 로 누적 → ~50 mL 편차.
|
||||||
|
val allWalls: List<Pair<Double, Double>?> = trCross.map { r ->
|
||||||
|
r?.let { Pair(it.antRefined, it.postRefined) }
|
||||||
}
|
}
|
||||||
val bv = estimateBladderVolume6ch(allWalls)
|
val bv = estimateBladderVolume6ch(allWalls)
|
||||||
// Python `position_records`: `if bv is not None: bvs.append(float(bv.volume_ml))`
|
// Python `position_records`: `if bv is not None: bvs.append(float(bv.volume_ml))`
|
||||||
|
|||||||
@@ -142,8 +142,8 @@ object PiezoHW {
|
|||||||
* Port of bv_estimation.py _repair_center_walls_for_6ch (#21 merge)
|
* Port of bv_estimation.py _repair_center_walls_for_6ch (#21 merge)
|
||||||
*/
|
*/
|
||||||
private fun repairCenterWallsFor6ch(
|
private fun repairCenterWallsFor6ch(
|
||||||
centerWalls: List<Pair<Int, Int>?>
|
centerWalls: List<Pair<Double, Double>?>
|
||||||
): List<Pair<Int, Int>?> {
|
): List<Pair<Double, Double>?> {
|
||||||
val result = centerWalls.toMutableList()
|
val result = centerWalls.toMutableList()
|
||||||
val valid = result.indices.filter { i ->
|
val valid = result.indices.filter { i ->
|
||||||
val w = result[i]; w != null
|
val w = result[i]; w != null
|
||||||
@@ -158,8 +158,9 @@ private fun repairCenterWallsFor6ch(
|
|||||||
if (gap == 1) {
|
if (gap == 1) {
|
||||||
val pw = result[prevIdx]!!
|
val pw = result[prevIdx]!!
|
||||||
val nw = result[nextIdx]!!
|
val nw = result[nextIdx]!!
|
||||||
val ant = ((pw.first + nw.first) / 2.0 + 0.5).toInt()
|
// Python 1:1: 선형 보간 (반올림 없이 float 유지 — subsample refined 값 보존)
|
||||||
val post = ((pw.second + nw.second) / 2.0 + 0.5).toInt()
|
val ant = (pw.first + nw.first) / 2.0
|
||||||
|
val post = (pw.second + nw.second) / 2.0
|
||||||
result[prevIdx + 1] = Pair(ant, post)
|
result[prevIdx + 1] = Pair(ant, post)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -179,8 +180,8 @@ private fun repairCenterWallsFor6ch(
|
|||||||
* lr_ratio = LR 반축 / AP 반축.
|
* lr_ratio = LR 반축 / AP 반축.
|
||||||
*/
|
*/
|
||||||
fun computeLrRatio(
|
fun computeLrRatio(
|
||||||
centerWalls: List<Pair<Int, Int>?>,
|
centerWalls: List<Pair<Double, Double>?>,
|
||||||
lateralWalls: List<Pair<Int, Int>?>,
|
lateralWalls: List<Pair<Double, Double>?>,
|
||||||
maxRatio: Double = 1.0,
|
maxRatio: Double = 1.0,
|
||||||
sensorZMm: DoubleArray = PiezoHW.sensorZMmAll
|
sensorZMm: DoubleArray = PiezoHW.sensorZMmAll
|
||||||
): Double {
|
): Double {
|
||||||
@@ -330,9 +331,17 @@ private fun segmentToDistancesMm(
|
|||||||
ant: Int, post: Int,
|
ant: Int, post: Int,
|
||||||
dps: Double = PiezoHW.distancePerSample,
|
dps: Double = PiezoHW.distancePerSample,
|
||||||
offset: Double = PiezoHW.delayOffsetMm
|
offset: Double = PiezoHW.delayOffsetMm
|
||||||
|
): Pair<Double, Double> = 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<Double, Double> {
|
): Pair<Double, Double> {
|
||||||
val d1 = sampleToMm(ant.toDouble(), dps, offset)
|
val d1 = sampleToMm(ant, dps, offset)
|
||||||
val d2 = sampleToMm(post.toDouble(), dps, offset)
|
val d2 = sampleToMm(post, dps, offset)
|
||||||
return Pair(min(d1, d2), max(d1, d2))
|
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 계산
|
* 6ch BV 추정 — center 4채널(CH0~CH3) + lateral 2채널(CH4/CH5)에서 lr_ratio 계산
|
||||||
* config_6ch.py / bv_estimation.py estimate_bladder_volume_6ch 1:1 포팅
|
* 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<Pair<Double,Double>?>)`
|
||||||
|
* 오버로드 사용 (refined ant/post 전달).
|
||||||
*/
|
*/
|
||||||
|
@JvmName("estimateBladderVolume6chInt")
|
||||||
fun estimateBladderVolume6ch(
|
fun estimateBladderVolume6ch(
|
||||||
allWalls: List<Pair<Int, Int>?>, // 6채널 전체 (ant, post), null = invalid
|
allWalls: List<Pair<Int, Int>?>,
|
||||||
|
): 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<Pair<Double, Double>?>,
|
||||||
): BVResult? {
|
): BVResult? {
|
||||||
var centerWalls = PiezoHW.centerCh.map { if (it < allWalls.size) allWalls[it] else null }
|
var centerWalls = PiezoHW.centerCh.map { if (it < allWalls.size) allWalls[it] else null }
|
||||||
centerWalls = repairCenterWallsFor6ch(centerWalls)
|
centerWalls = repairCenterWallsFor6ch(centerWalls)
|
||||||
@@ -494,6 +517,8 @@ fun estimateBladderVolume6ch(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Int 오버로드 — legacy 호환. */
|
||||||
|
@JvmName("estimateBladderVolumeInt")
|
||||||
fun estimateBladderVolume(
|
fun estimateBladderVolume(
|
||||||
walls: List<Pair<Int, Int>?>,
|
walls: List<Pair<Int, Int>?>,
|
||||||
distancePerSample: Double = PiezoHW.distancePerSample,
|
distancePerSample: Double = PiezoHW.distancePerSample,
|
||||||
@@ -504,6 +529,30 @@ fun estimateBladderVolume(
|
|||||||
lrRatio: Double = PiezoHW.defaultLrRatio,
|
lrRatio: Double = PiezoHW.defaultLrRatio,
|
||||||
applyAngleCorrection: Boolean = true,
|
applyAngleCorrection: Boolean = true,
|
||||||
edgeCh: Int = walls.size - 1,
|
edgeCh: Int = walls.size - 1,
|
||||||
|
edgeRefChannels: List<Int> = 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<Pair<Double, Double>?>,
|
||||||
|
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<Int> = listOf(walls.size - 2, walls.size - 3)
|
edgeRefChannels: List<Int> = listOf(walls.size - 2, walls.size - 3)
|
||||||
): BVResult? {
|
): BVResult? {
|
||||||
|
|
||||||
@@ -513,7 +562,7 @@ fun estimateBladderVolume(
|
|||||||
val wEdge = walls[edgeCh]
|
val wEdge = walls[edgeCh]
|
||||||
if (wEdge != null) {
|
if (wEdge != null) {
|
||||||
val lEdge = wEdge.second - wEdge.first
|
val lEdge = wEdge.second - wEdge.first
|
||||||
val refLens = mutableListOf<Int>()
|
val refLens = mutableListOf<Double>()
|
||||||
for (ci in edgeRefChannels) {
|
for (ci in edgeRefChannels) {
|
||||||
if (ci in walls.indices) {
|
if (ci in walls.indices) {
|
||||||
val w = walls[ci]
|
val w = walls[ci]
|
||||||
@@ -521,7 +570,7 @@ fun estimateBladderVolume(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val minRef = refLens.minOrNull()
|
val minRef = refLens.minOrNull()
|
||||||
if (minRef != null && lEdge.toDouble() < 0.9 * minRef.toDouble()) {
|
if (minRef != null && lEdge < 0.9 * minRef) {
|
||||||
edgeIsShort = true
|
edgeIsShort = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -654,19 +703,26 @@ fun estimateBladderVolume(
|
|||||||
var resid = fit[4] as DoubleArray
|
var resid = fit[4] as DoubleArray
|
||||||
var meanRes = resid.average()
|
var meanRes = resid.average()
|
||||||
|
|
||||||
// 반복 outlier 제거: worst 점 하나씩, 최소 5점 유지
|
// Python `_bv_core` outlier 제거 loop (bv_estimation.py:1284-1302) 1:1:
|
||||||
while (meanRes > ellipseCostThr && keep.count { it } > 5) {
|
// 조건 : 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 worstLocal = resid.indices.maxByOrNull { resid[it] } ?: break
|
||||||
val activeIndices = keep.indices.filter { keep[it] }
|
val activeIndices = keep.indices.filter { keep[it] }
|
||||||
keep[activeIndices[worstLocal]] = false
|
keep[activeIndices[worstLocal]] = false
|
||||||
val keptY = keep.indices.filter { keep[it] }.map { allYw[it] }.toDoubleArray()
|
val keptY = keep.indices.filter { keep[it] }.map { allYw[it] }.toDoubleArray()
|
||||||
val keptZ = keep.indices.filter { keep[it] }.map { allZw[it] }.toDoubleArray()
|
val keptZ = keep.indices.filter { keep[it] }.map { allZw[it] }.toDoubleArray()
|
||||||
val fit2 = fitEllipsePts(keptY, keptZ) ?: break
|
val fit2 = fitEllipsePts(keptY, keptZ) ?: break
|
||||||
val meanRes2 = (fit2[4] as DoubleArray).average()
|
val y02 = fit2[0] as Double; val z02 = fit2[1] as Double
|
||||||
if (meanRes2 < meanRes) {
|
val bSi2 = fit2[2] as Double; val aAp2 = fit2[3] as Double
|
||||||
y0 = fit2[0] as Double; z0 = fit2[1] as Double
|
val resid2 = fit2[4] as DoubleArray
|
||||||
bSi = fit2[2] as Double; aAp = fit2[3] as Double
|
val meanRes2 = resid2.average()
|
||||||
resid = fit2[4] as DoubleArray; meanRes = meanRes2
|
val improved = meanRes2 < meanRes || (bSi2 / aAp2) < (bSi / aAp)
|
||||||
|
if (improved) {
|
||||||
|
y0 = y02; z0 = z02; bSi = bSi2; aAp = aAp2
|
||||||
|
resid = resid2; meanRes = meanRes2
|
||||||
} else break
|
} else break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,10 +741,12 @@ fun estimateBladderVolume(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// shrink_bottom_cap=true (Python default) — posterior arc 원 fit + sagitta shrink 로
|
// shrink_bottom_cap=true — posterior arc 원 fit + sagitta shrink 로 R_eff 공유 곡률.
|
||||||
// R_eff 공유 곡률 산출. 성공 시 both caps 높이를 정규화 (자유 b_si 제거).
|
// 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? =
|
val rEffCap: Double? =
|
||||||
if (cApPrior != null) shrinkSiRadius(yWallPost, zWallPost, cApPrior) else null
|
if (cApPrior != null) shrinkSiRadius(yWallPost, zWallPost, cApPrior!!) else null
|
||||||
if (rEffCap != null) {
|
if (rEffCap != null) {
|
||||||
hCapBot = minorCapHeight(rEffCap, aCapS[0])
|
hCapBot = minorCapHeight(rEffCap, aCapS[0])
|
||||||
hCapTop = minorCapHeight(rEffCap, aCapS[n - 1])
|
hCapTop = minorCapHeight(rEffCap, aCapS[n - 1])
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ fun ClinicalLiveView(appState: AppState) {
|
|||||||
bvFrustum = com.medithings.vesiscan.managers.estimateBladderVolume6ch(channelWalls)
|
bvFrustum = com.medithings.vesiscan.managers.estimateBladderVolume6ch(channelWalls)
|
||||||
?.volumeMl?.takeIf { it.isFinite() && it > 0 }
|
?.volumeMl?.takeIf { it.isFinite() && it > 0 }
|
||||||
// BV[METHOD_D] = MethodD walls (apply_cross=true) + frustum.
|
// 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 ->
|
val signalsD = (0..5).map { ch ->
|
||||||
channels.find { it.channel == ch }?.buffer?.let { buf ->
|
channels.find { it.channel == ch }?.buffer?.let { buf ->
|
||||||
DoubleArray(buf.size) { buf[it].toDouble() }
|
DoubleArray(buf.size) { buf[it].toDouble() }
|
||||||
@@ -133,8 +135,8 @@ fun ClinicalLiveView(appState: AppState) {
|
|||||||
}
|
}
|
||||||
val mdResults = com.medithings.vesiscan.walldetect.MethodDRunner
|
val mdResults = com.medithings.vesiscan.walldetect.MethodDRunner
|
||||||
.detectMultichannel(signalsD, applyCross = true)
|
.detectMultichannel(signalsD, applyCross = true)
|
||||||
val mdWalls: List<Pair<Int, Int>?> = mdResults.map { r ->
|
val mdWalls: List<Pair<Double, Double>?> = mdResults.map { r ->
|
||||||
r?.let { Pair(it.ant, it.post) }
|
r?.let { Pair(it.antRefined, it.postRefined) }
|
||||||
}
|
}
|
||||||
bvMethodD = com.medithings.vesiscan.managers.estimateBladderVolume6ch(mdWalls)
|
bvMethodD = com.medithings.vesiscan.managers.estimateBladderVolume6ch(mdWalls)
|
||||||
?.volumeMl?.takeIf { it.isFinite() && it > 0 }
|
?.volumeMl?.takeIf { it.isFinite() && it > 0 }
|
||||||
|
|||||||
@@ -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