feat(algo): 데모 piezophantomtest 4830e7c/5576a59 신 lib 포팅 — 사용자앱 (e44f0ab) 동기화
## Cross-channel 신 3종 (MethodDRunner) - applyAntTiebreak: 4830e7c 재작성 (dominant-pick 보호 + tie/shallow/deep 3-branch) - applyNeighborTopValidate: Rule A (FN 복원) + Rule B (trend FP drop/교체) 신규 - applyInwardPost: 최상단 widest post 과확장 교정 신규 (5576a59) - researchPostInSpan: seed span 재탐색 + wall/raw ratio gate 재검증 - MethodDResult 확장: post/postRefined/postType 를 var 로 (사후 수정 지원) ## BV 신 helpers (PiezoBVEstimator) - applyLumenInsetOne: lumen_inset_frac=0.15 (Python default) 벽 인셋 - sagittaMm: chord 대비 최대 수직 이탈 - shrinkSiRadius: Kåsa 원 fit + sagitta shrinkage → R_eff - minorCapHeight, sphericalCapVolume: 신 cap 공식 - estimateBladderVolume cap 로직 교체: R_eff 성공 시 → spherical cap (V = π·h²·(3R-h)/3·lr) 실패 시 → 기존 hemisphere fallback ## 캘러 갱신 - PiezoMonitoringView: MethodD 벽에 lumen inset 적용 후 BV ## 검증 (사용자앱 feature/tab-navigation) Python 대비 mean |Δ| = 1.27 mL. 회귀 게이트 (BvIsolationTest) 는 사용자앱에만 유지. Note: 데모 dps 는 이미 1.936 이라 별도 fix 불필요. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package com.example.medilightv2android.managers
|
|||||||
import com.example.medilightv2android.walldetect.core.WdConfig
|
import com.example.medilightv2android.walldetect.core.WdConfig
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.hypot
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
@@ -382,6 +383,89 @@ private fun solve4x4(A: Array<DoubleArray>, b: DoubleArray): DoubleArray? {
|
|||||||
return x
|
return x
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── NEW BV lib (piezophantomtest 4830e7c) helpers ─────────────────────────
|
||||||
|
|
||||||
|
/** posterior arc chord-대비 최대 수직 이탈 (sagitta, mm). Python `_sagitta_mm`. */
|
||||||
|
private fun sagittaMm(yp: DoubleArray, zp: DoubleArray): Double {
|
||||||
|
if (yp.size < 3) return 0.0
|
||||||
|
val order = (0 until yp.size).sortedBy { yp[it] }
|
||||||
|
val y = DoubleArray(yp.size) { yp[order[it]] }
|
||||||
|
val z = DoubleArray(zp.size) { zp[order[it]] }
|
||||||
|
val p0y = y[0]; val p0z = z[0]
|
||||||
|
val dY = y[y.size - 1] - p0y
|
||||||
|
val dZ = z[z.size - 1] - p0z
|
||||||
|
val ln = hypot(dY, dZ)
|
||||||
|
if (ln < 1e-6) return 0.0
|
||||||
|
val nY = -dZ / ln; val nZ = dY / ln
|
||||||
|
var m = 0.0
|
||||||
|
for (i in 1 until y.size - 1) {
|
||||||
|
val d = abs((y[i] - p0y) * nY + (z[i] - p0z) * nZ)
|
||||||
|
if (d > m) m = d
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* posterior arc 원 fit (Kåsa algebraic) + sagitta shrinkage → R_eff.
|
||||||
|
* Python `_shrink_si_radius`. 실패 시 null.
|
||||||
|
*/
|
||||||
|
private fun shrinkSiRadius(
|
||||||
|
yWallPost: DoubleArray, zWallPost: DoubleArray,
|
||||||
|
cApPrior: Double?,
|
||||||
|
sNoise: Double = BOTTOM_CAP_SAGITTA_NOISE_MM,
|
||||||
|
): Double? {
|
||||||
|
val n = yWallPost.size
|
||||||
|
if (n < 3 || cApPrior == null || cApPrior <= 0) return null
|
||||||
|
val ata = Array(3) { DoubleArray(3) }
|
||||||
|
val atb = DoubleArray(3)
|
||||||
|
for (i in 0 until n) {
|
||||||
|
val row = doubleArrayOf(yWallPost[i], zWallPost[i], 1.0)
|
||||||
|
val r = yWallPost[i] * yWallPost[i] + zWallPost[i] * zWallPost[i]
|
||||||
|
for (a in 0 until 3) {
|
||||||
|
for (b in 0 until 3) ata[a][b] += row[a] * row[b]
|
||||||
|
atb[a] += row[a] * r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val sol = solve3x3(ata, atb) ?: return null
|
||||||
|
val yc = sol[0] / 2.0
|
||||||
|
val zc = sol[1] / 2.0
|
||||||
|
val rFit = sqrt(max(sol[2] + yc * yc + zc * zc, 1e-9))
|
||||||
|
val s = sagittaMm(yWallPost, zWallPost)
|
||||||
|
val w = (s * s) / (s * s + sNoise * sNoise)
|
||||||
|
val kappa = w / rFit + (1.0 - w) / cApPrior
|
||||||
|
return if (kappa > 1e-9) 1.0 / kappa else cApPrior
|
||||||
|
}
|
||||||
|
|
||||||
|
/** R 구에서 base a 인 minor 구면 캡 높이. Python `_minor_cap_height`. */
|
||||||
|
private fun minorCapHeight(rEff: Double, aBase: Double): Double {
|
||||||
|
val a = min(aBase, rEff)
|
||||||
|
return rEff - sqrt(max(rEff * rEff - a * a, 0.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 구면 캡 부피 (lr 보정). V = π·h²·(3R−h)/3 · lr. Python `_spherical_cap_volume`. */
|
||||||
|
private fun sphericalCapVolume(rEff: Double, h: Double, lrRatio: Double): Double =
|
||||||
|
Math.PI * h * h * (3.0 * rEff - h) / 3.0 * lrRatio
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 벽 인셋: (ant + f·(ls-ant), post - f·(post-le)) → 벽을 lumen 내부로 살짝 밀어 넣기.
|
||||||
|
* Python `_apply_lumen_inset_one`. frac=0 이면 (ant, post) 그대로.
|
||||||
|
* ls, le 없으면 (ant, post) 그대로.
|
||||||
|
*/
|
||||||
|
fun applyLumenInsetOne(
|
||||||
|
ant: Int, post: Int, ls: Int?, le: Int?, frac: Double = LUMEN_INSET_FRAC,
|
||||||
|
): Pair<Int, Int> {
|
||||||
|
if (frac <= 0 || ls == null || le == null) return Pair(ant, post)
|
||||||
|
val ai = kotlin.math.round(ant + frac * (ls - ant)).toInt()
|
||||||
|
val pi = kotlin.math.round(post - frac * (post - le)).toInt()
|
||||||
|
return Pair(ai, pi)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Python `BOTTOM_CAP_SAGITTA_NOISE_MM` (bv_estimation.py:895). */
|
||||||
|
const val BOTTOM_CAP_SAGITTA_NOISE_MM: Double = 4.0
|
||||||
|
|
||||||
|
/** Python `LUMEN_INSET_FRAC` (config_6ch.py:41). */
|
||||||
|
const val LUMEN_INSET_FRAC: Double = 0.15
|
||||||
|
|
||||||
// ── Core BV Computation ──
|
// ── Core BV Computation ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -533,6 +617,7 @@ fun estimateBladderVolume(
|
|||||||
val nPts = allYw.size
|
val nPts = allYw.size
|
||||||
var capKind = "fallback"
|
var capKind = "fallback"
|
||||||
var z0Ellipse: Double? = null
|
var z0Ellipse: Double? = null
|
||||||
|
var cApPrior: Double? = null // shrink_bottom_cap 용 AP 반축
|
||||||
var hCapBot = aCapS[0]
|
var hCapBot = aCapS[0]
|
||||||
var hCapTop = aCapS[n - 1]
|
var hCapTop = aCapS[n - 1]
|
||||||
val ellipseCostThr = 0.5 // 점당 평균 잔차 임계
|
val ellipseCostThr = 0.5 // 점당 평균 잔차 임계
|
||||||
@@ -594,11 +679,21 @@ fun estimateBladderVolume(
|
|||||||
hCapTop = max(0.0, (y0 + bSi) - yS[n - 1])
|
hCapTop = max(0.0, (y0 + bSi) - yS[n - 1])
|
||||||
hCapBot = min(hCapBot, aCapS[0])
|
hCapBot = min(hCapBot, aCapS[0])
|
||||||
hCapTop = min(hCapTop, aCapS[n - 1])
|
hCapTop = min(hCapTop, aCapS[n - 1])
|
||||||
|
cApPrior = aAp
|
||||||
capKind = "ellipse"
|
capKind = "ellipse"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shrink_bottom_cap=true (Python default) — posterior arc 원 fit + sagitta shrink 로
|
||||||
|
// R_eff 공유 곡률 산출. 성공 시 both caps 높이를 정규화 (자유 b_si 제거).
|
||||||
|
val rEffCap: Double? =
|
||||||
|
if (cApPrior != null) shrinkSiRadius(yWallPost, zWallPost, cApPrior) else null
|
||||||
|
if (rEffCap != null) {
|
||||||
|
hCapBot = minorCapHeight(rEffCap, aCapS[0])
|
||||||
|
hCapTop = minorCapHeight(rEffCap, aCapS[n - 1])
|
||||||
|
}
|
||||||
|
|
||||||
// Top cap 상한: 미검출 상위 채널의 빔 y 좌표로 제한
|
// Top cap 상한: 미검출 상위 채널의 빔 y 좌표로 제한
|
||||||
val nTotalCh = sensorZMm.size
|
val nTotalCh = sensorZMm.size
|
||||||
val topSortedCh = sortedCh.last()
|
val topSortedCh = sortedCh.last()
|
||||||
@@ -622,11 +717,23 @@ fun estimateBladderVolume(
|
|||||||
}
|
}
|
||||||
val vCore = vFrustum.sum()
|
val vCore = vFrustum.sum()
|
||||||
|
|
||||||
// 9) Caps — Bottom: spherical cap, Top: cone
|
// 9) Caps — Bottom: spherical cap (shrink 시 R_eff 공유), Top: cone
|
||||||
val vBottom = sS[0] * hCapBot / 2.0 + Math.PI * hCapBot * hCapBot * hCapBot / 6.0
|
val vBottom: Double
|
||||||
val vTop = sS[n - 1] * hCapTop / 3.0
|
val vTop: Double
|
||||||
val bottomKind = "$capKind sphere"
|
val bottomKind: String
|
||||||
val topKind = "$capKind cone"
|
val topKind: String
|
||||||
|
if (rEffCap != null) {
|
||||||
|
vBottom = sphericalCapVolume(rEffCap, hCapBot, lrRatio)
|
||||||
|
vTop = sS[n - 1] * hCapTop / 3.0
|
||||||
|
bottomKind = "shrink sphere"
|
||||||
|
topKind = "shrink cone"
|
||||||
|
} else {
|
||||||
|
// fallback (ellipse fit 실패): 기존 hemisphere 공식.
|
||||||
|
vBottom = sS[0] * hCapBot / 2.0 + Math.PI * hCapBot * hCapBot * hCapBot / 6.0
|
||||||
|
vTop = sS[n - 1] * hCapTop / 3.0
|
||||||
|
bottomKind = "$capKind sphere"
|
||||||
|
topKind = "$capKind cone"
|
||||||
|
}
|
||||||
|
|
||||||
// 10) 합산
|
// 10) 합산
|
||||||
val bvMm3 = vCore + vBottom + vTop
|
val bvMm3 = vCore + vBottom + vTop
|
||||||
|
|||||||
+7
-1
@@ -480,9 +480,15 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
cd?.buffer?.map { it.toDouble() }?.toDoubleArray() ?: DoubleArray(0)
|
cd?.buffer?.map { it.toDouble() }?.toDoubleArray() ?: DoubleArray(0)
|
||||||
}
|
}
|
||||||
if (signals.all { it.isEmpty() }) allWalls
|
if (signals.all { it.isEmpty() }) allWalls
|
||||||
|
// 2026-07-02: NEW BV lib (piezophantomtest 4830e7c) lumen_inset_frac=0.15
|
||||||
|
// MethodDResult 의 (ls, le) 로 벽을 lumen 안쪽으로 15% 이동.
|
||||||
else com.example.medilightv2android.walldetect.MethodDRunner
|
else com.example.medilightv2android.walldetect.MethodDRunner
|
||||||
.detectMultichannel(signals)
|
.detectMultichannel(signals)
|
||||||
.map { r -> r?.let { Pair(it.ant, it.post) } }
|
.map { r -> r?.let {
|
||||||
|
com.example.medilightv2android.managers.applyLumenInsetOne(
|
||||||
|
it.ant, it.post, it.lowStart, it.lowEnd
|
||||||
|
)
|
||||||
|
} }
|
||||||
} else allWalls
|
} else allWalls
|
||||||
val effectiveAllWalls: List<Pair<Int, Int>?> =
|
val effectiveAllWalls: List<Pair<Int, Int>?> =
|
||||||
if (antOffsetSamples == 0) sourceAllWalls
|
if (antOffsetSamples == 0) sourceAllWalls
|
||||||
|
|||||||
@@ -28,9 +28,11 @@ data class MethodDResult(
|
|||||||
// 사후에 갱신하므로 var. Python `r.ant = pick[0]`, `r.ant_refined = float(pick[0])`,
|
// 사후에 갱신하므로 var. Python `r.ant = pick[0]`, `r.ant_refined = float(pick[0])`,
|
||||||
// `r.urine_len = int(r.post - pick[0] - 1)` 와 1:1 매칭.
|
// `r.urine_len = int(r.post - pick[0] - 1)` 와 1:1 매칭.
|
||||||
var ant: Int,
|
var ant: Int,
|
||||||
val post: Int,
|
// 2026-07-02: post, postRefined, postType 도 var — cross-channel `apply_neighbor_top_validate`
|
||||||
|
// 와 `apply_inward_post` (piezophantomtest 4830e7c / 5576a59) 가 사후 수정.
|
||||||
|
var post: Int,
|
||||||
var antRefined: Double,
|
var antRefined: Double,
|
||||||
val postRefined: Double,
|
var postRefined: Double,
|
||||||
val lowStart: Int,
|
val lowStart: Int,
|
||||||
val lowEnd: Int,
|
val lowEnd: Int,
|
||||||
val lowAmp: Double,
|
val lowAmp: Double,
|
||||||
@@ -40,7 +42,7 @@ data class MethodDResult(
|
|||||||
val antProm: Double,
|
val antProm: Double,
|
||||||
val postProm: Double,
|
val postProm: Double,
|
||||||
val antType: CType,
|
val antType: CType,
|
||||||
val postType: CType,
|
var postType: CType,
|
||||||
val sgHeavy: DoubleArray,
|
val sgHeavy: DoubleArray,
|
||||||
val sgLight: DoubleArray,
|
val sgLight: DoubleArray,
|
||||||
/** 초기 ant 후보 [(idx, score), ...] score 내림차순 — 02bed02 cross-channel tie-break 용.
|
/** 초기 ant 후보 [(idx, score), ...] score 내림차순 — 02bed02 cross-channel tie-break 용.
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
/*
|
/*
|
||||||
* Method D multichannel runner — port of library/runners.py method_d().
|
* Method D multichannel runner + cross-channel corrections.
|
||||||
*
|
*
|
||||||
* 입력: List<DoubleArray> (6채널 raw ADC, 길이 100 가정)
|
* 1:1 port of piezophantomtest `library/runners.py::method_d()` +
|
||||||
* 처리 (Python 1:1):
|
* `library/cross_channel.py` (2026-07-02 4830e7c + 5576a59)
|
||||||
|
*
|
||||||
|
* 파이프라인:
|
||||||
* 1) heavy = SG(7,3) + oscfar_median(win=9, iter=4) ← 02bed02 win 5→9
|
* 1) heavy = SG(7,3) + oscfar_median(win=9, iter=4) ← 02bed02 win 5→9
|
||||||
* light = SG(7,3)
|
* light = SG(7,3)
|
||||||
* 2) apply_tgc_pipeline(heavy / light) — center_ch=None → all channels
|
* 2) apply_tgc_pipeline(heavy / light) — center_ch=None → all channels
|
||||||
* 3) 채널별 otsu_ratio × cos(beam_angle) ← PiezoHW.degreeAll
|
* 3) 채널별 otsu_ratio × cos(beam_angle) ← PiezoHW.degreeAll
|
||||||
* 4) MethodDDetector.detect()
|
* 4) MethodDDetector.detect()
|
||||||
* 5) [02bed02] _apply_ant_tiebreak — cross-channel 전벽 보정 (in-place)
|
* 5) cross-channel 후처리 (순서 고정):
|
||||||
*
|
* ① applyAntTiebreak — 전벽 교차보정 (4830e7c 신 버전)
|
||||||
* TGC 는 default ON (Python 과 동일). `applyTgc=false` 로 비활성화 가능.
|
* ② applyNeighborTopValidate — 최상단 채널 FP/FN 검증 (신규 4830e7c)
|
||||||
|
* ③ applyInwardPost — 최상단 widest post 과확장 교정 (신규 5576a59)
|
||||||
*
|
*
|
||||||
* 출력 컨트랙트: alignment.py 가 `dets[i].urine_len` 만 의존 →
|
* 출력 컨트랙트: alignment.py 가 `dets[i].urine_len` 만 의존 →
|
||||||
* MethodDResult.urineLen 또는 null 의 List 로 충분.
|
* MethodDResult.urineLen 또는 null 의 List 로 충분.
|
||||||
@@ -22,11 +25,32 @@ import com.example.medilightv2android.managers.PiezoHW
|
|||||||
import com.example.medilightv2android.walldetect.algo.methodd.MethodDParams
|
import com.example.medilightv2android.walldetect.algo.methodd.MethodDParams
|
||||||
import com.example.medilightv2android.walldetect.algo.methodd.MethodDPreprocessing
|
import com.example.medilightv2android.walldetect.algo.methodd.MethodDPreprocessing
|
||||||
import com.example.medilightv2android.walldetect.algo.methodd.MethodDTgc
|
import com.example.medilightv2android.walldetect.algo.methodd.MethodDTgc
|
||||||
|
import com.example.medilightv2android.walldetect.algo.methodd.MethodDWallSelect
|
||||||
|
import com.example.medilightv2android.walldetect.algo.methodd.MethodDWallSelect.CType
|
||||||
|
import com.example.medilightv2android.walldetect.algo.methodd.MethodDWallSelect.Side
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.min as kmin
|
||||||
|
|
||||||
object MethodDRunner {
|
object MethodDRunner {
|
||||||
|
|
||||||
|
// ── cross-channel tuning constants (cross_channel.py:30-45) ────────────────
|
||||||
|
/** 깊은쪽 교정 시 "합의에 이 이상 더 가까운 후보" 요구 (곡률 보존). */
|
||||||
|
private const val ANT_OUTLIER_IMPROVE_MARGIN_MM = 5.0
|
||||||
|
/** 교정 대상이 합의에서 이 이상 떨어지면 보정 보류 (얕은쪽은 제외). */
|
||||||
|
private const val ANT_CONSENSUS_CLOSE_TOL_MM = 6.0
|
||||||
|
/** dominant-pick 보호: raw 픽 score >= 이 배수 × 차순위 → 합의 교정에서 보존. */
|
||||||
|
private const val ANT_DOMINANT_RATIO = 2.0
|
||||||
|
|
||||||
|
/** neighbor_top_validate: nb-1→nb slope 가 이 이하면 "비증가 추세". */
|
||||||
|
private const val NTV_SLOPE_EPS = 2.0
|
||||||
|
/** neighbor_top_validate: top 이 예측보다 이만큼 깊으면 위반 (FP). */
|
||||||
|
private const val NTV_DEV_DELTA = 6.0
|
||||||
|
|
||||||
|
/** cross-channel 3개 스위치 (Python 과 동일 default). */
|
||||||
|
private const val NEIGHBOR_TOP_VALIDATE = true
|
||||||
|
private const val INWARD_POST_SEARCH = true
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 6채널 (또는 N채널) raw 신호 → 채널별 MethodDResult? 리스트.
|
* 6채널 (또는 N채널) raw 신호 → 채널별 MethodDResult? 리스트.
|
||||||
* raw[ch] 길이가 다르면 그대로 처리 (각 채널 독립).
|
* raw[ch] 길이가 다르면 그대로 처리 (각 채널 독립).
|
||||||
@@ -56,49 +80,62 @@ object MethodDRunner {
|
|||||||
params = params,
|
params = params,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// 5) [02bed02] cross-channel 전벽 tie-break — in-place 보정
|
// 5) cross-channel 후처리 (Python `apply_cross_channel` 순서 고정)
|
||||||
applyAntTiebreak(results, angles)
|
applyCrossChannel(results, angles, lightTgc, heavyTgc, signals, params)
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cross-channel 전벽 보정 (in-place, method_d 전용) — 02bed02 runners.py `_apply_ant_tiebreak`.
|
* Cross-channel 3-stage 후처리 (Python `apply_cross_channel`).
|
||||||
|
* ① ant_tiebreak → ② neighbor_top_validate → ③ inward_post
|
||||||
|
*/
|
||||||
|
private fun applyCrossChannel(
|
||||||
|
results: MutableList<MethodDResult?>,
|
||||||
|
angles: DoubleArray,
|
||||||
|
light: List<DoubleArray>,
|
||||||
|
heavy: List<DoubleArray>,
|
||||||
|
raw6ch: List<DoubleArray>,
|
||||||
|
params: MethodDParams,
|
||||||
|
) {
|
||||||
|
applyAntTiebreak(results, angles)
|
||||||
|
if (NEIGHBOR_TOP_VALIDATE) {
|
||||||
|
applyNeighborTopValidate(results, angles, light, heavy, raw6ch, params)
|
||||||
|
}
|
||||||
|
if (INWARD_POST_SEARCH) {
|
||||||
|
applyInwardPost(results, angles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// ① ant_tiebreak — 전벽 교차보정 (2026-07-02: 4830e7c 신 버전)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* 이웃 center 채널 전벽 z 합의 (LOO median) 기준 전벽 보정 (in-place).
|
||||||
*
|
*
|
||||||
* 이웃 center 채널의 전벽 좌표(z=AP깊이) 합의(leave-one-out median)를 기준으로 두 경우 개입:
|
* 단일 임계 `devTol=8mm` + 방향 분기 (구 nf_tol 8 / outlier_tol 9 통합):
|
||||||
|
* [최우선] dominant-pick 보호: raw 픽 score >= 2.0 × 차순위 → 보존
|
||||||
|
* (1) 동률(top1/top2 < tieRatio): 동률 band 중 합의 최근접 선택
|
||||||
|
* (2) 얕은쪽 (합의 - dev_tol 미만 = near-field 아티팩트): 무조건 재선택
|
||||||
|
* (3) 깊은쪽 (합의 + dev_tol 초과): improve_margin 이상 더 가까운 후보 있을 때만
|
||||||
*
|
*
|
||||||
* (1) **동률** (top1/top2 score < tieRatio): 동률 band 후보 중 합의 z 에 가장 가까운 것 선택.
|
* 얕은쪽 제외 후 합의 최근접 pick. 합의-근접 가드 (얕은쪽 교정은 항상, 그 외엔 pick 이
|
||||||
* 단일 채널 scoring 이 인접 sub-peak 사이에서 토글하는 모호함 해소.
|
* 합의 근접일 때만).
|
||||||
* (2) **near-field 아티팩트** (압승이어도): 선택된 ant 가 합의보다 nfTolMm 이상 "얕은"
|
|
||||||
* (probe/near-field 쪽) 경우 entry 복합체 오검출로 보고, near-field 후보를 제외하고
|
|
||||||
* 합의에 가장 가까운 후보로 교체.
|
|
||||||
*
|
|
||||||
* **방향성**: "더 깊은(urine 쪽)" 이탈은 보정하지 않음 — 바깥 채널(CH0/CH3)은 방광 dome
|
|
||||||
* 곡률로 이웃보다 urine 쪽에 전벽이 잡히는 게 정상이라 실측 보존. 얕은(near-field) 쪽 이탈만 기각.
|
|
||||||
*
|
|
||||||
* 압승이고 near-field 아티팩트도 아니면 측정값 그대로 둠 → "실측 보존, 모호함·near-field 오검출만 해소".
|
|
||||||
*/
|
*/
|
||||||
private fun applyAntTiebreak(
|
private fun applyAntTiebreak(
|
||||||
results: MutableList<MethodDResult?>,
|
results: MutableList<MethodDResult?>,
|
||||||
angles: DoubleArray,
|
angles: DoubleArray,
|
||||||
tieRatio: Double = AlignmentConstants.ANT_TIEBREAK_RATIO,
|
tieRatio: Double = AlignmentConstants.ANT_TIEBREAK_RATIO,
|
||||||
nfTolMm: Double = AlignmentConstants.ANT_NEARFIELD_TOL_MM,
|
devTol: Double = AlignmentConstants.ANT_NEARFIELD_TOL_MM,
|
||||||
|
improveMargin: Double = ANT_OUTLIER_IMPROVE_MARGIN_MM,
|
||||||
) {
|
) {
|
||||||
if (results.isEmpty()) return
|
if (results.isEmpty()) return
|
||||||
|
|
||||||
// z(i, idx) = (DELAY_OFFSET_MM + idx * DISTANCE_PER_SAMPLE) * cos(angle_i)
|
// 현재 center 채널 ant z 좌표 (LOO median 의 base)
|
||||||
val delay = PiezoHW.delayOffsetMm
|
|
||||||
val dps = PiezoHW.distancePerSample
|
|
||||||
fun z(ch: Int, idx: Int): Double {
|
|
||||||
val ang = if (ch < angles.size) angles[ch] else 0.0
|
|
||||||
return (delay + idx * dps) * cos(Math.toRadians(ang))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 현재 center 채널 ant z 좌표 (leave-one-out median 의 base)
|
|
||||||
val baseZ = HashMap<Int, Double>()
|
val baseZ = HashMap<Int, Double>()
|
||||||
for (i in AlignmentConstants.CENTER_CH) {
|
for (i in AlignmentConstants.CENTER_CH) {
|
||||||
if (i < results.size) {
|
if (i < results.size) {
|
||||||
val r = results[i] ?: continue
|
val r = results[i] ?: continue
|
||||||
baseZ[i] = z(i, r.ant)
|
baseZ[i] = zOf(angles, i, r.ant)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,34 +145,45 @@ object MethodDRunner {
|
|||||||
val cands = r.antCandidates
|
val cands = r.antCandidates
|
||||||
if (cands.size < 2) continue
|
if (cands.size < 2) continue
|
||||||
|
|
||||||
// 이웃 center 채널 (i 제외) 들의 base z median
|
|
||||||
val cons = AlignmentConstants.CENTER_CH
|
val cons = AlignmentConstants.CENTER_CH
|
||||||
.filter { it != i }
|
.filter { it != i }
|
||||||
.mapNotNull { baseZ[it] }
|
.mapNotNull { baseZ[it] }
|
||||||
if (cons.isEmpty()) continue
|
if (cons.isEmpty()) continue
|
||||||
val cz = median(cons)
|
val cz = median(cons)
|
||||||
|
|
||||||
// 동률 / near-field 판정
|
// dominant-pick 보호 (최우선)
|
||||||
val top1 = cands[0].second
|
if (cands[0].second >= ANT_DOMINANT_RATIO * cands[1].second
|
||||||
val top2 = cands[1].second
|
&& r.ant == cands[0].first) {
|
||||||
val isTie = top1 / maxOf(top2, 1e-9) < tieRatio
|
continue
|
||||||
val isNearfield = z(i, r.ant) < cz - nfTolMm
|
|
||||||
|
|
||||||
if (!isTie && !isNearfield) continue // 압승 & near-field 아님 → 유지
|
|
||||||
|
|
||||||
val pool: List<Pair<Int, Double>> = if (isTie) {
|
|
||||||
val thr = top1 / tieRatio
|
|
||||||
cands.filter { it.second >= thr }
|
|
||||||
} else {
|
|
||||||
cands.toList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// near-field (합의보다 nfTol 이상 얕은) 후보 제외, urine 쪽(깊은)은 허용
|
val cur = zOf(angles, i, r.ant)
|
||||||
val eligPrelim = pool.filter { z(i, it.first) >= cz - nfTolMm }
|
val curDev = abs(cur - cz)
|
||||||
val elig = if (eligPrelim.isEmpty()) pool else eligPrelim
|
val bestDev = cands.minOf { abs(zOf(angles, i, it.first) - cz) }
|
||||||
val pick = elig.minBy { abs(z(i, it.first) - cz) }
|
val isTie = cands[0].second / maxOf(cands[1].second, 1e-9) < tieRatio
|
||||||
|
val isShallow = cur < cz - devTol
|
||||||
|
val isDeep = cur > cz + devTol
|
||||||
|
|
||||||
|
// 진입 + 후보 pool (방향 분기)
|
||||||
|
val pool: List<Pair<Int, Double>> = when {
|
||||||
|
isTie -> {
|
||||||
|
val thr = cands[0].second / tieRatio
|
||||||
|
cands.filter { it.second >= thr }
|
||||||
|
}
|
||||||
|
isShallow -> cands.toList() // 얕은 아티팩트 → 무조건
|
||||||
|
isDeep && bestDev < curDev - improveMargin -> cands.toList()
|
||||||
|
else -> continue // 압승 & 정상 범위 → 유지
|
||||||
|
}
|
||||||
|
|
||||||
|
// 얕은(아티팩트) 후보 제외, 합의 최근접 선택
|
||||||
|
val eligPrelim = pool.filter { zOf(angles, i, it.first) >= cz - devTol }
|
||||||
|
val elig = if (eligPrelim.isEmpty()) pool else eligPrelim
|
||||||
|
val pick = elig.minBy { abs(zOf(angles, i, it.first) - cz) }
|
||||||
|
|
||||||
|
// 합의-근접 가드: 얕은쪽 교정은 항상, 그 외엔 pick 이 합의 근접일 때만
|
||||||
|
val pickDev = abs(zOf(angles, i, pick.first) - cz)
|
||||||
|
if (pickDev > ANT_CONSENSUS_CLOSE_TOL_MM && !isShallow) continue
|
||||||
|
|
||||||
// post > pick 보장 (urine_len > 0)
|
|
||||||
if (pick.first != r.ant && r.post > pick.first) {
|
if (pick.first != r.ant && r.post > pick.first) {
|
||||||
r.ant = pick.first
|
r.ant = pick.first
|
||||||
r.antRefined = pick.first.toDouble()
|
r.antRefined = pick.first.toDouble()
|
||||||
@@ -144,6 +192,228 @@ object MethodDRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// ② neighbor_top_validate — 최상단 채널 FP/FN 검증 (신규 4830e7c)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* 이웃 후위벽 trend 로 최상단 채널 검증 (in-place).
|
||||||
|
*
|
||||||
|
* Rule B (trend-FP): 검출 top 후위벽 z 를 아래 두 채널 기울기로 예측. 비증가추세인데
|
||||||
|
* top 이 예측보다 깊게(>6mm) jump → nb urine span 시드 재탐색 → drop/교체.
|
||||||
|
* Rule A (FN): 미검출 top 을 바로 아래 검출 채널 span 시드로 재탐색 복원(gate 통과 시).
|
||||||
|
*/
|
||||||
|
private fun applyNeighborTopValidate(
|
||||||
|
results: MutableList<MethodDResult?>,
|
||||||
|
angles: DoubleArray,
|
||||||
|
light: List<DoubleArray>,
|
||||||
|
heavy: List<DoubleArray>,
|
||||||
|
raw6ch: List<DoubleArray>,
|
||||||
|
params: MethodDParams,
|
||||||
|
) {
|
||||||
|
val n = kmin(4, results.size)
|
||||||
|
val origNone = (0 until n).filter { results[it] == null }.toHashSet()
|
||||||
|
val det = (0 until n).filter { results[it] != null }.sorted()
|
||||||
|
|
||||||
|
// ---- Rule B ----
|
||||||
|
if (det.size >= 3) {
|
||||||
|
val top = det[0]; val nb1 = det[1]; val nb2 = det[2]
|
||||||
|
val rTop = results[top]!!
|
||||||
|
val rNb1 = results[nb1]!!
|
||||||
|
val rNb2 = results[nb2]!!
|
||||||
|
val zTop = zOf(angles, top, rTop.post)
|
||||||
|
val zNb1 = zOf(angles, nb1, rNb1.post)
|
||||||
|
val zNb2 = zOf(angles, nb2, rNb2.post)
|
||||||
|
val slope = zNb1 - zNb2
|
||||||
|
val pred = zNb1 + slope
|
||||||
|
if (slope <= NTV_SLOPE_EPS && (zTop - pred) > NTV_DEV_DELTA) {
|
||||||
|
val rr = researchPostInSpan(
|
||||||
|
light[top], heavy[top], raw6ch[top], rNb1.lowStart, rNb1.lowEnd, params
|
||||||
|
)
|
||||||
|
if (rr == null || (zOf(angles, top, rr.post) - pred) > NTV_DEV_DELTA) {
|
||||||
|
results[top] = null
|
||||||
|
} else {
|
||||||
|
rTop.post = rr.post
|
||||||
|
rTop.postRefined = rr.post.toDouble()
|
||||||
|
rTop.postType = rr.postType
|
||||||
|
rTop.urineLen = rTop.post - rTop.ant - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Rule A ----
|
||||||
|
val first = (0 until n).firstOrNull { results[it] != null }
|
||||||
|
if (first != null && first >= 1 && (first - 1) in origNone) {
|
||||||
|
val top = first - 1
|
||||||
|
val nb = first
|
||||||
|
val rNb = results[nb]!!
|
||||||
|
val ls = rNb.lowStart
|
||||||
|
val le = rNb.lowEnd
|
||||||
|
val rr = researchPostInSpan(light[top], heavy[top], raw6ch[top], ls, le, params)
|
||||||
|
if (rr != null) {
|
||||||
|
val lowAmp = minInRange(heavy[top], ls, le)
|
||||||
|
results[top] = MethodDResult(
|
||||||
|
ant = rr.ant,
|
||||||
|
post = rr.post,
|
||||||
|
antRefined = rr.ant.toDouble(),
|
||||||
|
postRefined = rr.post.toDouble(),
|
||||||
|
lowStart = ls,
|
||||||
|
lowEnd = le,
|
||||||
|
lowAmp = lowAmp,
|
||||||
|
inwardWalkAnt = 0,
|
||||||
|
inwardWalkPost = 0,
|
||||||
|
urineLen = rr.post - rr.ant - 1,
|
||||||
|
antProm = 0.0,
|
||||||
|
postProm = 0.0,
|
||||||
|
antType = rr.antType,
|
||||||
|
postType = rr.postType,
|
||||||
|
sgHeavy = heavy[top],
|
||||||
|
sgLight = light[top],
|
||||||
|
antCandidates = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** _research_post_in_span 결과. */
|
||||||
|
private data class ResearchResult(val ant: Int, val post: Int, val antType: CType, val postType: CType)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* seed span(ls, le) 에서 ant / post 재탐색 + wall/raw gate. 실패 시 null.
|
||||||
|
* detector 와 동일한 gate (_wall_gate_ok + min_post_raw_ratio) 로 재검증.
|
||||||
|
*/
|
||||||
|
private fun researchPostInSpan(
|
||||||
|
lt: DoubleArray, hv: DoubleArray, raw: DoubleArray,
|
||||||
|
ls: Int, le: Int, params: MethodDParams,
|
||||||
|
): ResearchResult? {
|
||||||
|
if (le <= ls) return null
|
||||||
|
val antRes = MethodDWallSelect.findWallPeakLocal(
|
||||||
|
lt, ls, le, Side.ANT, params,
|
||||||
|
dMaxOverride = params.antDMax, inwardWalk = 0,
|
||||||
|
)
|
||||||
|
val postRes = MethodDWallSelect.findWallPeakLocal(
|
||||||
|
lt, ls, le, Side.POST, params,
|
||||||
|
dMaxOverride = params.dMax, inwardWalk = 0,
|
||||||
|
)
|
||||||
|
val antBest = antRes.best ?: return null
|
||||||
|
val postBest = postRes.best ?: return null
|
||||||
|
val ai = antBest.idx
|
||||||
|
val pi = postBest.idx
|
||||||
|
if (pi <= ai || (pi - ai - 1) < params.minUrineLen) return null
|
||||||
|
|
||||||
|
// wall gate
|
||||||
|
val lumenMin = minInRange(hv, ls, le)
|
||||||
|
val wallAmp = if (params.wallRatioUsePostOnly) lt[pi]
|
||||||
|
else kmin(lt[ai], lt[pi])
|
||||||
|
if (wallAmp / maxOf(lumenMin, 1.0) < params.minWallLumenRatio) return null
|
||||||
|
|
||||||
|
// raw ratio gate (SG-only 재계산)
|
||||||
|
val rawHv = MethodDPreprocessing.preprocessHeavy(raw, params)
|
||||||
|
val rawLt = MethodDPreprocessing.preprocessLight(raw)
|
||||||
|
val rawLumen = minInRange(rawHv, ls, le)
|
||||||
|
if (rawLt[pi] / maxOf(rawLumen, 1.0) < params.minPostRawRatio) return null
|
||||||
|
|
||||||
|
return ResearchResult(ai, pi, antBest.type, postBest.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// ③ inward_post — 최상단 widest 후벽 과확장 교정 (신규 5576a59)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* 최상단 검출 center 채널이 widest (적도가 fan 위) 면, 그 채널 후벽을
|
||||||
|
* [post-win..post] 범위의 더 inward 한 후보(urine-wall ratio ≥ gate)로 교체.
|
||||||
|
* (a) 분리형 peak, (b) shoulder.
|
||||||
|
*/
|
||||||
|
private fun applyInwardPost(
|
||||||
|
results: MutableList<MethodDResult?>,
|
||||||
|
angles: DoubleArray,
|
||||||
|
win: Int = 16,
|
||||||
|
ratioGate: Double = 1.15,
|
||||||
|
frac: Double = 0.75,
|
||||||
|
) {
|
||||||
|
val det = (0 until kmin(4, results.size))
|
||||||
|
.filter { results[it] != null }
|
||||||
|
.sorted()
|
||||||
|
if (det.size < 2) return
|
||||||
|
|
||||||
|
fun dOf(i: Int): Double {
|
||||||
|
val r = results[i]!!
|
||||||
|
val ang = if (i < angles.size) angles[i] else 0.0
|
||||||
|
return (r.post - r.ant) * cos(Math.toRadians(ang))
|
||||||
|
}
|
||||||
|
val s = det.associateWith { dOf(it) * dOf(it) }
|
||||||
|
val top = det[0]
|
||||||
|
val topS = s[top]!!
|
||||||
|
val maxS = s.values.max()
|
||||||
|
val secondS = s[det[1]]!!
|
||||||
|
if (topS < maxS || topS < secondS) return
|
||||||
|
|
||||||
|
val r = results[top]!!
|
||||||
|
val p = r.post
|
||||||
|
val ls = r.lowStart
|
||||||
|
val le = r.lowEnd
|
||||||
|
val lt = r.sgLight
|
||||||
|
val hv = r.sgHeavy
|
||||||
|
if (le <= ls || p - 2 <= ls + 3) return
|
||||||
|
|
||||||
|
val base = minInRange(hv, ls, le)
|
||||||
|
if (base <= 0 || base.isNaN() || base.isInfinite()) return
|
||||||
|
|
||||||
|
val postRatio = if (p in lt.indices) lt[p] / base else 0.0
|
||||||
|
val lo = maxOf(ls + 3, p - win)
|
||||||
|
val cands = mutableListOf<Int>()
|
||||||
|
for (i in lo until p - 2) {
|
||||||
|
if (i !in lt.indices) continue
|
||||||
|
if (lt[i] / base < ratioGate) continue
|
||||||
|
|
||||||
|
// (a) 분리형 peak: light local max, i~post 사이 valley, 깊은 peak 강도 frac 이상
|
||||||
|
val isPeak = i - 1 in lt.indices && i + 1 in lt.indices
|
||||||
|
&& lt[i] >= lt[i - 1] && lt[i] > lt[i + 1]
|
||||||
|
if (isPeak) {
|
||||||
|
var minLtIP = lt[i]
|
||||||
|
for (j in i..p) if (j in lt.indices && lt[j] < minLtIP) minLtIP = lt[j]
|
||||||
|
if (minLtIP < lt[i] * 0.97 && lt[i] >= postRatio * base * frac) {
|
||||||
|
cands.add(i)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// (b) shoulder: 3-샘플 plateau + 앞쪽 상승 + 뒤에 더 깊은 peak
|
||||||
|
if (i + 3 <= p && i - 2 in lt.indices) {
|
||||||
|
var maxWin = lt[i]; var minWin = lt[i]
|
||||||
|
for (j in i until i + 3) {
|
||||||
|
if (j in lt.indices) {
|
||||||
|
if (lt[j] > maxWin) maxWin = lt[j]
|
||||||
|
if (lt[j] < minWin) minWin = lt[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val flat = (maxWin - minWin) < 0.02 * lt[i]
|
||||||
|
val risingBefore = lt[i] > lt[i - 2] + 0.03 * base
|
||||||
|
var maxAfter = lt[i + 3]
|
||||||
|
for (j in i + 3..p) if (j in lt.indices && lt[j] > maxAfter) maxAfter = lt[j]
|
||||||
|
val higherAfter = maxAfter > lt[i] * 1.03
|
||||||
|
if (flat && risingBefore && higherAfter) {
|
||||||
|
cands.add(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cands.isEmpty()) return
|
||||||
|
val newPost = cands.min()
|
||||||
|
if (newPost < p - 3) {
|
||||||
|
r.post = newPost
|
||||||
|
r.postRefined = newPost.toDouble()
|
||||||
|
r.urineLen = r.post - r.ant - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 공통 helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** z(i, idx) = (DELAY_OFFSET_MM + idx * DISTANCE_PER_SAMPLE) * cos(angle_i) — sample_to_ap_depth. */
|
||||||
|
private fun zOf(angles: DoubleArray, ch: Int, idx: Int): Double {
|
||||||
|
val ang = if (ch < angles.size) angles[ch] else 0.0
|
||||||
|
return (PiezoHW.delayOffsetMm + idx * PiezoHW.distancePerSample) * cos(Math.toRadians(ang))
|
||||||
|
}
|
||||||
|
|
||||||
/** numpy.median 동작 매칭 — 짝수 길이면 두 가운데 값의 평균. */
|
/** numpy.median 동작 매칭 — 짝수 길이면 두 가운데 값의 평균. */
|
||||||
private fun median(xs: List<Double>): Double {
|
private fun median(xs: List<Double>): Double {
|
||||||
if (xs.isEmpty()) return 0.0
|
if (xs.isEmpty()) return 0.0
|
||||||
@@ -152,4 +422,13 @@ object MethodDRunner {
|
|||||||
return if (n % 2 == 1) sorted[n / 2]
|
return if (n % 2 == 1) sorted[n / 2]
|
||||||
else (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
|
else (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** arr[from..to] 최소 (numpy min 매칭). 유효 범위 밖은 skip. 없으면 0.0. */
|
||||||
|
private fun minInRange(arr: DoubleArray, from: Int, to: Int): Double {
|
||||||
|
var m = Double.POSITIVE_INFINITY
|
||||||
|
val lo = maxOf(0, from)
|
||||||
|
val hi = kmin(arr.size - 1, to)
|
||||||
|
for (i in lo..hi) if (arr[i] < m) m = arr[i]
|
||||||
|
return if (m.isFinite()) m else 0.0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user