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:
@@ -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 동작 매칭 — 짝수 길이면 두 가운데 값의 평균. */
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user