feat(algo): 미이식 3건 마저 이식 — post_tie_lock · sibeam · F83
레퍼런스에서 남겨 뒀던 세 가지를 옮겼다. 셋 다 레퍼런스 경로 전용이고 기존(현행
임상) 경로는 그대로다.
· post_tie_lock 후벽 상위 두 후보의 score 가 구조적으로 붙는 구간(전 데이터
5773 결정 중 격차<10% 가 17.9%)에서 직전 trace 선택을 유지한다.
방향을 강제하지 않는다 — 깊은-peak 고정·인접 골 tie-break 은
전부 계통 편향을 낳았고, tie 밴드에서는 정의상 두 후보의 증거가
같기 때문이다. 상태기를 넘길 때만 동작하며, 레퍼런스도 streaming
strict pass 에서만 건다. streaming.py 이식 전까지 호출부는 없다.
· _sibeam_cap_bounds 검출 채널이 정확히 2개일 때 미검출 이웃 빔까지의 SI 거리를
cap 상한으로. 2개뿐이면 끝 채널이 서로의 이웃이라 기존 top/bottom
clamp 가 성립하지 않아 cap 이 무제한 외삽된다. supine 은 건너뛴다.
· F83 적도-밖 bottom cap 연속 보간(실리콘·supine). 전제인
mirror_bottom_cap 도 함께 옮겼다.
좌표계가 두 개라는 것을 확인했다
레퍼런스 cross_channel._z 는 sample_to_ap_depth 를 인자 없이 불러 교차채널 좌표가
언제나 모듈 기본값(1.936/6.85)으로 떨어진다. BV 는 프리셋 값을 명시로 넘긴다.
abs 는 두 값이 같아 표가 안 나지만 실리콘(1.897/7.651)에서는 갈린다 — 실측
f06/r1 단일 cycle 에서 CH0 전벽이 8 vs 14 로 갈려 439.88 vs 416.95 mL 이 됐다.
is_deep 판정이 34.209 > 34.042 처럼 아슬아슬한 자리라 작은 좌표 차가 선택을 뒤집는다.
의도인지 누락인지는 알고리즘팀 확인이 필요하다. 확인 전까지 레퍼런스와 같은 값을
내는 쪽을 택했고, 기존 경로는 프리셋 값을 그대로 쓴다.
missing_outside 의 null 과 빈 집합을 구분했다
null = 계산 안 함 → cap clamp 를 언제나 상한으로. 빈 집합 = 계산했고 '방광 밖'
채널이 없다 → 하한 쪽으로. ifEmpty { null } 로 뭉개면 "미검출이지만 span 은 있다"는
가장 흔한 경우에 si_floor 가 통째로 죽는다.
검증
· 레퍼런스 대조 6종 전부 불일치 0 — tie 1568행(lock 24건) · 실데이터 BV 784행
(nch=2 6건) · sibeam 120행(상한 102건) · 합성 BV 640행(mirror 226건) ·
F83 blend 440행 · F83 apply 12행. 실데이터가 미러·nch=2 에 거의 안 닿아
그 경로는 합성 입력으로 레퍼런스 함수를 직접 호출한 기대값으로 덮었다.
· 기존 경로 무변경 — 44 cycle × CCC on/off 덤프가 HEAD 와 88행 전부 일치.
· 전체 36개 통과 · demoDebug APK 빌드 성공.
LegacyEquivalenceDumpTest 가 PiezoHW.activePreset 을 명시로 고정하게 했다. 전역
가변 상태라 다른 테스트가 먼저 돌면 덤프가 통째로 달라진다 — 실제로 한 번 착시를
만들었다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,11 @@ package com.medithings.vesiscan.managers
|
||||
* 기존의 `inward_post` 는 빠진다)
|
||||
* · 파이프라인 `scale_recovery`(2) · `missing_outside` 증거 산출(4) ·
|
||||
* `channel_confidence`→untrusted(5b) · `CONF_DEMOTE`(6)
|
||||
* · 좌표계 교차채널 z 를 프리셋(실리콘 1.897/7.651) 대신 **전역 캘리브레이션**
|
||||
* (1.936/6.85)으로 잰다 — 레퍼런스 `cross_channel._z` 가 그렇다.
|
||||
* abs 는 값이 같아 표가 안 나고 실리콘에서만 갈린다.
|
||||
* · nch=2 clamp `_sibeam_cap_bounds` — 채널이 2개뿐일 때 빔 기하로 cap 상한
|
||||
* · 미러 · F83 `mirror_bottom_cap` + 적도-밖 bottom cap 연속 보간(실리콘·supine)
|
||||
* · BV 극 cap 의 상한/하한을 관측 증거로 가르는 `si_floor`/`missing_outside`,
|
||||
* bottom cap 제한 신설, 중앙 채널 보간 은행가 반올림,
|
||||
* `adaptive_large_bladder_relax` 제거(cap 축 폴백을 가로막고 있었다)
|
||||
|
||||
@@ -276,7 +276,7 @@ class AnchorGuide(
|
||||
wallsCross.map { r ->
|
||||
r?.let { WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) }
|
||||
},
|
||||
crossOutcome.missingOutside.ifEmpty { null })
|
||||
crossOutcome.missingOutside)
|
||||
|
||||
// ch3 검출률은 trace 단위다. 그 김에 trace 별 nch 통계도 얻는다(진단용).
|
||||
val traces = slidingTraces(cycles, traceWin)
|
||||
|
||||
@@ -588,6 +588,63 @@ 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
|
||||
|
||||
/**
|
||||
* Python `volume/config_vol.NCH2_SIBEAM_CLAMP_NCH` / `NCH2_SIBEAM_SKIP_SUPINE`.
|
||||
*
|
||||
* 검출 채널이 **정확히 2개**일 때만 빔 기하로 cap 을 제한한다. 3개 이상이면 위쪽의
|
||||
* top/bottom 채널 clamp 가 이미 같은 일을 하므로 중복이다. supine 은 건너뛴다 —
|
||||
* 그 자세에서는 적도가 fan 아래로 내려가 "빔이 못 잡았다"가 "방광이 없다"를 뜻하지
|
||||
* 않는다(오히려 F83 이 반대 방향으로 채우는 구간이다).
|
||||
*/
|
||||
const val NCH2_SIBEAM_CLAMP_NCH = 2
|
||||
const val NCH2_SIBEAM_SKIP_SUPINE = true
|
||||
|
||||
/** Python `volume/config_vol.MIRROR_*` — 적도 미관측 bottom cap 미러링. */
|
||||
const val MIRROR_MIN_NCH = 3
|
||||
const val MIRROR_EDGE_MARGIN = 0.02
|
||||
|
||||
/**
|
||||
* (상단, 하단) cap 높이 상한 = 끝 채널에서 **미검출 이웃 빔**까지의 SI 거리.
|
||||
* Python `bv_estimation._sibeam_cap_bounds`.
|
||||
*
|
||||
* 빔이 방광을 못 잡았다는 것이 곧 "방광이 거기까지 없다"는 관측이다. 상한이 없으면
|
||||
* (미검출 이웃이 없거나 기하가 무효) null 을 돌려 clamp 를 건너뛴다.
|
||||
*/
|
||||
internal fun sibeamCapBounds(
|
||||
validChannels: List<Int>,
|
||||
sortedChannels: List<Int>,
|
||||
yS: DoubleArray,
|
||||
dAnt: List<Double>,
|
||||
dPost: List<Double>,
|
||||
degreeDeg: DoubleArray,
|
||||
sensorZMm: DoubleArray,
|
||||
): Pair<Double?, Double?> {
|
||||
val det = sortedChannels.toSet()
|
||||
val out = arrayOfNulls<Double>(2)
|
||||
// (끝 index, 부호) — top 은 위쪽(+), bottom 은 아래쪽(−) 이웃까지의 거리를 본다.
|
||||
val ends = listOf(sortedChannels.size - 1 to 1.0, 0 to -1.0)
|
||||
for ((k, pair) in ends.withIndex()) {
|
||||
val (idx, sign) = pair
|
||||
val ch = sortedChannels[idx]
|
||||
val yE = yS[idx]
|
||||
val i = validChannels.indexOf(ch)
|
||||
if (i < 0) continue
|
||||
val dMid = (dAnt[i] + dPost[i]) / 2.0
|
||||
val z0 = dMid * cos(Math.toRadians(degreeDeg[ch]))
|
||||
var best: Double? = null
|
||||
for (nb in 0 until minOf(4, degreeDeg.size)) {
|
||||
if (nb in det) continue
|
||||
val c = cos(Math.toRadians(degreeDeg[nb]))
|
||||
if (abs(c) <= 1e-6) continue
|
||||
val yB = sensorZMm[nb] + (z0 / c) * sin(Math.toRadians(degreeDeg[nb]))
|
||||
val dy = (yB - yE) * sign
|
||||
if (dy > 0) best = if (best == null) dy else minOf(best, dy)
|
||||
}
|
||||
out[k] = best
|
||||
}
|
||||
return Pair(out[0], out[1])
|
||||
}
|
||||
|
||||
/**
|
||||
* Python `volume/config_vol.SI_FLOOR_WALK` — span 이 이어지는 가장 바깥 채널까지 하한을
|
||||
* 확장할지. 인접 채널에서 멈추지 않고 걸어간다. **레퍼런스 경로 전용**.
|
||||
@@ -690,6 +747,8 @@ fun estimateBladderVolume6ch(
|
||||
ellipseCapHeight: Boolean = true,
|
||||
missingOutside: Set<Int>? = null,
|
||||
siFloor: Boolean = true,
|
||||
supine: Boolean = false,
|
||||
mirrorBottomCap: Boolean = false,
|
||||
): BVResult? {
|
||||
var centerWalls = PiezoHW.centerCh.map { if (it < allWalls.size) allWalls[it] else null }
|
||||
centerWalls = repairCenterWallsFor6ch(centerWalls)
|
||||
@@ -708,6 +767,8 @@ fun estimateBladderVolume6ch(
|
||||
ellipseCapHeight = ellipseCapHeight,
|
||||
missingOutside = missingOutside,
|
||||
siFloor = siFloor,
|
||||
supine = supine,
|
||||
mirrorBottomCap = mirrorBottomCap,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -789,6 +850,8 @@ fun estimateBv(
|
||||
/** [estimateBladderVolume] 참조 — 검출기가 산출한 '진짜 방광 밖' 채널 집합. */
|
||||
missingOutside: Set<Int>? = null,
|
||||
siFloor: Boolean = true,
|
||||
supine: Boolean = false,
|
||||
mirrorBottomCap: Boolean = false,
|
||||
): BVResult? {
|
||||
// ── 레퍼런스 경로: 완화 없이 한 번 계산하고 cap 축 폴백만 건다 ────────────
|
||||
// "끝단 단면이 넓고 cap 이 얕으면 urine_inset 0.05 + b_si_floor 0.85 로 재계산"
|
||||
@@ -798,8 +861,12 @@ fun estimateBv(
|
||||
// 실측 PHANTOM mean: Python 160.03 mL vs 완화가 살아 있는 코틀린 232.49 mL.
|
||||
if (AlgoMode.reference) {
|
||||
val r = evalOnce(walls, lrRatio, urineInsetFrac, bSiFloorRatio, bSiFloorEdgeMin,
|
||||
missingOutside = missingOutside, siFloor = siFloor)
|
||||
return capAxisFallback(r, walls, lrRatio, urineInsetFrac, bSiFloorRatio, bSiFloorEdgeMin)
|
||||
missingOutside = missingOutside, siFloor = siFloor,
|
||||
supine = supine, mirrorBottomCap = mirrorBottomCap)
|
||||
// 폴백에는 자세·미러를 **넘긴다**(레퍼런스가 그렇게 부른다). missingOutside·
|
||||
// siFloor 는 반대로 넘기지 않는다 — 그쪽도 레퍼런스가 그렇게 부른다.
|
||||
return capAxisFallback(r, walls, lrRatio, urineInsetFrac, bSiFloorRatio,
|
||||
bSiFloorEdgeMin, supine, mirrorBottomCap)
|
||||
}
|
||||
|
||||
val result = evalOnce(walls, lrRatio, urineInsetFrac, bSiFloorRatio, bSiFloorEdgeMin)
|
||||
@@ -869,6 +936,8 @@ private fun capAxisFallback(
|
||||
urineInsetFrac: Double,
|
||||
bSiFloorRatio: Double?,
|
||||
bSiFloorEdgeMin: Double,
|
||||
supine: Boolean = false,
|
||||
mirrorBottomCap: Boolean = false,
|
||||
): BVResult? {
|
||||
if (result == null) return null
|
||||
val bSi = result.capBSiMm
|
||||
@@ -882,7 +951,7 @@ private fun capAxisFallback(
|
||||
// 떨어진다. 실측 f04 cyc20: 넘기면 249.95 mL, 안 넘기면 278.43 mL 로 후자가
|
||||
// 레퍼런스 값이다.
|
||||
val fb = evalOnce(walls, lrRatio, urineInsetFrac, bSiFloorRatio, bSiFloorEdgeMin,
|
||||
ellipseCapHeight = false)
|
||||
ellipseCapHeight = false, supine = supine, mirrorBottomCap = mirrorBottomCap)
|
||||
return if (fb != null && fb.volumeMl != 0.0) fb else result
|
||||
}
|
||||
|
||||
@@ -896,6 +965,8 @@ private fun evalOnce(
|
||||
ellipseCapHeight: Boolean = true,
|
||||
missingOutside: Set<Int>? = null,
|
||||
siFloor: Boolean = true,
|
||||
supine: Boolean = false,
|
||||
mirrorBottomCap: Boolean = false,
|
||||
): BVResult? {
|
||||
val insetWalls: List<Pair<Double, Double>?> = walls.map { w ->
|
||||
w?.let {
|
||||
@@ -909,7 +980,7 @@ private fun evalOnce(
|
||||
com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride = lrRatio
|
||||
return try {
|
||||
estimateBladderVolume6ch(insetWalls, bSiFloorRatio, bSiFloorEdgeMin, ellipseCapHeight,
|
||||
missingOutside, siFloor)
|
||||
missingOutside, siFloor, supine, mirrorBottomCap)
|
||||
} finally {
|
||||
com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride = prevOverride
|
||||
}
|
||||
@@ -976,6 +1047,14 @@ fun estimateBladderVolume(
|
||||
* null 이면 극 cap clamp 를 언제나 상한으로 건다(기존 동작).
|
||||
*/
|
||||
missingOutside: Set<Int>? = null,
|
||||
/** Python `supine`(IMU tilt < 25°). nch=2 빔 clamp 와 F83 이 자세를 본다. */
|
||||
supine: Boolean = false,
|
||||
/**
|
||||
* Python `mirror_bottom_cap`. 적도가 배열 fan 아래에 있어 관측 단면이 하단까지
|
||||
* 계속 넓어지는 램프일 때 bottom cap 을 상단 전체 높이로 **미러링**한다.
|
||||
* F83 이 base/mirror 두 값을 얻으려고 명시로 켠다 — 기존 경로는 켜지 않는다.
|
||||
*/
|
||||
mirrorBottomCap: Boolean = false,
|
||||
/**
|
||||
* Python `COND_CLAMP == 'si'`. 켜져 있으면 미검출 채널에 span 이 있을 때 같은
|
||||
* 지점을 **하한**으로 쓴다. 레퍼런스 경로에서만 의미가 있다.
|
||||
@@ -1313,9 +1392,9 @@ fun estimateBladderVolume(
|
||||
val vCore = vFrustum.sum()
|
||||
|
||||
// 9) Caps — Bottom: spherical cap (shrink 시 R_eff 공유), Top: cone
|
||||
val vBottom: Double
|
||||
var vBottom: Double
|
||||
val vTop: Double
|
||||
val bottomKind: String
|
||||
var bottomKind: String
|
||||
val topKind: String
|
||||
if (rEffCap != null) {
|
||||
vBottom = sphericalCapVolume(rEffCap, hCapBot, lrRatio)
|
||||
@@ -1333,12 +1412,43 @@ fun estimateBladderVolume(
|
||||
}
|
||||
hCapBot = applyCapDAdaptive(hCapBot, capDAdaptive)
|
||||
hCapTop = applyCapDAdaptive(hCapTop, capDAdaptive)
|
||||
// nch=2 전용 빔 기하 상한 — 채널이 2개뿐이면 위쪽 top/bottom 채널 clamp 가
|
||||
// 성립하지 않아(끝 채널이 곧 서로의 이웃) cap 이 무제한으로 외삽된다.
|
||||
// 기존 경로에는 이 제약이 없었으므로 레퍼런스에서만 건다.
|
||||
if (AlgoMode.reference && n == NCH2_SIBEAM_CLAMP_NCH &&
|
||||
!(NCH2_SIBEAM_SKIP_SUPINE && supine)) {
|
||||
val (bt, bb) = sibeamCapBounds(
|
||||
validChannels, sortedCh, yS, dAnt, dPost, degreeDeg, sensorZMm)
|
||||
if (bt != null) hCapTop = minOf(hCapTop, maxOf(0.0, bt))
|
||||
if (bb != null) hCapBot = minOf(hCapBot, maxOf(0.0, bb))
|
||||
}
|
||||
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"
|
||||
}
|
||||
|
||||
// ── 적도 미관측 bottom cap 미러링 ────────────────────────────────────
|
||||
// supine 큰 방광은 적도가 배열 fan 아래에 놓여, 관측 단면이 하단 edge 까지 계속
|
||||
// 넓어지는 램프가 된다. cap 높이 상한이 천장을 만들어 미관측 caudal 반쪽을 못 채운다.
|
||||
// **vTop 은 건드리지 않는다** — 레퍼런스 검증 A/B 가 vTop 고정 조건에서 이뤄졌다.
|
||||
// `mirrorBottomCap` 은 F83 만 켜므로 기존 경로에서는 이 블록이 돌지 않는다.
|
||||
if (mirrorBottomCap && n >= MIRROR_MIN_NCH && sortedCh[0] == 3) {
|
||||
val monotone = (1 until n).all { aCapS[it] - aCapS[it - 1] < 0.0 }
|
||||
if (monotone && aCapS[0] > 0 &&
|
||||
(aCapS[0] - aCapS[1]) / aCapS[0] >= MIRROR_EDGE_MARGIN) {
|
||||
val hMirror = (yS[n - 1] - yS[0]) + hCapTop
|
||||
if (hMirror > 1e-6) {
|
||||
val aB = aCapS[0]
|
||||
hCapBot = hMirror
|
||||
// 밑면 반경 aB · 높이 h 인 구면캡과 일관된 R (h>aB 면 R>aB → 밑면 아래가 넓어진다)
|
||||
rEffCap = (aB * aB + hCapBot * hCapBot) / (2.0 * hCapBot)
|
||||
vBottom = sphericalCapVolume(rEffCap!!, hCapBot, lrRatio)
|
||||
bottomKind = "mirror sphere"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 10) 합산
|
||||
val bvMm3 = vCore + vBottom + vTop
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
* F83 — 적도-밖 bottom cap 연속 보간. Python `volume/f83.py` 1:1.
|
||||
*/
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import com.medithings.vesiscan.walldetect.MethodDResult
|
||||
import com.medithings.vesiscan.walldetect.MethodDRunner
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* F83 — 적도-밖 bottom cap **연속 보간** (실리콘 · supine 전용).
|
||||
*
|
||||
* ## 문제
|
||||
* supine 큰 방광은 적도가 배열 fan 아래에 놓여, 관측 단면이 하단 edge 까지 계속 넓어지는
|
||||
* 램프가 된다. cap 높이 상한이 천장을 만들어 미관측 caudal 반쪽을 못 채운다.
|
||||
* hard mirror([estimateBv] 의 `mirrorBottomCap`)는 이걸 해결하지만 **측정 안에서 부분
|
||||
* 발동**해 rel 분포를 쪼갰고, 그 결과 선택위치 KPI 를 깎았다.
|
||||
*
|
||||
* ## 해법
|
||||
* trace 내부 5-cycle window 6개로 **합의 강도를 재서 cap 높이를 연속 보간**한다.
|
||||
* 과거 trace 도 정답 BV 도 쓰지 않는다(인과적). gate 통과율이 5/6 이상이면 hard mirror,
|
||||
* 그 외에는 gate·형상·CH3 안정성의 곱으로 weight 를 만들어 base↔mirror 를 보간한다.
|
||||
*
|
||||
* ⚠ demo-final 에서는 **레퍼런스 경로 전용**이다. 미러 블록이 전제인데 기존
|
||||
* 경로는 `mirrorBottomCap` 을 켜지 않으므로 게이트가 통과하지 않는다.
|
||||
*
|
||||
* ⚠ 이 경로는 sub-window 마다 **검출부터 다시 돌린다**. 순수 부피 계산이 아니라
|
||||
* 파이프라인 전체가 필요하므로 `PiezoBVEstimator.kt` 가 아니라 여기에 둔다.
|
||||
*/
|
||||
object PiezoBVF83 {
|
||||
|
||||
/** 현재 10-cycle trace 안에서 쓰는 sub-window 길이. */
|
||||
const val SUB_WIN = 5
|
||||
|
||||
/** 이 비율 이상 gate 를 통과하면 보간 없이 hard mirror. */
|
||||
const val FORCE_RATE = 5.0 / 6.0
|
||||
|
||||
/**
|
||||
* abs(v1/v2) 기기에서는 **순손실**이라 실리콘에서만 건다.
|
||||
* Python `F83_SILICON_ONLY`.
|
||||
*/
|
||||
const val SILICON_ONLY = true
|
||||
|
||||
private fun isSilicon(): Boolean = PiezoHW.activePreset.let {
|
||||
it == PiezoHW.DevicePreset.R100 || it == PiezoHW.DevicePreset.R200 ||
|
||||
it == PiezoHW.DevicePreset.R300
|
||||
}
|
||||
|
||||
private fun ramp(x: Double, lo: Double, hi: Double): Double =
|
||||
((x - lo) / (hi - lo)).coerceIn(0.0, 1.0)
|
||||
|
||||
/** 구면캡 부피(기하) — 보간 가중치를 **높이**가 아니라 부피로 환산할 때 쓴다. */
|
||||
private fun capGeom(a: Double, h: Double): Double =
|
||||
Math.PI * h * (3.0 * a * a + h * h) / 6.0
|
||||
|
||||
/** mirror 가 실제로 발동했는가 = bottom cap 이 미러 구면으로 바뀌었는가. */
|
||||
private fun gateOk(bv: BVResult?): Boolean = bv != null && bv.bottomKind == "mirror sphere"
|
||||
|
||||
/** CH3 단면 직경(mm). 미검출이면 NaN. */
|
||||
private fun ch3D(results: List<MethodDResult?>): Double {
|
||||
val r = results.getOrNull(3) ?: return Double.NaN
|
||||
return (r.post - r.ant) * PiezoHW.distancePerSample *
|
||||
cos(Math.toRadians(PiezoHW.degreeAll[3]))
|
||||
}
|
||||
|
||||
/**
|
||||
* base 와 mirror 의 **bottom cap 만** 높이-기하로 보간한 일관된 결과.
|
||||
*
|
||||
* 높이를 선형 보간한 뒤, 그 높이에 해당하는 **구면캡 부피 비율**로 부피를 섞는다.
|
||||
* 부피를 바로 선형 보간하면 높이와 부피가 서로 안 맞는 결과가 나온다.
|
||||
*/
|
||||
internal fun blend(base: BVResult?, mirror: BVResult?, weight: Double): BVResult? {
|
||||
if (base == null || mirror == null || !gateOk(mirror) || weight <= 0.0) return base
|
||||
if (weight >= 1.0) return mirror
|
||||
val h0 = base.bottomHMm
|
||||
val h1 = mirror.bottomHMm
|
||||
if (h1 <= h0 + 1e-9) return base
|
||||
if (base.sortedYMm.isEmpty()) return base
|
||||
var argmin = 0
|
||||
for (i in base.sortedYMm.indices) if (base.sortedYMm[i] < base.sortedYMm[argmin]) argmin = i
|
||||
val bottomCh = base.sortedChannels[argmin]
|
||||
val vi = base.validChannels.indexOf(bottomCh)
|
||||
if (vi < 0) return base
|
||||
val a = base.dMm[vi] / 2.0
|
||||
val hb = h0 + weight * (h1 - h0)
|
||||
val den = capGeom(a, h1) - capGeom(a, h0)
|
||||
val frac = (if (abs(den) > 1e-9) (capGeom(a, hb) - capGeom(a, h0)) / den else weight)
|
||||
.coerceIn(0.0, 1.0)
|
||||
val vb = base.vBottomMm3 + frac * (mirror.vBottomMm3 - base.vBottomMm3)
|
||||
val total = base.vCoreMm3 + vb + base.vTopMm3
|
||||
return base.copy(
|
||||
volumeMm3 = total,
|
||||
volumeMl = total / 1000.0,
|
||||
vBottomMm3 = vb,
|
||||
bottomHMm = hb,
|
||||
bottomKind = "f83 soft mirror",
|
||||
)
|
||||
}
|
||||
|
||||
/** F83 산출 부산물 — 부피에 영향이 없고 진단·기록용이다. */
|
||||
data class F83Diag(
|
||||
val weight: Double,
|
||||
val gateRate: Double,
|
||||
val shape: Double,
|
||||
val d3Cv: Double,
|
||||
)
|
||||
|
||||
/** [apply] 의 결과 + 진단. */
|
||||
data class F83Result(val bv: BVResult?, val diag: F83Diag?)
|
||||
|
||||
/**
|
||||
* 이미 구한 [walls]·[base] 에 F83 bottom cap 보간을 적용한다.
|
||||
* Python `f83_apply`.
|
||||
*
|
||||
* @param cycleBlock (cycle, channel, sample) — 현재 trace 를 이룬 원본 cycle 들.
|
||||
* sub-window 재검출에 쓴다.
|
||||
*/
|
||||
fun apply(
|
||||
walls: List<WallWithSpan?>,
|
||||
cycleBlock: List<List<DoubleArray>>,
|
||||
supine: Boolean,
|
||||
base: BVResult?,
|
||||
missingOutside: Set<Int>? = null,
|
||||
/**
|
||||
* 호출부 walls 의 `si_floor` 태그. 검출기에서 온 walls 면 true, 합성 wall 이면
|
||||
* false 다. **sub-window 쪽은 여기서 다시 검출하므로 항상 true** 라 따로 받지 않는다.
|
||||
*/
|
||||
siFloor: Boolean = true,
|
||||
): F83Result {
|
||||
val b = base ?: estimateBv(walls, missingOutside = missingOutside,
|
||||
siFloor = siFloor, supine = supine)
|
||||
if (!supine || b == null) return F83Result(b, null)
|
||||
if (SILICON_ONLY && !isSilicon()) return F83Result(b, null) // abs 는 순손실
|
||||
|
||||
val mirror = estimateBv(walls, missingOutside = missingOutside, siFloor = siFloor,
|
||||
supine = supine, mirrorBottomCap = true)
|
||||
if (!gateOk(mirror)) return F83Result(b, null)
|
||||
|
||||
val win = minOf(SUB_WIN, cycleBlock.size)
|
||||
val subGate = ArrayList<Boolean>()
|
||||
val shapes = ArrayList<Double>()
|
||||
val d3s = ArrayList<Double>()
|
||||
for (start in 0..(cycleBlock.size - win)) {
|
||||
val trace = meanOf(cycleBlock.subList(start, start + win))
|
||||
val outcome = MethodDRunner.detectMultichannelWithEvidence(trace, applyCross = true)
|
||||
val subWalls = outcome.walls.map { r ->
|
||||
r?.let { WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) }
|
||||
}
|
||||
val bv = estimateBv(subWalls,
|
||||
missingOutside = outcome.missingOutside,
|
||||
mirrorBottomCap = true)
|
||||
subGate.add(gateOk(bv))
|
||||
d3s.add(ch3D(outcome.walls))
|
||||
if (bv != null && 3 in bv.validChannels && bv.sortedYMm.size >= 2) {
|
||||
val d3 = bv.dMm[bv.validChannels.indexOf(3)]
|
||||
if (d3 > 0) shapes.add((bv.sortedYMm.max() - bv.sortedYMm.min()) / d3)
|
||||
}
|
||||
}
|
||||
|
||||
val gateRate = if (subGate.isEmpty()) 0.0 else subGate.count { it }.toDouble() / subGate.size
|
||||
val shape = if (shapes.isEmpty()) 0.0 else median(shapes.toDoubleArray())
|
||||
val d3v = d3s.filter { it.isFinite() && it > 0 }.toDoubleArray()
|
||||
val d3Cv = if (d3v.size >= 2) stdPop(d3v) / d3v.average() else 1.0
|
||||
|
||||
var weight = ramp(gateRate, 0.2, 0.8) *
|
||||
ramp(shape, 0.35, 0.65) *
|
||||
(1.0 - ramp(d3Cv, 0.02, 0.06))
|
||||
if (gateRate >= FORCE_RATE) weight = 1.0
|
||||
|
||||
return F83Result(blend(b, mirror, weight), F83Diag(weight, gateRate, shape, d3Cv))
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 trace 의 cycle 들로 F83_100 BV 를 계산한다. Python `estimate_bv_f83_100`.
|
||||
*
|
||||
* [fullResults] 를 주면 평균 trace 재검출을 건너뛴다.
|
||||
*/
|
||||
fun estimate(
|
||||
cycleBlock: List<List<DoubleArray>>,
|
||||
supine: Boolean,
|
||||
fullResults: List<MethodDResult?>? = null,
|
||||
fullMissingOutside: Set<Int>? = null,
|
||||
): F83Result {
|
||||
require(cycleBlock.isNotEmpty()) { "cycleBlock 이 비어 있다" }
|
||||
var mo = fullMissingOutside
|
||||
val results = fullResults ?: run {
|
||||
val o = MethodDRunner.detectMultichannelWithEvidence(
|
||||
meanOf(cycleBlock), applyCross = true)
|
||||
mo = o.missingOutside
|
||||
o.walls
|
||||
}
|
||||
val walls = results.map { r ->
|
||||
r?.let { WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) }
|
||||
}
|
||||
val base = estimateBv(walls, missingOutside = mo, supine = supine)
|
||||
return apply(walls, cycleBlock, supine, base, mo)
|
||||
}
|
||||
|
||||
/** cycle 들의 채널별 평균 — Python `block.mean(axis=0)`. */
|
||||
private fun meanOf(cycles: List<List<DoubleArray>>): List<DoubleArray> {
|
||||
val nCh = cycles[0].size
|
||||
return (0 until nCh).map { ch ->
|
||||
val len = cycles[0][ch].size
|
||||
val acc = DoubleArray(len)
|
||||
for (c in cycles) for (i in 0 until minOf(len, c[ch].size)) acc[i] += c[ch][i]
|
||||
DoubleArray(len) { acc[it] / cycles.size }
|
||||
}
|
||||
}
|
||||
|
||||
private fun median(a: DoubleArray): Double {
|
||||
val s = a.sortedArray()
|
||||
val n = s.size
|
||||
if (n == 0) return 0.0
|
||||
return if (n % 2 == 1) s[n / 2] else (s[n / 2 - 1] + s[n / 2]) / 2.0
|
||||
}
|
||||
|
||||
/** 모집단 표준편차 — numpy `std()` 기본(ddof=0)과 같다. */
|
||||
private fun stdPop(a: DoubleArray): Double {
|
||||
val m = a.average()
|
||||
var acc = 0.0
|
||||
for (x in a) acc += (x - m) * (x - m)
|
||||
return sqrt(acc / a.size)
|
||||
}
|
||||
}
|
||||
@@ -146,7 +146,7 @@ fun ClinicalLiveView(appState: AppState) {
|
||||
// `missingOutside` 는 극 cap clamp 의 방향(상한/하한)을 가르는 관측 증거다.
|
||||
// 기존 경로에서는 검출기가 빈 집합을 주므로 null 로 넘겨 종전과 같이 동작한다.
|
||||
bvMethodD = com.medithings.vesiscan.managers.estimateBv(
|
||||
mdWalls, missingOutside = mdOutcome.missingOutside.ifEmpty { null })
|
||||
mdWalls, missingOutside = mdOutcome.missingOutside)
|
||||
?.volumeMl?.takeIf { it.isFinite() && it > 0 }
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -374,7 +374,7 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
.detectMultichannelWithEvidence(signals)
|
||||
val dets = outcome.walls
|
||||
methodDDets = dets // BV wrapper 경로에서 refined+span 재사용
|
||||
methodDMissingOutside = outcome.missingOutside.ifEmpty { null }
|
||||
methodDMissingOutside = outcome.missingOutside
|
||||
for (i in 0..5) {
|
||||
val r = dets.getOrNull(i)
|
||||
if (r != null) allWalls[i] = Pair(r.ant, r.post)
|
||||
@@ -452,7 +452,7 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
.detectMultichannelWithEvidence(signals)
|
||||
val dets = outcome.walls
|
||||
methodDDets = dets
|
||||
methodDMissingOutside = outcome.missingOutside.ifEmpty { null }
|
||||
methodDMissingOutside = outcome.missingOutside
|
||||
dets.map { r -> r?.let { Pair(it.ant, it.post) } }
|
||||
}
|
||||
} else allWalls
|
||||
|
||||
@@ -49,11 +49,17 @@ data class MethodDResult(
|
||||
/** 초기 ant 후보 [(idx, score), ...] score 내림차순 — 02bed02 cross-channel tie-break 용.
|
||||
* recovery 전 wall_select 결과를 그대로 노출 (Python: detector.py `ant_candidates`). */
|
||||
val antCandidates: List<Pair<Int, Double>> = emptyList(),
|
||||
/**
|
||||
* 초기 post 후보 [(idx, score), ...] score 내림차순 (Python `post_candidates`).
|
||||
* 후벽 near-tie 판정([PostTieLock])에 쓴다.
|
||||
*/
|
||||
val postCandidates: List<Pair<Int, Double>> = emptyList(),
|
||||
)
|
||||
|
||||
object MethodDDetector {
|
||||
|
||||
private fun wallGateOk(antAmp: Double, postAmp: Double, lumenMin: Double, p: MethodDParams): Boolean {
|
||||
/** Python `detector._wall_gate_ok`. [PostTieLock] 이 되돌린 뒤 재검증에 쓴다. */
|
||||
fun wallGateOk(antAmp: Double, postAmp: Double, lumenMin: Double, p: MethodDParams): Boolean {
|
||||
val wallAmp = if (p.wallRatioUsePostOnly) postAmp else kotlin.math.min(antAmp, postAmp)
|
||||
return wallAmp / max(lumenMin, 1.0) >= p.minWallLumenRatio
|
||||
}
|
||||
@@ -98,6 +104,8 @@ object MethodDDetector {
|
||||
// Python: tuple((int(c[0]), float(c[4])) for c in sorted(ant_res['candidates'], key=-x[4]))
|
||||
val antCandidates: List<Pair<Int, Double>> =
|
||||
antRes.candidates.sortedByDescending { it.score }.map { it.idx to it.score }
|
||||
val postCandidates: List<Pair<Int, Double>> =
|
||||
postRes.candidates.sortedByDescending { it.score }.map { it.idx to it.score }
|
||||
|
||||
var antIdx = antRes.best.idx
|
||||
var antProm = antRes.best.prom
|
||||
@@ -215,6 +223,7 @@ object MethodDDetector {
|
||||
sgHeavy = sgHeavy,
|
||||
sgLight = sgLight,
|
||||
antCandidates = antCandidates,
|
||||
postCandidates = postCandidates,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ object MethodDRunner {
|
||||
/** dominant-pick 보호: raw 픽 score >= 이 배수 × 차순위 → 합의 교정에서 보존. */
|
||||
private const val ANT_DOMINANT_RATIO = 2.0
|
||||
|
||||
// 레퍼런스 경로의 교차채널 z 좌표용 전역 캘리브레이션. [zAt] 주석 참조.
|
||||
private const val CCC_DISTANCE_PER_SAMPLE = 1.936
|
||||
private const val CCC_DELAY_OFFSET_MM = 6.85
|
||||
|
||||
/** neighbor_top_validate: nb-1→nb slope 가 이 이하면 "비증가 추세". */
|
||||
private const val NTV_SLOPE_EPS = 2.0
|
||||
/** neighbor_top_validate: top 이 예측보다 이만큼 깊으면 위반 (FP). */
|
||||
@@ -97,8 +101,9 @@ object MethodDRunner {
|
||||
beamAnglesDeg: DoubleArray? = null,
|
||||
applyTgc: Boolean = true,
|
||||
applyCross: Boolean = true,
|
||||
tieLock: PostTieLock? = null,
|
||||
): List<MethodDResult?> = detectMultichannelWithEvidence(
|
||||
signals, params, beamAnglesDeg, applyTgc, applyCross).walls
|
||||
signals, params, beamAnglesDeg, applyTgc, applyCross, tieLock).walls
|
||||
|
||||
fun detectMultichannelWithEvidence(
|
||||
signals: List<DoubleArray>,
|
||||
@@ -106,6 +111,11 @@ object MethodDRunner {
|
||||
beamAnglesDeg: DoubleArray? = null,
|
||||
applyTgc: Boolean = true,
|
||||
applyCross: Boolean = true,
|
||||
/**
|
||||
* 후벽 near-tie trace lock 상태기. **측정 하나마다** 새로 만들어 trace 를 순서대로
|
||||
* 넘겨야 의미가 있다. null 이면 미적용 — 기존 경로 호출부는 넘기지 않는다.
|
||||
*/
|
||||
tieLock: PostTieLock? = null,
|
||||
): DetectionOutcome {
|
||||
val angles = beamAnglesDeg ?: PiezoHW.degreeAll
|
||||
// 1) per-channel SG denoise (heavy + light)
|
||||
@@ -128,6 +138,11 @@ object MethodDRunner {
|
||||
}
|
||||
val ref = com.medithings.vesiscan.managers.AlgoMode.reference
|
||||
|
||||
// ── 3) 후벽 near-tie trace lock ───────────────────────────────────
|
||||
// 검출 뒤·교차채널 앞. 상태기를 넘겨야 동작하므로 넘기지 않는 기존 호출부는
|
||||
// 아무 영향이 없다.
|
||||
tieLock?.apply(results, params)
|
||||
|
||||
// ── 2) 검출 실패 채널만 SG5 로 재탐색 (레퍼런스 경로 전용) ─────────────
|
||||
// 이미 검출된 채널은 건드리지 않는다 — 얻는 것은 커버리지뿐이고 측정면은 불변.
|
||||
if (ref && SCALE_RECOVERY && applyCross) {
|
||||
@@ -136,9 +151,12 @@ object MethodDRunner {
|
||||
|
||||
// ── 4) 미검출 채널의 span 유무 판정 (레퍼런스 경로 전용) ───────────────
|
||||
// **CCC 앞에서** 계산한다 — CCC 가 복구한 채널은 '방광 밖'이 아니다.
|
||||
val missingOut: Set<Int> =
|
||||
// **null 과 빈 집합은 다르다.** null = 계산 안 함 → cap clamp 를 언제나 상한으로.
|
||||
// 빈 집합 = 계산했고 '방광 밖' 채널이 없다 → 미검출 채널은 전부 span 이 있다는
|
||||
// 뜻이라 하한 쪽으로 간다. 뭉개면 si_floor 가 가장 흔한 경우에 통째로 죽는다.
|
||||
val missingOut: Set<Int>? =
|
||||
if (ref && applyCross) missingOutside(results, lightTgc, heavyTgc, angles, params)
|
||||
else emptySet()
|
||||
else null
|
||||
|
||||
// ── 5) 교차채널 보정 ───────────────────────────────────────────────
|
||||
if (applyCross) {
|
||||
@@ -177,7 +195,7 @@ object MethodDRunner {
|
||||
*/
|
||||
data class DetectionOutcome(
|
||||
val walls: List<MethodDResult?>,
|
||||
val missingOutside: Set<Int>,
|
||||
val missingOutside: Set<Int>?,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -554,11 +572,12 @@ object MethodDRunner {
|
||||
// 공통 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))
|
||||
}
|
||||
/**
|
||||
* z(i, idx) — `sample_to_ap_depth`. 좌표계 선택은 [zAt] 이 한다(모드에 따라 갈린다).
|
||||
* 여기서 따로 계산하면 `ant_tiebreak` 만 다른 좌표를 쓰게 된다.
|
||||
*/
|
||||
private fun zOf(angles: DoubleArray, ch: Int, idx: Int): Double =
|
||||
zAt(if (ch < angles.size) angles[ch] else 0.0, idx)
|
||||
|
||||
/** numpy.median 동작 매칭 — 짝수 길이면 두 가운데 값의 평균. */
|
||||
private fun median(xs: List<Double>): Double {
|
||||
@@ -580,9 +599,23 @@ object MethodDRunner {
|
||||
|
||||
// ═══ 레퍼런스 경로 전용 ([AlgoMode.reference]) ═══════════════════
|
||||
|
||||
/** z(angle, idx) — angle 을 직접 받는 sample_to_ap_depth. vdipDeepCandidate 재사용. */
|
||||
/**
|
||||
* z(angle, idx) — angle 을 직접 받는 `sample_to_ap_depth`.
|
||||
*
|
||||
* ⚠ 레퍼런스 경로에서는 **프리셋이 아니라 전역 캘리브레이션 상수**를 쓴다.
|
||||
* `wall_correction/cross_channel._z` 가 `sample_to_ap_depth(idx, angle)` 를 인자 없이
|
||||
* 불러 모듈 기본값(1.936 / 6.85)으로 떨어지기 때문이다. BV 쪽은 프리셋 값을 명시로
|
||||
* 넘기므로 두 좌표계가 서로 다르다 — abs(v*)는 값이 같아 표가 안 나지만 실리콘
|
||||
* (r*: 1.897/7.651)에서는 갈린다. 실측 f06/r1 단일 cycle 에서 CH0 전벽이 8 vs 14 로
|
||||
* 갈려 부피가 439.88 vs 416.95 mL 이 됐다.
|
||||
*
|
||||
* 기존 경로는 프리셋 값을 그대로 쓴다 — 오늘 임상이 그 위에서 돌았다.
|
||||
*/
|
||||
private fun zAt(angleDeg: Double, idx: Int): Double =
|
||||
(PiezoHW.delayOffsetMm + idx * PiezoHW.distancePerSample) * cos(Math.toRadians(angleDeg))
|
||||
if (com.medithings.vesiscan.managers.AlgoMode.reference)
|
||||
(CCC_DELAY_OFFSET_MM + idx * CCC_DISTANCE_PER_SAMPLE) * cos(Math.toRadians(angleDeg))
|
||||
else
|
||||
(PiezoHW.delayOffsetMm + idx * PiezoHW.distancePerSample) * cos(Math.toRadians(angleDeg))
|
||||
|
||||
/** numpy.median 동작 매칭 — 짝수 길이면 두 가운데 값의 평균. */
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* 후벽 near-tie 인과적 trace lock — Python `wall_correction/post_tie_lock.py` 1:1.
|
||||
*/
|
||||
package com.medithings.vesiscan.walldetect
|
||||
|
||||
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDParams
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* 후벽 near-tie 구간에서만 **직전 trace 의 선택을 유지**하는 채널별 상태기.
|
||||
*
|
||||
* ## 무엇이 문제인가
|
||||
* 후벽 후보 score = `prom^γ / (1 + dist_decay·dist)` 의 두 항이 구조적으로 상쇄되는
|
||||
* 형상이 있다. 레퍼런스 실측 p2_2/v2/supine/1cm CH1: 후보 63/65 에서 65 는 prominence 가
|
||||
* 8.8% 크지만(γ=1.5 → +13.5%) span edge 에서 2 sample 더 멀어 거리 패널티가 −13.3% —
|
||||
* 격차가 **0.02%** 까지 붙는다. 그 상태에서 idx 63 진폭이 trace 간 +13 ADC(+1.0%) 흔들린
|
||||
* 것만으로 선택이 뒤집히고, 그 2 sample 이 교차채널 Rule B 의 기울기 부호까지 바꿔
|
||||
* CH0 삭제/유지를 가른다.
|
||||
*
|
||||
* 전 데이터 5773 결정 기준: 상위 1·2위 격차 <5% 인 결정이 8.7%, <10% 가 17.9%
|
||||
* (중앙값 격차 36.4%). **토글 채널의 측정내 최소격차 중앙값 9.2% vs 비토글 49.6%** —
|
||||
* tie 가 토글의 5배 강한 예측자다. 토글 채널 수와 측정 bv_cv 도 함께 움직인다
|
||||
* (0~1개 6.2~6.8% → 3개 10.7%).
|
||||
*
|
||||
* ## 왜 '고정'이지 '보정'이 아닌가
|
||||
* 같은 문제에 방향을 강제한 앞선 시도들은 전부 실패했다 — 깊은-peak 고정은 전역 회귀,
|
||||
* 인접 골 tie-break 는 항상 얕은 쪽을 골라 계통 과소추정을 되살렸다(abs −2.3~−4.8pp).
|
||||
* tie 밴드에서는 **정의상 두 후보의 증거가 거의 같다.** 그러니 어느 쪽이 옳은지 정하지
|
||||
* 말고 직전 선택을 유지하면, 기대값 상 깊은/얕은 어느 쪽으로도 편향되지 않으면서
|
||||
* 토글만 사라진다. 과거만 참조하므로 실시간 경로에도 쓸 수 있다.
|
||||
*
|
||||
* ## 쓰는 법
|
||||
* 측정(위치) 하나마다 인스턴스를 새로 만들고, trace 를 **순서대로** 넘긴다.
|
||||
* 검출 **뒤**·교차채널 **앞**에 걸린다.
|
||||
* ```
|
||||
* val lock = PostTieLock()
|
||||
* for (trace in traces) {
|
||||
* MethodDRunner.detectMultichannelWithEvidence(trace, tieLock = lock)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
class PostTieLock(
|
||||
private val tieMargin: Double = TIE_MARGIN,
|
||||
private val maxSep: Int = MAX_LOCK_SEP,
|
||||
) {
|
||||
/** 채널 → 직전 trace 에서 최종 채택된 후벽 index. */
|
||||
private val prev = HashMap<Int, Int>()
|
||||
|
||||
/** 지금까지 되돌린 횟수(진단용). */
|
||||
var nLocked: Int = 0
|
||||
private set
|
||||
|
||||
fun reset() {
|
||||
prev.clear()
|
||||
nLocked = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 검출 결과(교차채널 前)를 제자리에서 보정한다. tie 인 채널만 직전 선택을 유지한다.
|
||||
*
|
||||
* 되돌린 뒤에도 검출기와 **같은** wall/lumen 게이트를 다시 통과해야 한다 — 고정이
|
||||
* 검출기가 애초에 기각했을 후벽을 되살리면 안 된다.
|
||||
*/
|
||||
fun apply(results: List<MethodDResult?>, params: MethodDParams = MethodDParams.DEFAULT) {
|
||||
for (ch in CENTER_CH) {
|
||||
if (ch >= results.size) continue
|
||||
val r = results[ch] ?: continue
|
||||
val cur = r.post
|
||||
val cands = r.postCandidates
|
||||
val p = prev[ch]
|
||||
if (p != null && p != cur && cands.size >= 2) {
|
||||
// 상위 1·2위 상대격차. 작을수록 두 후보의 증거가 같다는 뜻이다.
|
||||
val gap = cands[0].second / maxOf(cands[1].second, 1e-12) - 1.0
|
||||
val tied = cands.filter { it.second >= cands[0].second / (1.0 + tieMargin) }
|
||||
if (gap <= tieMargin &&
|
||||
tied.any { it.first == p } &&
|
||||
(maxSep <= 0 || abs(p - cur) <= maxSep) &&
|
||||
gateOk(r, p, params)
|
||||
) {
|
||||
r.post = p
|
||||
r.postRefined = p.toDouble()
|
||||
r.urineLen = p - r.ant - 1
|
||||
nLocked++
|
||||
}
|
||||
}
|
||||
prev[ch] = r.post
|
||||
}
|
||||
}
|
||||
|
||||
/** 되돌린 후벽이 검출기와 동일한 wall/lumen 게이트를 통과하는가. */
|
||||
private fun gateOk(r: MethodDResult, newPost: Int, params: MethodDParams): Boolean {
|
||||
val lt = r.sgLight
|
||||
val hv = r.sgHeavy
|
||||
val ls = r.lowStart
|
||||
val le = r.lowEnd
|
||||
if (newPost <= 0 || newPost >= lt.size || le <= ls) return false
|
||||
if ((newPost - r.ant - 1) < params.minUrineLen) return false
|
||||
var lumenMin = Double.MAX_VALUE
|
||||
for (i in ls..le) if (hv[i] < lumenMin) lumenMin = hv[i]
|
||||
return MethodDDetector.wallGateOk(lt[r.ant], lt[newPost], lumenMin, params)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Python `config_wc.TIE_MARGIN` — 상위 1·2위 상대격차가 이 이하면 tie. */
|
||||
const val TIE_MARGIN = 0.10
|
||||
|
||||
/** Python `config_wc.MAX_LOCK_SEP` — 되돌릴 수 있는 최대 index 이동. 0 = 무제한. */
|
||||
const val MAX_LOCK_SEP = 2
|
||||
|
||||
/** Python `config_wc.ENABLED`. 호출부가 측정마다 상태기를 만들어 넘긴다. */
|
||||
const val ENABLED = true
|
||||
|
||||
private val CENTER_CH = intArrayOf(0, 1, 2, 3)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user