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 kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.min
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sin
|
||||
@@ -382,6 +383,89 @@ private fun solve4x4(A: Array<DoubleArray>, b: DoubleArray): DoubleArray? {
|
||||
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 ──
|
||||
|
||||
/**
|
||||
@@ -533,6 +617,7 @@ fun estimateBladderVolume(
|
||||
val nPts = allYw.size
|
||||
var capKind = "fallback"
|
||||
var z0Ellipse: Double? = null
|
||||
var cApPrior: Double? = null // shrink_bottom_cap 용 AP 반축
|
||||
var hCapBot = aCapS[0]
|
||||
var hCapTop = aCapS[n - 1]
|
||||
val ellipseCostThr = 0.5 // 점당 평균 잔차 임계
|
||||
@@ -594,11 +679,21 @@ fun estimateBladderVolume(
|
||||
hCapTop = max(0.0, (y0 + bSi) - yS[n - 1])
|
||||
hCapBot = min(hCapBot, aCapS[0])
|
||||
hCapTop = min(hCapTop, aCapS[n - 1])
|
||||
cApPrior = aAp
|
||||
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 좌표로 제한
|
||||
val nTotalCh = sensorZMm.size
|
||||
val topSortedCh = sortedCh.last()
|
||||
@@ -622,11 +717,23 @@ fun estimateBladderVolume(
|
||||
}
|
||||
val vCore = vFrustum.sum()
|
||||
|
||||
// 9) Caps — Bottom: spherical cap, Top: cone
|
||||
val vBottom = sS[0] * hCapBot / 2.0 + Math.PI * hCapBot * hCapBot * hCapBot / 6.0
|
||||
val vTop = sS[n - 1] * hCapTop / 3.0
|
||||
val bottomKind = "$capKind sphere"
|
||||
val topKind = "$capKind cone"
|
||||
// 9) Caps — Bottom: spherical cap (shrink 시 R_eff 공유), Top: cone
|
||||
val vBottom: Double
|
||||
val vTop: Double
|
||||
val bottomKind: String
|
||||
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) 합산
|
||||
val bvMm3 = vCore + vBottom + vTop
|
||||
|
||||
+7
-1
@@ -480,9 +480,15 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
cd?.buffer?.map { it.toDouble() }?.toDoubleArray() ?: DoubleArray(0)
|
||||
}
|
||||
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
|
||||
.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
|
||||
val effectiveAllWalls: List<Pair<Int, Int>?> =
|
||||
if (antOffsetSamples == 0) sourceAllWalls
|
||||
|
||||
@@ -28,9 +28,11 @@ data class MethodDResult(
|
||||
// 사후에 갱신하므로 var. Python `r.ant = pick[0]`, `r.ant_refined = float(pick[0])`,
|
||||
// `r.urine_len = int(r.post - pick[0] - 1)` 와 1:1 매칭.
|
||||
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,
|
||||
val postRefined: Double,
|
||||
var postRefined: Double,
|
||||
val lowStart: Int,
|
||||
val lowEnd: Int,
|
||||
val lowAmp: Double,
|
||||
@@ -40,7 +42,7 @@ data class MethodDResult(
|
||||
val antProm: Double,
|
||||
val postProm: Double,
|
||||
val antType: CType,
|
||||
val postType: CType,
|
||||
var postType: CType,
|
||||
val sgHeavy: DoubleArray,
|
||||
val sgLight: DoubleArray,
|
||||
/** 초기 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 가정)
|
||||
* 처리 (Python 1:1):
|
||||
* 1:1 port of piezophantomtest `library/runners.py::method_d()` +
|
||||
* `library/cross_channel.py` (2026-07-02 4830e7c + 5576a59)
|
||||
*
|
||||
* 파이프라인:
|
||||
* 1) heavy = SG(7,3) + oscfar_median(win=9, iter=4) ← 02bed02 win 5→9
|
||||
* light = SG(7,3)
|
||||
* 2) apply_tgc_pipeline(heavy / light) — center_ch=None → all channels
|
||||
* 3) 채널별 otsu_ratio × cos(beam_angle) ← PiezoHW.degreeAll
|
||||
* 4) MethodDDetector.detect()
|
||||
* 5) [02bed02] _apply_ant_tiebreak — cross-channel 전벽 보정 (in-place)
|
||||
*
|
||||
* TGC 는 default ON (Python 과 동일). `applyTgc=false` 로 비활성화 가능.
|
||||
* 5) cross-channel 후처리 (순서 고정):
|
||||
* ① applyAntTiebreak — 전벽 교차보정 (4830e7c 신 버전)
|
||||
* ② applyNeighborTopValidate — 최상단 채널 FP/FN 검증 (신규 4830e7c)
|
||||
* ③ applyInwardPost — 최상단 widest post 과확장 교정 (신규 5576a59)
|
||||
*
|
||||
* 출력 컨트랙트: alignment.py 가 `dets[i].urine_len` 만 의존 →
|
||||
* 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.MethodDPreprocessing
|
||||
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.cos
|
||||
import kotlin.math.min as kmin
|
||||
|
||||
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? 리스트.
|
||||
* raw[ch] 길이가 다르면 그대로 처리 (각 채널 독립).
|
||||
@@ -56,49 +80,62 @@ object MethodDRunner {
|
||||
params = params,
|
||||
)
|
||||
}
|
||||
// 5) [02bed02] cross-channel 전벽 tie-break — in-place 보정
|
||||
applyAntTiebreak(results, angles)
|
||||
// 5) cross-channel 후처리 (Python `apply_cross_channel` 순서 고정)
|
||||
applyCrossChannel(results, angles, lightTgc, heavyTgc, signals, params)
|
||||
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 에 가장 가까운 것 선택.
|
||||
* 단일 채널 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 오검출만 해소".
|
||||
* 얕은쪽 제외 후 합의 최근접 pick. 합의-근접 가드 (얕은쪽 교정은 항상, 그 외엔 pick 이
|
||||
* 합의 근접일 때만).
|
||||
*/
|
||||
private fun applyAntTiebreak(
|
||||
results: MutableList<MethodDResult?>,
|
||||
angles: DoubleArray,
|
||||
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
|
||||
|
||||
// z(i, idx) = (DELAY_OFFSET_MM + idx * DISTANCE_PER_SAMPLE) * cos(angle_i)
|
||||
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)
|
||||
// 현재 center 채널 ant z 좌표 (LOO median 의 base)
|
||||
val baseZ = HashMap<Int, Double>()
|
||||
for (i in AlignmentConstants.CENTER_CH) {
|
||||
if (i < results.size) {
|
||||
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
|
||||
if (cands.size < 2) continue
|
||||
|
||||
// 이웃 center 채널 (i 제외) 들의 base z median
|
||||
val cons = AlignmentConstants.CENTER_CH
|
||||
.filter { it != i }
|
||||
.mapNotNull { baseZ[it] }
|
||||
if (cons.isEmpty()) continue
|
||||
val cz = median(cons)
|
||||
|
||||
// 동률 / near-field 판정
|
||||
val top1 = cands[0].second
|
||||
val top2 = cands[1].second
|
||||
val isTie = top1 / maxOf(top2, 1e-9) < tieRatio
|
||||
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()
|
||||
// dominant-pick 보호 (최우선)
|
||||
if (cands[0].second >= ANT_DOMINANT_RATIO * cands[1].second
|
||||
&& r.ant == cands[0].first) {
|
||||
continue
|
||||
}
|
||||
|
||||
// near-field (합의보다 nfTol 이상 얕은) 후보 제외, urine 쪽(깊은)은 허용
|
||||
val eligPrelim = pool.filter { z(i, it.first) >= cz - nfTolMm }
|
||||
val elig = if (eligPrelim.isEmpty()) pool else eligPrelim
|
||||
val pick = elig.minBy { abs(z(i, it.first) - cz) }
|
||||
val cur = zOf(angles, i, r.ant)
|
||||
val curDev = abs(cur - cz)
|
||||
val bestDev = cands.minOf { abs(zOf(angles, 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) {
|
||||
r.ant = pick.first
|
||||
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 동작 매칭 — 짝수 길이면 두 가운데 값의 평균. */
|
||||
private fun median(xs: List<Double>): Double {
|
||||
if (xs.isEmpty()) return 0.0
|
||||
@@ -152,4 +422,13 @@ object MethodDRunner {
|
||||
return if (n % 2 == 1) sorted[n / 2]
|
||||
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