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:
2026-07-02 14:18:20 +09:00
parent 889c65b038
commit 55d55225ab
4 changed files with 453 additions and 59 deletions
@@ -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
@@ -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
}
} }