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:
@@ -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