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:
2026-09-03 18:09:26 +09:00
parent ccb44f0ea6
commit 441f249410
19 changed files with 4415 additions and 29 deletions
@@ -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)
}
}