feat(algo): 레퍼런스 경로를 dev 스위치로 · 기본은 현행 유지
신 저장소에 맞춘 레퍼런스(piezo-phantom-test/vesiscan_test) 단계들을 옮기되,
demo-final 의 기본 동작은 건드리지 않았다. 2026-09-03 임상이 현재 HEAD 위에서
돌았으므로 그 값을 움직이는 변경을 기본 경로에 넣을 수 없다.
`AlgoMode.reference` (계측 화면 dev 패널 → "알고리즘", 기본 꺼짐) 를 켜면:
· CCC 3단계 → 7단계
envelope(①.9) · ant_tiebreak(①) · Λ-dip(①.7) · edge_post(①.8) ·
lumen_return(①.95) · NTV(② Rule A 만) · fp_gate(③). inward_post 는 빠진다.
· 파이프라인 4개 추가
scale_recovery(2) · missing_outside 산출(4) · channel_confidence→untrusted(5b) ·
CONF_DEMOTE(6). missing_outside 는 CCC **앞**에서 계산한다 — CCC 가 복구한
채널은 방광 밖이 아니다.
· BV
si_floor/missing_outside 로 극 cap clamp 의 방향(상한/하한)을 관측 증거로 가름,
bottom cap 제한 신설, 중앙 채널 보간 은행가 반올림,
adaptive_large_bladder_relax 제거(b_si 를 0.85·a_ap 로 바닥 처리해 cap 축 비를
정확히 0.85 로 만들어 바로 다음 cap 축 폴백을 경계에서 막고 있었다).
검출 파라미터(d_max 15 · inward_walk_win 4) · ANT_FLOOR_REF · cap 축 폴백 ·
ELLIPSE_CAP_R_MAX · TOP_CAP_EDGE_K · cap_d_adaptive · 프리셋 유도 dps/delay 는
ebddc52/20a48ad 에서 이미 기본 경로에 들어가 있어 양쪽 공통이다. 되돌리지 않았다.
검증
· 기존 경로 무변경 — LegacyEquivalenceDumpTest 가 44 cycle × CCC on/off 를
검출→BV 로 돌려 덤프하고, HEAD 코드 덤프와 대조. 88 행 전부 일치.
(이 테스트는 일부러 AlgoMode 를 참조하지 않는다 — HEAD 에 없는 타입이라
참조하면 대조 자체가 불가능해진다.)
· 토글 배선 — AlgoModeSwitchTest: 켜면 44 중 40 cycle 이 갈리고
missing_outside 가 산출되며, 끄면 기존 값이 정확히 재현된다.
· 전체 30개 통과.
함께 고친 것
저장소가 갈리며 없어진 픽스처 경로(medilightv2android/data123)를
vesiscan-design-archive/data123 로 옮겼다. 5개 테스트가 FileNotFoundException 으로
조용히 실패하고 있었다.
그래서 ebddc52 이후 한 번도 안 돌던 CenterAlignerValidationTest 가 다시 돌았고
hit 7→8 · 10→11 로 어긋났다. 현행 레퍼런스 AnchorGuide(ver='v1') 를 같은 세션에
직접 돌려 보니 8 · 11 이었다 — 코틀린이 맞고 기대값이 낡은 것이다(구
v3_python_reference.py 기준, 현재 저장소에 없음). 기대값을 갱신했다.
bv_cv 는 현행 레퍼런스가 더 이상 내지 않아 대조할 수단이 없다. "Python 과
bit-exact" 라던 주석은 근거가 없어졌으므로 걷어내고 회귀 고정으로 성격을 바꿨다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 알고리즘 경로 전환 — 기존(demo-final 현행) vs 레퍼런스(piezo-phantom-test 1:1).
|
||||
*/
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
/**
|
||||
* 검출·BV 알고리즘을 어느 쪽으로 돌릴지.
|
||||
*
|
||||
* ## 왜 두 경로를 유지하나
|
||||
* demo-final 은 팬텀 시연과 임상 계측이 함께 도는 브랜치다. 여기서 "기존"이란
|
||||
* **오늘 임상이 실제로 돌아간 코드**(커밋 `ebddc52`·`20a48ad` 시점의 검출·BV)를 말한다.
|
||||
* 그 위에 신 저장소가 추가로 맞춘 레퍼런스 단계들은 값을 눈에 띄게 바꾸므로, 시연·계측이
|
||||
* 모르는 사이 다른 알고리즘으로 도는 일이 없도록 **기본은 기존 경로**로 두고 레퍼런스
|
||||
* 경로는 dev 패널에서 명시적으로 켤 때만 쓴다.
|
||||
*
|
||||
* ## 무엇이 갈리나
|
||||
* 켜면 아래가 전부 레퍼런스 동작으로 바뀐다. 부분 적용은 없다 — 단계들이 서로를
|
||||
* 전제하므로 섞으면 어느 쪽도 아닌 값이 나온다.
|
||||
*
|
||||
* · 교차채널 보정 3단계 → **7단계**
|
||||
* (①.9 envelope · ① ant_tiebreak · ①.7 Λ-dip · ①.8 edge ·
|
||||
* ①.95 lumen_return · ② NTV[Rule A 만] · ③ fp_gate,
|
||||
* 기존의 `inward_post` 는 빠진다)
|
||||
* · 파이프라인 `scale_recovery`(2) · `missing_outside` 증거 산출(4) ·
|
||||
* `channel_confidence`→untrusted(5b) · `CONF_DEMOTE`(6)
|
||||
* · BV 극 cap 의 상한/하한을 관측 증거로 가르는 `si_floor`/`missing_outside`,
|
||||
* bottom cap 제한 신설, 중앙 채널 보간 은행가 반올림,
|
||||
* `adaptive_large_bladder_relax` 제거(cap 축 폴백을 가로막고 있었다)
|
||||
*
|
||||
* 검출 파라미터(`d_max` 15 · `inward_walk_win` 4)와 prominence 기준(`ANT_FLOOR_REF`),
|
||||
* cap 축 폴백 · `ELLIPSE_CAP_R_MAX` · `TOP_CAP_EDGE_K` · `cap_d_adaptive`, 프리셋 유도
|
||||
* dps/delay 는 **양쪽 공통**이다 — `ebddc52`/`20a48ad` 에서 이미 기존 경로에 들어갔고
|
||||
* 오늘 임상이 그 위에서 돌았으므로 여기서 되돌리지 않는다.
|
||||
*
|
||||
* ## 상태 보관
|
||||
* 프로세스 수명 동안만 유지한다(런타임 토글). 앱을 다시 켜면 기존 경로로 돌아간다.
|
||||
*/
|
||||
object AlgoMode {
|
||||
|
||||
/** true = 레퍼런스 1:1 경로. 기본은 false(기존 경로). */
|
||||
@Volatile
|
||||
var reference: Boolean = false
|
||||
|
||||
/** 화면에 표시할 이름. */
|
||||
val label: String get() = if (reference) "레퍼런스 (Python 1:1)" else "기존 (현행 임상)"
|
||||
}
|
||||
@@ -132,8 +132,8 @@ object AnchorConfig {
|
||||
*
|
||||
* BV 를 못 내면 1.0(최악)을 돌려준다 — Python 과 같다. 후보 정렬에서 자연히 뒤로 밀린다.
|
||||
*/
|
||||
fun capFraction(walls: List<WallWithSpan?>): Double {
|
||||
val r = estimateBv(walls) ?: return 1.0
|
||||
fun capFraction(walls: List<WallWithSpan?>, missingOutside: Set<Int>? = null): Double {
|
||||
val r = estimateBv(walls, missingOutside = missingOutside) ?: return 1.0
|
||||
val y = r.sortedYMm
|
||||
val ySpan = if (y.size >= 2) (y.max() - y.min()) else 0.0
|
||||
val top = if (r.topHMm.isFinite()) r.topHMm else 0.0
|
||||
@@ -266,12 +266,17 @@ class AnchorGuide(
|
||||
// 같은 mean-scan 을 두 번 검출한다 — 통일하면 안 된다(SPEC §8.2-1).
|
||||
val wallsBase = MethodDRunner.detectMultichannel(
|
||||
meanScan, methodDParams, applyTgc = true, applyCross = false)
|
||||
val wallsCross = MethodDRunner.detectMultichannel(
|
||||
val crossOutcome = MethodDRunner.detectMultichannelWithEvidence(
|
||||
meanScan, methodDParams, applyTgc = true, applyCross = true)
|
||||
val wallsCross = crossOutcome.walls
|
||||
|
||||
val capFrac = capFraction(wallsCross.map { r ->
|
||||
r?.let { WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) }
|
||||
})
|
||||
// cap_frac 은 극 cap 높이로 계산하므로 clamp 방향을 가르는 증거가 필요하다.
|
||||
// 기존 경로에서는 빈 집합이라 null 로 떨어져 종전과 같이 동작한다.
|
||||
val capFrac = capFraction(
|
||||
wallsCross.map { r ->
|
||||
r?.let { WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) }
|
||||
},
|
||||
crossOutcome.missingOutside.ifEmpty { null })
|
||||
|
||||
// ch3 검출률은 trace 단위다. 그 김에 trace 별 nch 통계도 얻는다(진단용).
|
||||
val traces = slidingTraces(cycles, traceWin)
|
||||
|
||||
@@ -237,10 +237,20 @@ private fun repairCenterWallsFor6ch(
|
||||
if (gap == 1) {
|
||||
val pw = result[prevIdx]!!
|
||||
val nw = result[nextIdx]!!
|
||||
// Python 1:1: 선형 보간 (반올림 없이 float 유지 — subsample refined 값 보존)
|
||||
val ant = (pw.first + nw.first) / 2.0
|
||||
val post = (pw.second + nw.second) / 2.0
|
||||
result[prevIdx + 1] = Pair(ant, post)
|
||||
// 레퍼런스는 **정수로 절단한 뒤** 평균내고 은행가 반올림한다:
|
||||
// `int(round((int(prev) + int(next)) / 2.0))`
|
||||
// 보간된 채널은 subsample 정밀도를 갖지 않는다 — 근거가 이웃 둘의 정수
|
||||
// 위치뿐이기 때문이다. 실측 f01 tr11: 이 차이로 CH2 단면이 2971.0 vs
|
||||
// 2788.2 mm² 로 갈려 부피가 296.97 vs 293.67 mL 이 됐다.
|
||||
// 기존 경로는 float 평균 그대로 둔다(팬텀 시연 결과 불변).
|
||||
if (AlgoMode.reference) {
|
||||
val ant = bankersRound((pw.first.toInt() + nw.first.toInt()) / 2.0)
|
||||
val post = bankersRound((pw.second.toInt() + nw.second.toInt()) / 2.0)
|
||||
result[prevIdx + 1] = Pair(ant.toDouble(), post.toDouble())
|
||||
} else {
|
||||
result[prevIdx + 1] = Pair((pw.first + nw.first) / 2.0,
|
||||
(pw.second + nw.second) / 2.0)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// gap >= 2: drop top group
|
||||
@@ -578,6 +588,44 @@ 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.SI_FLOOR_WALK` — span 이 이어지는 가장 바깥 채널까지 하한을
|
||||
* 확장할지. 인접 채널에서 멈추지 않고 걸어간다. **레퍼런스 경로 전용**.
|
||||
*/
|
||||
const val SI_FLOOR_WALK = true
|
||||
|
||||
/** Python 3 `round()` 는 half-to-even 이다. `kotlin.math.round` 는 half-up 이라 갈린다. */
|
||||
private fun bankersRound(x: Double): Int = Math.rint(x).toInt()
|
||||
|
||||
/**
|
||||
* [start] 에서 [step] 방향으로 span 이 이어지는 동안 걸어가 **가장 바깥 채널**을 준다.
|
||||
*
|
||||
* 미검출 채널에 low echo span 이 있으면 방광이 그 빔까지는 온다는 뜻이므로, 연속으로
|
||||
* span 이 있으면 가장 바깥 것이 cap 하한이다. span 없는 채널(= [missingOutside] 에 속함)을
|
||||
* 만나면 멈춘다. null 반환 = 인접 채널부터 span 이 없다 → 종전대로 상한.
|
||||
*/
|
||||
private fun farthestWithSpan(missingOutside: Set<Int>?, start: Int, step: Int, stop: Int): Int? {
|
||||
if (missingOutside == null) return null
|
||||
var far: Int? = null
|
||||
var c = start
|
||||
while ((c - stop) * step <= 0 && c !in missingOutside) {
|
||||
far = c
|
||||
c += step
|
||||
}
|
||||
return far
|
||||
}
|
||||
|
||||
/**
|
||||
* 극 cap clamp 를 **상한으로** 걸어도 되는가.
|
||||
*
|
||||
* clamp 의 전제는 "미검출 채널 = 방광이 거기까지 안 감"인데, 레퍼런스 실측상 그 전제가
|
||||
* 맞는 경우는 silicon 17.2% / abs 3.5% 뿐이다 — 나머지는 low echo span 이 있는데 벽만
|
||||
* 못 잡은 것이다. [missingOutside] 는 **span 자체가 없어 진짜 방광 밖으로 판정된** 채널
|
||||
* 집합이고, null 이면 종전대로 항상 상한(= 조건부 비활성).
|
||||
*/
|
||||
private fun clampAllowed(missingOutside: Set<Int>?, ch: Int): Boolean =
|
||||
missingOutside == null || ch in missingOutside
|
||||
|
||||
/** Python `volume/config_vol.ELLIPSE_CAP_R_MAX` — 타원 cap 높이의 r 상한. */
|
||||
const val ELLIPSE_CAP_R_MAX = 0.92
|
||||
|
||||
@@ -640,6 +688,8 @@ fun estimateBladderVolume6ch(
|
||||
bSiFloorRatio: Double? = null,
|
||||
bSiFloorEdgeMin: Double = 0.5,
|
||||
ellipseCapHeight: Boolean = true,
|
||||
missingOutside: Set<Int>? = null,
|
||||
siFloor: Boolean = true,
|
||||
): BVResult? {
|
||||
var centerWalls = PiezoHW.centerCh.map { if (it < allWalls.size) allWalls[it] else null }
|
||||
centerWalls = repairCenterWallsFor6ch(centerWalls)
|
||||
@@ -656,6 +706,8 @@ fun estimateBladderVolume6ch(
|
||||
bSiFloorRatio = bSiFloorRatio,
|
||||
bSiFloorEdgeMin = bSiFloorEdgeMin,
|
||||
ellipseCapHeight = ellipseCapHeight,
|
||||
missingOutside = missingOutside,
|
||||
siFloor = siFloor,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -734,7 +786,22 @@ fun estimateBv(
|
||||
bSiFloorRatio: Double? = null,
|
||||
bSiFloorEdgeMin: Double = 0.5,
|
||||
adaptiveLargeBladderRelax: Boolean = true,
|
||||
/** [estimateBladderVolume] 참조 — 검출기가 산출한 '진짜 방광 밖' 채널 집합. */
|
||||
missingOutside: Set<Int>? = null,
|
||||
siFloor: Boolean = true,
|
||||
): BVResult? {
|
||||
// ── 레퍼런스 경로: 완화 없이 한 번 계산하고 cap 축 폴백만 건다 ────────────
|
||||
// "끝단 단면이 넓고 cap 이 얕으면 urine_inset 0.05 + b_si_floor 0.85 로 재계산"
|
||||
// 하던 완화가 **레퍼런스에서 제거됐다**. 남아 있는 동안 실제로 해를 끼쳤다:
|
||||
// b_si 를 0.85·a_ap 로 바닥 처리하는 바람에 cap 축 비가 정확히 0.85 가 되어 바로
|
||||
// 다음의 cap 축 폴백(`< 0.85`)이 **경계에서 발동하지 않았다**.
|
||||
// 실측 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)
|
||||
}
|
||||
|
||||
val result = evalOnce(walls, lrRatio, urineInsetFrac, bSiFloorRatio, bSiFloorEdgeMin)
|
||||
|
||||
// low_wide_endpoint 완화(아래)는 조건이 까다롭지만, **cap 축 폴백은 항상 적용된다** —
|
||||
@@ -809,7 +876,11 @@ private fun capAxisFallback(
|
||||
if (bSi == null || cAp == null || cAp <= 0.0) return result
|
||||
val dMax = result.dMm.filter { it > 0.0 }.maxOrNull() ?: 0.0
|
||||
if (bSi / cAp >= CAP_AXIS_RATIO_MAX || dMax >= CAP_AXIS_DMAX_MAX) return result
|
||||
// 폴백은 **부피를 폴백 계산 그대로** 쓴다(Python 과 동일) — 진단 필드만 옮기지 않는다.
|
||||
// 폴백은 **부피를 폴백 계산 그대로** 쓴다(Python 과 동일).
|
||||
// ⚠ 폴백 재계산에는 `missingOutside`·`siFloor` 를 **넘기지 않는다**. 레퍼런스
|
||||
// `runners.estimate_bv` 의 폴백 호출이 그 두 인자를 빼고 부르기 때문에 기본값으로
|
||||
// 떨어진다. 실측 f04 cyc20: 넘기면 249.95 mL, 안 넘기면 278.43 mL 로 후자가
|
||||
// 레퍼런스 값이다.
|
||||
val fb = evalOnce(walls, lrRatio, urineInsetFrac, bSiFloorRatio, bSiFloorEdgeMin,
|
||||
ellipseCapHeight = false)
|
||||
return if (fb != null && fb.volumeMl != 0.0) fb else result
|
||||
@@ -823,6 +894,8 @@ private fun evalOnce(
|
||||
bSiFloorRatio: Double?,
|
||||
bSiFloorEdgeMin: Double,
|
||||
ellipseCapHeight: Boolean = true,
|
||||
missingOutside: Set<Int>? = null,
|
||||
siFloor: Boolean = true,
|
||||
): BVResult? {
|
||||
val insetWalls: List<Pair<Double, Double>?> = walls.map { w ->
|
||||
w?.let {
|
||||
@@ -835,7 +908,8 @@ private fun evalOnce(
|
||||
val prevOverride = com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride
|
||||
com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride = lrRatio
|
||||
return try {
|
||||
estimateBladderVolume6ch(insetWalls, bSiFloorRatio, bSiFloorEdgeMin, ellipseCapHeight)
|
||||
estimateBladderVolume6ch(insetWalls, bSiFloorRatio, bSiFloorEdgeMin, ellipseCapHeight,
|
||||
missingOutside, siFloor)
|
||||
} finally {
|
||||
com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride = prevOverride
|
||||
}
|
||||
@@ -897,6 +971,16 @@ fun estimateBladderVolume(
|
||||
* 무너졌을 때(b_si/c_ap 가 작을 때) 타원 dome 을 믿으면 cap 이 과소해진다.
|
||||
*/
|
||||
ellipseCapHeight: Boolean = true,
|
||||
/**
|
||||
* **span 자체가 없어 진짜 방광 밖으로 판정된** 채널 집합(Python `missing_outside`).
|
||||
* null 이면 극 cap clamp 를 언제나 상한으로 건다(기존 동작).
|
||||
*/
|
||||
missingOutside: Set<Int>? = null,
|
||||
/**
|
||||
* Python `COND_CLAMP == 'si'`. 켜져 있으면 미검출 채널에 span 이 있을 때 같은
|
||||
* 지점을 **하한**으로 쓴다. 레퍼런스 경로에서만 의미가 있다.
|
||||
*/
|
||||
siFloor: Boolean = true,
|
||||
): BVResult? {
|
||||
|
||||
// 1) Edge channel filter — cap 방식 결정용 플래그
|
||||
@@ -1154,7 +1238,11 @@ fun estimateBladderVolume(
|
||||
val topSortedCh = sortedCh.last()
|
||||
val nCenterDet = sortedCh.count { it < 4 }
|
||||
if (nCenterDet >= 2 && topSortedCh > 0) {
|
||||
val upperCh = topSortedCh - 1
|
||||
var upperCh = topSortedCh - 1
|
||||
// span 이 이어지는 가장 바깥 채널까지 걸어간다 — 방광이 거기까지 온다는 관측이다.
|
||||
if (siFloor && SI_FLOOR_WALK) {
|
||||
farthestWithSpan(missingOutside, upperCh, -1, 0)?.let { upperCh = it }
|
||||
}
|
||||
val upperTheta = degreeDeg[upperCh] * Math.PI / 180.0
|
||||
val yBeamUpper = if (z0Ellipse != null && abs(cos(upperTheta)) > 1e-6) {
|
||||
val dAtZ0 = z0Ellipse!! / cos(upperTheta)
|
||||
@@ -1163,7 +1251,58 @@ fun estimateBladderVolume(
|
||||
sensorZMm[upperCh].toDouble()
|
||||
}
|
||||
val hTopLimit = yBeamUpper - yS[n - 1]
|
||||
if (hTopLimit > 0 && hCapTop > hTopLimit) hCapTop = hTopLimit
|
||||
if (hTopLimit > 0) {
|
||||
// **같은 지점을 관측 증거에 따라 상한 또는 하한으로 쓴다.**
|
||||
// 미검출 채널에 span 이 없다 = 방광이 그 빔에 없다 → 상한(기존).
|
||||
// span 이 있다 = 방광이 그 빔까지는 온다 → **하한**(외삽을 관측으로 대체).
|
||||
// `missingOutside == null`(기존 경로)이면 clampAllowed 가 항상 true 라
|
||||
// 기존 동작과 완전히 같다.
|
||||
if (clampAllowed(missingOutside, upperCh)) {
|
||||
if (hCapTop > hTopLimit) hCapTop = hTopLimit
|
||||
} else if (siFloor) {
|
||||
hCapTop = max(hCapTop, hTopLimit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom cap 제한 (top 대칭) — 최하단 검출채널이 CH3(최inferior)이 아니면 아래에
|
||||
// 미검출 채널이 있다. 그 빔이 방광 중심 깊이(z0)를 지나는 y 로 bottom cap 을
|
||||
// 제한한다. sitting 에서 CH3(치골)이 소실될 때 bottom cap 이 과외삽되는 것을 막는다.
|
||||
// 기존 경로에는 이 제약이 아예 없었으므로 레퍼런스에서만 건다.
|
||||
val botSortedCh = sortedCh.first()
|
||||
if (AlgoMode.reference && nCenterDet >= 2 && botSortedCh < 3) {
|
||||
var lowerCh = botSortedCh + 1 // index+1 = 더 inferior(낮은 y) 채널
|
||||
if (siFloor && SI_FLOOR_WALK) {
|
||||
farthestWithSpan(missingOutside, lowerCh, +1, 3)?.let { lowerCh = it }
|
||||
}
|
||||
val lowerTheta = degreeDeg[lowerCh] * Math.PI / 180.0
|
||||
// z0: 타원 fit 실패(nch=2 등) 시 단면 wall 중심 깊이로 추정한다. fallback 에서
|
||||
// sensor 위치만 쓰면 한계가 헐거워진다.
|
||||
val z0Eff = z0Ellipse ?: run {
|
||||
var acc = 0.0
|
||||
for (k in zWallAnt.indices) acc += (zWallAnt[k] + zWallPost[k]) / 2.0
|
||||
if (zWallAnt.isNotEmpty()) acc / zWallAnt.size else 0.0
|
||||
}
|
||||
val yBeamLower = if (abs(cos(lowerTheta)) > 1e-6) {
|
||||
sensorZMm[lowerCh] + (z0Eff / cos(lowerTheta)) * sin(lowerTheta)
|
||||
} else {
|
||||
sensorZMm[lowerCh].toDouble()
|
||||
}
|
||||
val hBotLimit = yS[0] - yBeamLower
|
||||
var applyH: Double? = null
|
||||
if (clampAllowed(missingOutside, lowerCh)) {
|
||||
if (hBotLimit > 0 && hCapBot > hBotLimit) applyH = hBotLimit // 방광 밖 → 상한
|
||||
} else if (siFloor && hBotLimit > hCapBot) {
|
||||
applyH = hBotLimit // span 있음 → 하한
|
||||
}
|
||||
if (applyH != null) {
|
||||
hCapBot = applyH
|
||||
// bottom 구면캡 R 을 바뀐 높이로 역산 (base 반경 유지)
|
||||
if (rEffCap != null && hCapBot > 1e-6) {
|
||||
val abL = aCapS[0]
|
||||
rEffCap = (abL * abL + hCapBot * hCapBot) / (2.0 * hCapBot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8) Core frustum (traditional: h = dy)
|
||||
|
||||
@@ -133,8 +133,9 @@ fun ClinicalLiveView(appState: AppState) {
|
||||
DoubleArray(buf.size) { buf[it].toDouble() }
|
||||
} ?: DoubleArray(100)
|
||||
}
|
||||
val mdResults = com.medithings.vesiscan.walldetect.MethodDRunner
|
||||
.detectMultichannel(signalsD, applyCross = true)
|
||||
val mdOutcome = com.medithings.vesiscan.walldetect.MethodDRunner
|
||||
.detectMultichannelWithEvidence(signalsD, applyCross = true)
|
||||
val mdResults = mdOutcome.walls
|
||||
// Python `runners.estimate_bv` 1:1 대응 (inset + adaptive_large_bladder_relax).
|
||||
val mdWalls = mdResults.map { r ->
|
||||
r?.let {
|
||||
@@ -142,7 +143,10 @@ fun ClinicalLiveView(appState: AppState) {
|
||||
it.antRefined, it.postRefined, it.lowStart, it.lowEnd)
|
||||
}
|
||||
}
|
||||
bvMethodD = com.medithings.vesiscan.managers.estimateBv(mdWalls)
|
||||
// `missingOutside` 는 극 cap clamp 의 방향(상한/하한)을 가르는 관측 증거다.
|
||||
// 기존 경로에서는 검출기가 빈 집합을 주므로 null 로 넘겨 종전과 같이 동작한다.
|
||||
bvMethodD = com.medithings.vesiscan.managers.estimateBv(
|
||||
mdWalls, missingOutside = mdOutcome.missingOutside.ifEmpty { null })
|
||||
?.volumeMl?.takeIf { it.isFinite() && it > 0 }
|
||||
}
|
||||
}
|
||||
|
||||
+36
-5
@@ -287,6 +287,8 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
// Method D detection 결과 (refined ant/post + span) — Method D BV wrapper
|
||||
// 경로에서 재사용해 adaptive_large_bladder_relax 등 Python 1:1 적용.
|
||||
var methodDDets: List<com.medithings.vesiscan.walldetect.MethodDResult?>? = null
|
||||
/** 극 cap clamp 방향을 가르는 관측 증거. 기존 경로에서는 항상 null. */
|
||||
var methodDMissingOutside: Set<Int>? = null
|
||||
|
||||
val validChannels = channels.filter { it.isValid }
|
||||
val allChannelsReceived = channels.filter { it.buffer.size >= 20 }.size >= 6
|
||||
@@ -368,9 +370,11 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
val cd = channels.find { it.channel == ch }
|
||||
cd?.buffer?.map { it.toDouble() }?.toDoubleArray() ?: DoubleArray(0)
|
||||
}
|
||||
val dets = com.medithings.vesiscan.walldetect.MethodDRunner
|
||||
.detectMultichannel(signals)
|
||||
val outcome = com.medithings.vesiscan.walldetect.MethodDRunner
|
||||
.detectMultichannelWithEvidence(signals)
|
||||
val dets = outcome.walls
|
||||
methodDDets = dets // BV wrapper 경로에서 refined+span 재사용
|
||||
methodDMissingOutside = outcome.missingOutside.ifEmpty { null }
|
||||
for (i in 0..5) {
|
||||
val r = dets.getOrNull(i)
|
||||
if (r != null) allWalls[i] = Pair(r.ant, r.post)
|
||||
@@ -444,9 +448,11 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
// Wrapper 가 lumen_inset + adaptive_large_bladder_relax + b_si_floor 모두
|
||||
// 자동 적용 → Python 1:1. 여기서는 sourceAllWalls 에 int (ant,post) 만
|
||||
// 반환 (기존 wall-count 게이트 · UI 표시용).
|
||||
val dets = com.medithings.vesiscan.walldetect.MethodDRunner
|
||||
.detectMultichannel(signals)
|
||||
val outcome = com.medithings.vesiscan.walldetect.MethodDRunner
|
||||
.detectMultichannelWithEvidence(signals)
|
||||
val dets = outcome.walls
|
||||
methodDDets = dets
|
||||
methodDMissingOutside = outcome.missingOutside.ifEmpty { null }
|
||||
dets.map { r -> r?.let { Pair(it.ant, it.post) } }
|
||||
}
|
||||
} else allWalls
|
||||
@@ -490,7 +496,10 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
com.medithings.vesiscan.managers.BvMethod.METHOD_D_PHANTOM) {
|
||||
com.medithings.vesiscan.managers.estimateBvPhantomSphere(walls)
|
||||
} else {
|
||||
com.medithings.vesiscan.managers.estimateBv(walls)
|
||||
// `missingOutside` = 극 cap clamp 방향을 가르는 관측 증거.
|
||||
// 기존 경로에서는 항상 null 이라 종전 동작과 같다.
|
||||
com.medithings.vesiscan.managers.estimateBv(
|
||||
walls, missingOutside = methodDMissingOutside)
|
||||
}
|
||||
if (bvResult != null) {
|
||||
rawVolumeMl = bvResult.volumeMl
|
||||
@@ -2094,6 +2103,28 @@ fun PiezoMonitoringView(appState: AppState) {
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = dividerPadding))
|
||||
|
||||
// 알고리즘 경로 — 기본은 **기존(팬텀 시연)**. 레퍼런스는 Python
|
||||
// `piezo-phantom-test` 1:1 경로로, 검출(CCC 7단계·파이프라인)과
|
||||
// BV(극 cap 증거 clamp) · dps/delay 가 함께 바뀐다. 시연 결과가
|
||||
// 달라지므로 여기서 명시적으로 켤 때만 쓴다.
|
||||
var refAlgo by remember { mutableStateOf(com.medithings.vesiscan.managers.AlgoMode.reference) }
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column {
|
||||
Text("알고리즘", fontSize = labelSize, fontWeight = FontWeight.SemiBold)
|
||||
Text(com.medithings.vesiscan.managers.AlgoMode.label,
|
||||
fontSize = 11.sp, color = MlSecondaryText)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Switch(checked = refAlgo,
|
||||
onCheckedChange = {
|
||||
refAlgo = it
|
||||
com.medithings.vesiscan.managers.AlgoMode.reference = it
|
||||
},
|
||||
colors = SwitchDefaults.colors(checkedTrackColor = Color(0xFF7C3AED)))
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = dividerPadding))
|
||||
|
||||
// 2026-07-01: lr_ratio override (algorithm 팀 4830e7c 표준)
|
||||
var lrFixed by remember {
|
||||
mutableStateOf(com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride != null)
|
||||
|
||||
@@ -41,7 +41,8 @@ data class MethodDResult(
|
||||
var urineLen: Int,
|
||||
val antProm: Double,
|
||||
val postProm: Double,
|
||||
val antType: CType,
|
||||
// 레퍼런스 경로의 envelope_research(①.9)가 전벽을 옮기며 함께 갱신한다.
|
||||
var antType: CType,
|
||||
var postType: CType,
|
||||
val sgHeavy: DoubleArray,
|
||||
val sgLight: DoubleArray,
|
||||
|
||||
@@ -23,6 +23,9 @@ package com.medithings.vesiscan.walldetect
|
||||
import com.medithings.vesiscan.managers.AlignmentConstants
|
||||
import com.medithings.vesiscan.managers.PiezoHW
|
||||
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDParams
|
||||
import com.medithings.vesiscan.walldetect.algo.MedianFilter
|
||||
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDSpan
|
||||
import com.medithings.vesiscan.walldetect.algo.SgSmoothGeneric
|
||||
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDPreprocessing
|
||||
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDTgc
|
||||
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDWallSelect
|
||||
@@ -51,6 +54,31 @@ object MethodDRunner {
|
||||
private const val NEIGHBOR_TOP_VALIDATE = true
|
||||
private const val INWARD_POST_SEARCH = true
|
||||
|
||||
// ── 레퍼런스 경로 전용 상수 ([AlgoMode.reference] 일 때만 쓰인다) ──
|
||||
// 값·근거는 piezo-phantom-test `wall_correction/config_wc.py` · `runners.py`.
|
||||
private const val VDIP_DIP_TOL = 8.0
|
||||
private const val VDIP_REACH_MARGIN = 15.0
|
||||
private const val LUMEN_RETURN_RECOVERY = true
|
||||
private const val LUMEN_RETURN_LOOKAHEAD = 12
|
||||
private const val LUMEN_RETURN_LEVEL = 1.5
|
||||
private const val LUMEN_RETURN_DMAX = 26
|
||||
private const val FP_CHANNEL_GATE = true
|
||||
private const val FP_GATE_PROM_MAX = 0.75
|
||||
private const val FP_GATE_DZ_MIN = 5.0
|
||||
private const val EDGE_POST_RECOVERY = true
|
||||
private const val EDGE_CLOSER_TOL_MM = 2.0
|
||||
private const val EDGE_POST_TREND = true
|
||||
private const val ENVELOPE_RESEARCH = true
|
||||
private const val ENVELOPE_MARGIN = 5
|
||||
private const val ENVELOPE_TRIM = true
|
||||
private const val ENVELOPE_NCH1_ANT_DMAX = 26
|
||||
private const val SCALE_RECOVERY = true
|
||||
private const val RECOVERY_WINDOW = 5
|
||||
private const val CCC_EVIDENCE_MIN_CONF = 0.75
|
||||
private const val CCC_EVIDENCE_MAX_NCH = 3
|
||||
private const val CONF_DEMOTE = 0.58
|
||||
private const val CONF_DEMOTE_MIN_KEEP = 3
|
||||
|
||||
/**
|
||||
* 6채널 (또는 N채널) raw 신호 → 채널별 MethodDResult? 리스트.
|
||||
* raw[ch] 길이가 다르면 그대로 처리 (각 채널 독립).
|
||||
@@ -61,13 +89,24 @@ object MethodDRunner {
|
||||
* — 교차채널 보정이 nch 를 바꿔 위치 선택이 흔들리는 것을 차단.
|
||||
* BV 산출은 applyCross=true 로 별도 수행.
|
||||
*/
|
||||
/** 기존 호출부 호환 — 증거가 필요 없으면 이쪽. */
|
||||
fun detectMultichannel(
|
||||
|
||||
signals: List<DoubleArray>,
|
||||
params: MethodDParams = MethodDParams.DEFAULT,
|
||||
beamAnglesDeg: DoubleArray? = null,
|
||||
applyTgc: Boolean = true,
|
||||
applyCross: Boolean = true,
|
||||
): List<MethodDResult?> {
|
||||
): List<MethodDResult?> = detectMultichannelWithEvidence(
|
||||
signals, params, beamAnglesDeg, applyTgc, applyCross).walls
|
||||
|
||||
fun detectMultichannelWithEvidence(
|
||||
signals: List<DoubleArray>,
|
||||
params: MethodDParams = MethodDParams.DEFAULT,
|
||||
beamAnglesDeg: DoubleArray? = null,
|
||||
applyTgc: Boolean = true,
|
||||
applyCross: Boolean = true,
|
||||
): DetectionOutcome {
|
||||
val angles = beamAnglesDeg ?: PiezoHW.degreeAll
|
||||
// 1) per-channel SG denoise (heavy + light)
|
||||
val heavyList = signals.map { MethodDPreprocessing.preprocessHeavy(it, params) }
|
||||
@@ -87,16 +126,69 @@ object MethodDRunner {
|
||||
params = params,
|
||||
)
|
||||
}
|
||||
// 5) cross-channel 후처리 (Python `apply_cross_channel` 순서 고정)
|
||||
if (applyCross) {
|
||||
applyCrossChannel(results, angles, lightTgc, heavyTgc, signals, params)
|
||||
val ref = com.medithings.vesiscan.managers.AlgoMode.reference
|
||||
|
||||
// ── 2) 검출 실패 채널만 SG5 로 재탐색 (레퍼런스 경로 전용) ─────────────
|
||||
// 이미 검출된 채널은 건드리지 않는다 — 얻는 것은 커버리지뿐이고 측정면은 불변.
|
||||
if (ref && SCALE_RECOVERY && applyCross) {
|
||||
applyScaleRecovery(results, signals, angles, params, applyTgc)
|
||||
}
|
||||
return results
|
||||
|
||||
// ── 4) 미검출 채널의 span 유무 판정 (레퍼런스 경로 전용) ───────────────
|
||||
// **CCC 앞에서** 계산한다 — CCC 가 복구한 채널은 '방광 밖'이 아니다.
|
||||
val missingOut: Set<Int> =
|
||||
if (ref && applyCross) missingOutside(results, lightTgc, heavyTgc, angles, params)
|
||||
else emptySet()
|
||||
|
||||
// ── 5) 교차채널 보정 ───────────────────────────────────────────────
|
||||
if (applyCross) {
|
||||
// 5b) 약한 채널을 CCC 복구 단계에서 숨긴다(레퍼런스 경로 전용).
|
||||
var untrusted: Set<Int>? = null
|
||||
if (ref && CCC_EVIDENCE_MIN_CONF > 0.0) {
|
||||
val cf = channelConfidence(results, signals)
|
||||
if (cf != null) {
|
||||
var weak = cf.filterValues { it < CCC_EVIDENCE_MIN_CONF }.keys.toSet()
|
||||
if (CCC_EVIDENCE_MAX_NCH > 0 && cf.size > CCC_EVIDENCE_MAX_NCH) weak = emptySet()
|
||||
if (cf.size - weak.size >= 2) untrusted = weak // 근거가 없어지는 것은 막는다
|
||||
}
|
||||
}
|
||||
applyCrossChannel(results, angles, lightTgc, heavyTgc, signals, params, untrusted)
|
||||
}
|
||||
|
||||
// ── 6) 약한 채널 강등 (레퍼런스 경로 전용) ─────────────────────────
|
||||
if (ref && CONF_DEMOTE > 0.0 && applyCross) {
|
||||
val cf = channelConfidence(results, signals)
|
||||
if (cf != null) {
|
||||
val keep = cf.filterValues { it >= CONF_DEMOTE }.keys
|
||||
if (keep.size >= maxOf(1, CONF_DEMOTE_MIN_KEEP)) {
|
||||
for ((c, v) in cf) if (v < CONF_DEMOTE && c < results.size) results[c] = null
|
||||
}
|
||||
}
|
||||
}
|
||||
return DetectionOutcome(results, missingOut)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-channel 3-stage 후처리 (Python `apply_cross_channel`).
|
||||
* ① ant_tiebreak → ② neighbor_top_validate → ③ inward_post
|
||||
* 검출 결과 + **관측 증거**.
|
||||
*
|
||||
* [missingOutside] 는 극 cap 의 상한/하한 방향을 가르는 값이라 BV 에 함께 넘겨야
|
||||
* 한다. **CCC 앞 시점**이어야 하므로(복구된 채널은 방광 밖이 아니다) 검출기가
|
||||
* 만들어 돌려준다. 기존 경로에서는 항상 빈 집합이다.
|
||||
*/
|
||||
data class DetectionOutcome(
|
||||
val walls: List<MethodDResult?>,
|
||||
val missingOutside: Set<Int>,
|
||||
)
|
||||
|
||||
/**
|
||||
* 교차채널 보정 — 모드에 따라 **다른 순서**로 돈다.
|
||||
*
|
||||
* 기존(팬텀 시연): ① ant_tiebreak → ② neighbor_top_validate(Rule A+B) → ③ inward_post
|
||||
* 레퍼런스 : ①.9 envelope → ① ant_tiebreak → ①.7 Λ-dip → ①.8 edge →
|
||||
* ①.95 lumen_return → ② NTV(Rule A 만) → ③ fp_channel_gate
|
||||
*
|
||||
* 두 경로를 섞으면 안 된다. 레퍼런스의 단계들은 서로를 전제한다(예: ①.9 가 먼저
|
||||
* 후보를 확보해야 ① 이 제대로 재순위를 매긴다).
|
||||
*/
|
||||
private fun applyCrossChannel(
|
||||
results: MutableList<MethodDResult?>,
|
||||
@@ -105,14 +197,49 @@ object MethodDRunner {
|
||||
heavy: List<DoubleArray>,
|
||||
raw6ch: List<DoubleArray>,
|
||||
params: MethodDParams,
|
||||
untrusted: Set<Int>? = null,
|
||||
) {
|
||||
if (!com.medithings.vesiscan.managers.AlgoMode.reference) {
|
||||
applyAntTiebreak(results, angles)
|
||||
if (NEIGHBOR_TOP_VALIDATE) {
|
||||
applyNeighborTopValidate(results, angles, light, heavy, raw6ch, params)
|
||||
}
|
||||
if (INWARD_POST_SEARCH) {
|
||||
applyInwardPost(results, angles)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ── 레퍼런스 경로 ──
|
||||
// 신뢰도 낮은 채널은 **복구 단계에서만** 숨긴다(이웃 근거로 안 쓰이도록).
|
||||
val hidden = HashMap<Int, MethodDResult>()
|
||||
if (untrusted != null) {
|
||||
for (c in untrusted) {
|
||||
if (c < results.size) {
|
||||
val r = results[c]
|
||||
if (r != null) { hidden[c] = r; results[c] = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
// ①.9 포락선 재탐색은 **맨 앞**이다 — 탐색창이 진짜 벽을 배제한 경우를 고치는
|
||||
// 단계라, 이후 재탐색들이 재순위를 매기기 전에 후보를 먼저 확보해야 한다.
|
||||
if (ENVELOPE_RESEARCH) applyEnvelopeResearch(results, params)
|
||||
applyAntTiebreak(results, angles)
|
||||
applyLambdaPostRecovery(results, angles, light, heavy, raw6ch, params)
|
||||
if (EDGE_POST_RECOVERY) {
|
||||
applyEdgePostRecovery(results, angles, light, heavy, raw6ch, params)
|
||||
}
|
||||
if (LUMEN_RETURN_RECOVERY) applyLumenReturnRecovery(results, raw6ch, params)
|
||||
// 판정 단계 **전에** 복원한다 — 숨긴 채로 두면 CCC 가 그 채널을 FP 로 기각할
|
||||
// 기회를 잃어 약한 채널이 오히려 살아남는다.
|
||||
for ((c, r) in hidden) if (results[c] == null) results[c] = r
|
||||
if (NEIGHBOR_TOP_VALIDATE) {
|
||||
applyNeighborTopValidate(results, angles, light, heavy, raw6ch, params)
|
||||
}
|
||||
if (INWARD_POST_SEARCH) {
|
||||
applyInwardPost(results, angles)
|
||||
// 레퍼런스의 NTV 는 **Rule A(FN 복구)만** 한다. 기존 구현의 Rule B(최상단
|
||||
// FP 기각)는 ③ fp_channel_gate 로 옮겨졌으므로 레퍼런스 경로에서는 건너뛴다.
|
||||
applyNeighborTopValidate(results, angles, light, heavy, raw6ch, params,
|
||||
ruleBEnabled = false)
|
||||
}
|
||||
if (FP_CHANNEL_GATE) applyFpChannelGate(results, angles)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -218,33 +345,43 @@ object MethodDRunner {
|
||||
heavy: List<DoubleArray>,
|
||||
raw6ch: List<DoubleArray>,
|
||||
params: MethodDParams,
|
||||
/**
|
||||
* 최상단 채널 FP **기각**(Rule B) 사용 여부.
|
||||
*
|
||||
* 레퍼런스는 Rule B 를 걷어내고 FN **복구**(Rule A)만 남겼다 — 기각은
|
||||
* ③ `fp_channel_gate` 가 전 채널을 같은 기준으로 판정하는 쪽으로 옮겨졌다.
|
||||
* 기존(팬텀 시연) 경로는 종전대로 Rule B 를 쓴다.
|
||||
*/
|
||||
ruleBEnabled: Boolean = true,
|
||||
) {
|
||||
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
|
||||
if (ruleBEnabled) {
|
||||
// ---- 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,4 +577,541 @@ object MethodDRunner {
|
||||
for (i in lo..hi) if (arr[i] < m) m = arr[i]
|
||||
return if (m.isFinite()) m else 0.0
|
||||
}
|
||||
|
||||
// ═══ 레퍼런스 경로 전용 ([AlgoMode.reference]) ═══════════════════
|
||||
|
||||
/** z(angle, idx) — angle 을 직접 받는 sample_to_ap_depth. vdipDeepCandidate 재사용. */
|
||||
private fun zAt(angleDeg: Double, idx: Int): Double =
|
||||
(PiezoHW.delayOffsetMm + idx * PiezoHW.distancePerSample) * cos(Math.toRadians(angleDeg))
|
||||
|
||||
/** numpy.median 동작 매칭 — 짝수 길이면 두 가운데 값의 평균. */
|
||||
/**
|
||||
* cur_post 보다 깊고 target_z + margin 이내에서 gate 통과하는 최강진폭 local-max peak
|
||||
* idx (없으면 null). detector 동일 gate (wall_gate_ok + min_post_raw_ratio) 로 재검증해
|
||||
* 얕은 반향 peak 대신 진짜 심부 후벽만 채택. V-dip 심부후벽 재탐색용.
|
||||
*/
|
||||
private fun vdipDeepCandidate(
|
||||
lt: DoubleArray, hv: DoubleArray, raw: DoubleArray,
|
||||
ai: Int, curPost: Int, angleDeg: Double,
|
||||
ls: Int, le: Int, targetZ: Double,
|
||||
params: MethodDParams,
|
||||
): Int? {
|
||||
val L = lt.size
|
||||
var hi = curPost + 1
|
||||
while (hi < L - 1 && zAt(angleDeg, hi) < targetZ + VDIP_REACH_MARGIN) hi++
|
||||
val cap = if (params.postMaxIdx > 0) params.postMaxIdx else (L - 1)
|
||||
hi = kmin(kmin(hi, cap), L - 2)
|
||||
if (hi <= curPost + 1 || le <= ls) return null
|
||||
val lumenMin = minInRange(hv, ls, le)
|
||||
val rawHv = MethodDPreprocessing.preprocessHeavy(raw, params)
|
||||
val rawLt = MethodDPreprocessing.preprocessLight(raw)
|
||||
val rawLumen = minInRange(rawHv, ls, le)
|
||||
var best = -1
|
||||
var bestAmp = -1.0
|
||||
for (c in (curPost + 2)..hi) {
|
||||
// local max
|
||||
if (!(lt[c] >= lt[c - 1] && lt[c] > lt[c + 1])) continue
|
||||
// wall gate (detector 와 동일: wallRatioUsePostOnly ? post : min(ant,post))
|
||||
val wallAmp = if (params.wallRatioUsePostOnly) lt[c] else kmin(lt[ai], lt[c])
|
||||
if (wallAmp / maxOf(lumenMin, 1.0) < params.minWallLumenRatio) continue
|
||||
// raw ratio gate
|
||||
if (rawLt[c] / maxOf(rawLumen, 1.0) < params.minPostRawRatio) continue
|
||||
if (lt[c] > bestAmp) {
|
||||
bestAmp = lt[c]; best = c
|
||||
}
|
||||
}
|
||||
return if (best < 0) null else best
|
||||
}
|
||||
|
||||
/**
|
||||
* ①.7 Λ-dip 이웃 심부후벽 복구 — Python `apply_lambda_post_recovery`.
|
||||
*
|
||||
* ## 종전 구현(V-dip)과 방향이 반대다
|
||||
* 2026-09-03 까지 여기는 "가운데 채널이 이웃보다 **얕게** 잡혔으면 가운데를 깊게"
|
||||
* 고치는 V-dip 이었다. 레퍼런스는 그 반대다 — "가운데가 이웃보다 **깊게** 잡혔으면
|
||||
* (그쪽이 진짜 후벽일 가능성이 높으므로) **이웃 둘을** 그 깊이로 끌어올린다".
|
||||
*
|
||||
* 기준선도 다르다: V-dip 은 두 이웃의 평균을, Λ-dip 은 **가운데 채널의 깊이**를
|
||||
* 목표로 삼는다. 그리고 레퍼런스에는 prominence 가드가 있다 — 더 약한 후보로는
|
||||
* 옮기지 않는다(후벽 재탐색 4종의 공통 규칙).
|
||||
*/
|
||||
private fun applyLambdaPostRecovery(
|
||||
results: MutableList<MethodDResult?>,
|
||||
angles: DoubleArray,
|
||||
light: List<DoubleArray>,
|
||||
heavy: List<DoubleArray>,
|
||||
raw6ch: List<DoubleArray>,
|
||||
params: MethodDParams,
|
||||
dipTol: Double = VDIP_DIP_TOL,
|
||||
) {
|
||||
val det = AlignmentConstants.CENTER_CH
|
||||
.filter { it < results.size && results[it] != null }
|
||||
.sorted()
|
||||
if (det.size < 3) return
|
||||
|
||||
val zc = HashMap<Int, Double>()
|
||||
for (i in det) zc[i] = zOf(angles, i, results[i]!!.post)
|
||||
|
||||
for (pos in 1 until det.size - 1) {
|
||||
val i = det[pos]; val lo = det[pos - 1]; val hi = det[pos + 1]
|
||||
val zi = zc[i]!!; val zLo = zc[lo]!!; val zHi = zc[hi]!!
|
||||
// Λ-dip: 가운데가 양 이웃보다 dipTol 깊고, 두 이웃끼리는 dipTol 이내로 일치
|
||||
if (!(zi - zLo > dipTol && zi - zHi > dipTol && abs(zLo - zHi) < dipTol)) continue
|
||||
|
||||
val cons = zi // 기준선 = 가운데 채널의 깊이
|
||||
for (j in listOf(lo, hi)) {
|
||||
val r = results[j] ?: continue
|
||||
val ang = if (j < angles.size) angles[j] else 0.0
|
||||
val newp = vdipDeepCandidate(
|
||||
light[j], heavy[j], raw6ch[j], r.ant, r.post, ang,
|
||||
r.lowStart, r.lowEnd, cons, params
|
||||
) ?: continue
|
||||
if (newp <= r.post) continue // 깊어지는 방향만
|
||||
// 더 약한 후보로는 옮기지 않는다(prominence 가드).
|
||||
if (postPromAt(light[j], newp, r.lowStart, r.lowEnd) <
|
||||
postPromAt(light[j], r.post, r.lowStart, r.lowEnd)) continue
|
||||
val zNew = zAt(ang, newp)
|
||||
if (abs(zNew - cons) >= abs(zc[j]!! - cons)) continue // 기준선에 가까워질 때만
|
||||
if (zNew > cons + VDIP_REACH_MARGIN) continue // overshoot 방지
|
||||
r.post = newp
|
||||
r.postRefined = newp.toDouble()
|
||||
r.urineLen = newp - r.ant - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 후벽 prominence — 검출기와 같은 정의(low echo 영역 바닥 기준).
|
||||
*
|
||||
* `MethodDResult.postProm` 을 쓰지 않는 이유: Rule A 로 만들어진 값은 0.0 이고,
|
||||
* 단계마다 벽이 옮겨져도 갱신되지 않는다. 같은 바닥으로 양쪽을 다시 재야 비교가
|
||||
* 성립한다.
|
||||
*/
|
||||
private fun postPromAt(sig: DoubleArray, idx: Int, ls: Int, le: Int): Double {
|
||||
if (le < ls || idx < 0 || idx >= sig.size || le >= sig.size) return 0.0
|
||||
return sig[idx] - minInRange(sig, ls, le)
|
||||
}
|
||||
|
||||
/**
|
||||
* 후보 [idx] 뒤에서 신호가 lumen 수준으로 되돌아가는가 = 후보가 방광 **안**이다.
|
||||
*
|
||||
* 진짜 후벽 뒤는 조직이라 low echo 수준으로 돌아가지 않는다. 참값이 필요 없는
|
||||
* 물리 판별이다. [sig] 는 반드시 **비-TGC**(SG-only)여야 한다 — TGC 는 깊은 곳의
|
||||
* lumen 도 들어올려 판정을 무디게 한다.
|
||||
*/
|
||||
private fun lumenReturnsAfter(sig: DoubleArray, idx: Int, ls: Int, le: Int, minRun: Int): Boolean {
|
||||
if (le <= ls || idx >= sig.size - 1) return false
|
||||
val floor = minInRange(sig, ls, le)
|
||||
val med = medianInRange(sig, ls, le)
|
||||
val level = floor + LUMEN_RETURN_LEVEL * maxOf(med - floor, 1.0)
|
||||
var run = 0
|
||||
var k = idx + 1
|
||||
val end = kmin(sig.size, idx + 1 + LUMEN_RETURN_LOOKAHEAD)
|
||||
while (k < end) {
|
||||
run = if (sig[k] <= level) run + 1 else 0
|
||||
if (run >= minRun) return true
|
||||
k++
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun medianInRange(sig: DoubleArray, ls: Int, le: Int): Double {
|
||||
val v = DoubleArray(le - ls + 1) { sig[ls + it] }
|
||||
v.sort()
|
||||
val n = v.size
|
||||
return if (n % 2 == 1) v[n / 2] else 0.5 * (v[n / 2 - 1] + v[n / 2])
|
||||
}
|
||||
|
||||
/**
|
||||
* ①.95 방광 **안**을 후벽으로 잡은 채널을 더 깊이 재탐색 — Python
|
||||
* `apply_lumen_return_recovery`.
|
||||
*
|
||||
* 판정은 **비-TGC 신호**로 한다(SPEC "포팅 시 특히 틀리기 쉬운 곳" 2번). TGC 신호로
|
||||
* 하면 깊은 곳의 lumen 이 들어올려져 "되돌아옴"을 못 본다.
|
||||
*/
|
||||
private fun applyLumenReturnRecovery(
|
||||
results: MutableList<MethodDResult?>,
|
||||
raw6ch: List<DoubleArray>,
|
||||
params: MethodDParams,
|
||||
) {
|
||||
for (i in AlignmentConstants.CENTER_CH) {
|
||||
if (i >= results.size) continue
|
||||
val r = results[i] ?: continue
|
||||
val ls = r.lowStart; val le = r.lowEnd
|
||||
if (le <= ls) continue
|
||||
val lt = r.sgLight
|
||||
val chk = MethodDPreprocessing.preprocessLight(raw6ch[i]) // 비-TGC 판정 신호
|
||||
val run = params.lowMinLen
|
||||
if (!lumenReturnsAfter(chk, r.post, ls, le, run)) continue
|
||||
|
||||
val out = MethodDWallSelect.findWallPeakLocal(
|
||||
lt, ls, le, MethodDWallSelect.Side.POST, params,
|
||||
dMaxOverride = LUMEN_RETURN_DMAX, inwardWalk = r.inwardWalkPost,
|
||||
)
|
||||
val b = out.best ?: continue
|
||||
val nw = b.idx
|
||||
if (nw == r.post || lumenReturnsAfter(chk, nw, ls, le, run)) continue
|
||||
if (postPromAt(lt, nw, ls, le) < postPromAt(lt, r.post, ls, le)) continue
|
||||
if (lt.size - 1 - nw < params.minPostOutward) continue // far-field tail FP
|
||||
if ((nw - r.ant - 1) < params.minUrineLen) continue
|
||||
r.post = nw
|
||||
r.postRefined = nw.toDouble()
|
||||
r.postType = b.type
|
||||
r.urineLen = nw - r.ant - 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ①.8 끝 채널 후벽 심부 복구 — Python `apply_edge_post_recovery`(ends='all').
|
||||
*
|
||||
* Λ-dip 은 **중간 채널**만 본다(양 이웃이 있어야 성립). 끝 채널은 후벽 검증이 전혀
|
||||
* 없어, 탐색창이 진짜 벽을 배제해도 아무도 잡지 못한다. 후벽 탐색창 끝은
|
||||
* `low_end + d_max + inward_walk_post` 인데, span 끝이 1 샘플만 흔들려도 창 끝이
|
||||
* 10 샘플 넘게 갈려 진짜 벽이 창 밖으로 나간다.
|
||||
*
|
||||
* ## 깊이는 진입 조건일 뿐이다
|
||||
* 끝 채널이 이웃 추세보다 얕은 것은 **아치 곡률의 정상 배치이기도 하다**. 그래서
|
||||
* 실제 판별은 prominence 가드가 한다 — 더 약한 후보로는 옮기지 않는다. 곡률로
|
||||
* 정상인 채널은 자기가 이미 최강이라 자연히 차단된다.
|
||||
*
|
||||
* ## 끝 채널과 중간 채널의 조건이 다르다
|
||||
* 끝 채널은 실패 모드가 **탐색창 배제**라 "창 밖 후보만"(needOutside) 조건을 건다.
|
||||
* 중간 채널은 후보가 창 **안**에 있는데 거리 페널티가 얕은 쪽을 올려 뒤집는 경우라,
|
||||
* 창밖 조건을 걸면 안 된다.
|
||||
*/
|
||||
private fun applyEdgePostRecovery(
|
||||
results: MutableList<MethodDResult?>,
|
||||
angles: DoubleArray,
|
||||
light: List<DoubleArray>,
|
||||
heavy: List<DoubleArray>,
|
||||
raw6ch: List<DoubleArray>,
|
||||
params: MethodDParams,
|
||||
dipTol: Double = VDIP_DIP_TOL,
|
||||
) {
|
||||
val det = AlignmentConstants.CENTER_CH
|
||||
.filter { it < results.size && results[it] != null }
|
||||
.sorted()
|
||||
if (det.size < 2) return
|
||||
|
||||
fun z(i: Int) = zOf(angles, i, results[i]!!.post)
|
||||
|
||||
// (대상 채널, 목표 깊이, 창밖 요구 여부)
|
||||
val pairs = ArrayList<Triple<Int, Double, Boolean>>()
|
||||
run { // bottom 끝
|
||||
val j = det[det.size - 1]; val nb = det[det.size - 2]
|
||||
var cons = z(nb)
|
||||
// 끝채널 목표를 단일 이웃 값에 고정하면 그 이웃의 1-sample jitter 가
|
||||
// closer gate 를 뒤집는다. 두 번째 이웃이 있으면 추세를 외삽한다.
|
||||
if (EDGE_POST_TREND && det.size >= 3) {
|
||||
cons += kmin(maxOf(z(nb) - z(det[det.size - 3]), 0.0), VDIP_REACH_MARGIN)
|
||||
}
|
||||
pairs.add(Triple(j, cons, true))
|
||||
}
|
||||
run { // top 끝
|
||||
val j = det[0]; val nb = det[1]
|
||||
var cons = z(nb)
|
||||
if (EDGE_POST_TREND && det.size >= 3) {
|
||||
cons += kmin(maxOf(z(nb) - z(det[2]), 0.0), VDIP_REACH_MARGIN)
|
||||
}
|
||||
pairs.add(Triple(j, cons, true))
|
||||
}
|
||||
for (k in 1 until det.size - 1) { // 중간 채널: 더 깊은 인접 이웃을 목표로
|
||||
val j = det[k]
|
||||
val nb = if (z(det[k - 1]) >= z(det[k + 1])) det[k - 1] else det[k + 1]
|
||||
pairs.add(Triple(j, z(nb), false))
|
||||
}
|
||||
|
||||
for ((j, cons, needOutside) in pairs) {
|
||||
if (z(j) >= cons - dipTol) continue // 이웃 추세보다 tol 넘게 얕을 때만
|
||||
val r = results[j] ?: continue
|
||||
val ang = if (j < angles.size) angles[j] else 0.0
|
||||
val new = vdipDeepCandidate(
|
||||
light[j], heavy[j], raw6ch[j], r.ant, r.post, ang,
|
||||
r.lowStart, r.lowEnd, cons, params
|
||||
) ?: continue
|
||||
if (new <= r.post) continue
|
||||
// 검출기가 **볼 수 없었던** 벽만 복구한다. 후보가 탐색창 안이었다면 검출기가
|
||||
// 보고도 점수로 밀어낸 것이므로 뒤집지 않는다.
|
||||
if (needOutside && new <= r.lowEnd + params.dMax + r.inwardWalkPost) continue
|
||||
if (postPromAt(light[j], new, r.lowStart, r.lowEnd) <
|
||||
postPromAt(light[j], r.post, r.lowStart, r.lowEnd)) continue
|
||||
val zNew = zAt(ang, new)
|
||||
// strict closer 는 1-sample 변화만으로 같은 심부후보를 채택/기각했다.
|
||||
// 2mm 이내 열세는 동등으로 보고 prominence 증거를 따른다.
|
||||
if (abs(zNew - cons) > abs(z(j) - cons) + EDGE_CLOSER_TOL_MM ||
|
||||
zNew > cons + VDIP_REACH_MARGIN) continue
|
||||
r.post = new
|
||||
r.postRefined = new.toDouble()
|
||||
r.urineLen = new - r.ant - 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ①.9 채널 합의 포락선 안에서 전벽·후벽 재탐색 — Python `apply_envelope_research`.
|
||||
*
|
||||
* 검출기의 탐색창은 span 기준이다(전벽 `span_s − ant_d_max − inward_walk`,
|
||||
* 후벽 `low_end + d_max + inward_walk`). span 이 늦게/일찍 잡히면 **진짜 벽이 창
|
||||
* 밖**이 되어 후보에조차 못 든다. 다른 채널들의 합의 포락선까지 창을 넓혀 그 경우만
|
||||
* 구제한다.
|
||||
*
|
||||
* [ENVELOPE_TRIM]: 포락선을 min/max 로 잡으면 채널 하나가 틀렸을 때 범위가 통째로
|
||||
* 끌려간다. CCC 맨 앞이라 아직 아무 재탐색도 거치지 않은 원 검출이어서 그 노출이
|
||||
* 크므로, 채널이 3개 이상이면 **최극단 하나를 빼고** 잡는다.
|
||||
*
|
||||
* 전벽 교체 가드가 특이하다: **현재 벽이 base 후보 중 최고점이면 교체하지 않는다.**
|
||||
* 이 단계의 취지는 "진짜 벽이 창 밖이라 후보에 아예 없던" 경우를 구제하는 것인데,
|
||||
* 이미 최고점이면 그 경우가 아니다. 진폭차 가드만 쓰면 얕을수록 유리해져 근거리
|
||||
* ringdown 이 진짜 벽을 이긴다.
|
||||
*/
|
||||
private fun applyEnvelopeResearch(
|
||||
results: MutableList<MethodDResult?>,
|
||||
params: MethodDParams,
|
||||
margin: Int = ENVELOPE_MARGIN,
|
||||
) {
|
||||
val det = AlignmentConstants.CENTER_CH.filter { it < results.size && results[it] != null }
|
||||
if (det.isEmpty()) return
|
||||
|
||||
if (det.size == 1) {
|
||||
// 교차채널 합의가 없어 포락선을 못 만든다. 전벽 창만 넓혀 재탐색한다.
|
||||
// (후벽은 ①.95 lumen-return 재탐색이 채널 수와 무관하게 처리한다.)
|
||||
val r = results[det[0]]!!
|
||||
val ls = r.lowStart; val le = r.lowEnd
|
||||
if (le <= ls) return
|
||||
val lt = r.sgLight
|
||||
val out = MethodDWallSelect.findWallPeakLocal(
|
||||
lt, ls, le, MethodDWallSelect.Side.ANT, params,
|
||||
dMaxOverride = ENVELOPE_NCH1_ANT_DMAX, inwardWalk = r.inwardWalkAnt,
|
||||
)
|
||||
val b = out.best ?: return
|
||||
val na = b.idx
|
||||
if (na != r.ant &&
|
||||
postPromAt(lt, na, ls, le) >= postPromAt(lt, r.ant, ls, le) &&
|
||||
(r.post - na - 1) >= params.minUrineLen) {
|
||||
r.ant = na
|
||||
r.antRefined = na.toDouble()
|
||||
r.antType = b.type
|
||||
r.urineLen = r.post - na - 1
|
||||
}
|
||||
return
|
||||
}
|
||||
if (det.size < 2) return
|
||||
|
||||
val posts = det.map { results[it]!!.post }.sorted()
|
||||
val ants = det.map { results[it]!!.ant }.sorted()
|
||||
val k = if (ENVELOPE_TRIM && det.size >= 3) 1 else 0
|
||||
val postMax = posts[posts.size - 1 - k] + margin
|
||||
val antMin = ants[k] - margin
|
||||
|
||||
for (i in det) {
|
||||
val r = results[i] ?: continue
|
||||
val ls = r.lowStart; val le = r.lowEnd
|
||||
if (le <= ls) continue
|
||||
val lt = r.sgLight
|
||||
|
||||
// ── 전벽: 창을 [antMin, span_s] 로 넓혀 재탐색 ──
|
||||
val iwA = r.inwardWalkAnt
|
||||
val needA = ls - antMin - iwA
|
||||
if (needA > params.antDMax) {
|
||||
val out = MethodDWallSelect.findWallPeakLocal(
|
||||
lt, ls, le, MethodDWallSelect.Side.ANT, params,
|
||||
dMaxOverride = needA, inwardWalk = iwA,
|
||||
)
|
||||
val b = out.best
|
||||
if (b != null) {
|
||||
val na = b.idx
|
||||
// 현재 벽이 base 후보 중 최고점이면 교체하지 않는다(위 KDoc 참조).
|
||||
val cb = r.antCandidates
|
||||
val keep = cb.isNotEmpty() &&
|
||||
r.ant == cb.maxByOrNull { it.second }!!.first
|
||||
if (na != r.ant && !keep &&
|
||||
postPromAt(lt, na, ls, le) >= postPromAt(lt, r.ant, ls, le) &&
|
||||
(r.post - na - 1) >= params.minUrineLen) {
|
||||
r.ant = na
|
||||
r.antRefined = na.toDouble()
|
||||
r.antType = b.type
|
||||
r.urineLen = r.post - na - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 후벽: 창을 [span_e, postMax] 로 넓혀 재탐색 ──
|
||||
val iw = r.inwardWalkPost
|
||||
val need = postMax - le - iw
|
||||
if (need <= params.dMax) continue // 이미 창 안 — 재탐색 불필요
|
||||
val out = MethodDWallSelect.findWallPeakLocal(
|
||||
lt, ls, le, MethodDWallSelect.Side.POST, params,
|
||||
dMaxOverride = need, inwardWalk = iw,
|
||||
)
|
||||
val best = out.best ?: continue
|
||||
val new = best.idx
|
||||
if (new == r.post) continue
|
||||
if (postPromAt(lt, new, ls, le) < postPromAt(lt, r.post, ls, le)) continue
|
||||
if ((new - r.ant - 1) < params.minUrineLen) continue
|
||||
r.post = new
|
||||
r.postRefined = new.toDouble()
|
||||
r.postType = best.type
|
||||
r.urineLen = new - r.ant - 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ③ FP 채널 기각 — Python `apply_fp_channel_gate`.
|
||||
*
|
||||
* 양쪽 벽이 **동시에** 이웃 median 보다 [FP_GATE_DZ_MIN] 넘게 깊고, **동시에**
|
||||
* prominence 비가 [FP_GATE_PROM_MAX] 미만인 채널을 버린다. 한쪽만 어긋난 것은
|
||||
* 재탐색 대상이므로 AND 조건이 본질이다. 검출이 3채널 미만이면 기준이 불안정해
|
||||
* 발동하지 않는다.
|
||||
*
|
||||
* peer 는 **전 6채널**이다. 측방(CH4·CH5)이 x 축으로 ±10mm 벗어나 있어 일관성
|
||||
* 면에서는 어색하지만, peer 가 3 → 5 개로 늘어 median 이 안정된다(레퍼런스 주석의
|
||||
* 실측 근거: CENTER_CH 로 제한하면 LOSO −0.16pp 로 전원 악화).
|
||||
*/
|
||||
private fun applyFpChannelGate(results: MutableList<MethodDResult?>, angles: DoubleArray) {
|
||||
val det = results.indices.filter { results[it] != null }
|
||||
if (det.size < 3) return
|
||||
val za = HashMap<Int, Double>()
|
||||
val zp = HashMap<Int, Double>()
|
||||
for (i in det) {
|
||||
val ang = if (i < angles.size) angles[i] else 0.0
|
||||
za[i] = zAt(ang, results[i]!!.ant)
|
||||
zp[i] = zAt(ang, results[i]!!.post)
|
||||
}
|
||||
val drop = ArrayList<Int>()
|
||||
for (i in det) {
|
||||
val peers = det.filter { it != i }
|
||||
if (peers.size < 2) continue
|
||||
val r = results[i]!!
|
||||
val pa = r.antProm / maxOf(medianOf(peers.map { results[it]!!.antProm }), 1.0)
|
||||
val pp = r.postProm / maxOf(medianOf(peers.map { results[it]!!.postProm }), 1.0)
|
||||
val da = za[i]!! - medianOf(peers.map { za[it]!! })
|
||||
val dp = zp[i]!! - medianOf(peers.map { zp[it]!! })
|
||||
if (pa < FP_GATE_PROM_MAX && pp < FP_GATE_PROM_MAX &&
|
||||
da > FP_GATE_DZ_MIN && dp > FP_GATE_DZ_MIN) drop.add(i)
|
||||
}
|
||||
for (i in drop) results[i] = null
|
||||
}
|
||||
|
||||
/** numpy `median` 과 같은 정의 — 짝수 개면 가운데 둘의 평균. */
|
||||
/** numpy `median` 과 같은 정의 — 짝수 개면 가운데 둘의 평균. */
|
||||
private fun medianOf(v: List<Double>): Double {
|
||||
if (v.isEmpty()) return 0.0
|
||||
val a = v.sorted()
|
||||
val n = a.size
|
||||
return if (n % 2 == 1) a[n / 2] else 0.5 * (a[n / 2 - 1] + a[n / 2])
|
||||
}
|
||||
|
||||
/**
|
||||
* 파이프라인 2 — 검출 실패 채널만 SG`window` 로 재탐색 (Python `apply_scale_recovery`).
|
||||
*
|
||||
* prod 전처리는 SG7 인데, 스케일 스윕에서 **SG5 가 검출률이 더 높다**는 것이
|
||||
* 확인됐다(SG7 이 약한 후벽 peak 의 prominence 를 일부 뭉갠다). 그런데 window 를
|
||||
* 전역으로 바꾸면 **모든 채널의 벽이 함께 이동해 부피가 통째로 밀린다** — window 는
|
||||
* 검출식으로 쓰되 측정식으로 쓰면 안 된다.
|
||||
*
|
||||
* 그래서 규칙이 좁다: **prod 에서 이미 검출된 채널은 절대 건드리지 않는다.** 실패한
|
||||
* 채널만 SG5 로 다시 본다. 게이트는 prod 파라미터를 그대로 써서 FP 는 strict 를
|
||||
* 유지한다. 얻는 것은 커버리지(nch)뿐이고 측정면은 불변이다.
|
||||
*/
|
||||
private fun applyScaleRecovery(
|
||||
results: MutableList<MethodDResult?>,
|
||||
raw6ch: List<DoubleArray>,
|
||||
angles: DoubleArray,
|
||||
params: MethodDParams,
|
||||
applyTgc: Boolean,
|
||||
window: Int = RECOVERY_WINDOW,
|
||||
) {
|
||||
val fails = results.indices.filter { results[it] == null }
|
||||
if (fails.isEmpty()) return
|
||||
|
||||
// 지정 window 로 heavy/light 재생성 (+TGC). prod 흐름과 같은 순서다.
|
||||
val heavy = raw6ch.map {
|
||||
MedianFilter.runningMedianRoot(
|
||||
SgSmoothGeneric.smooth(it, window, MethodDPreprocessing.SG_POLY),
|
||||
params.oscfarWin, params.oscfarMaxIters)
|
||||
}
|
||||
val light = raw6ch.map { SgSmoothGeneric.smooth(it, window, MethodDPreprocessing.SG_POLY) }
|
||||
val heavyT = if (applyTgc) MethodDTgc.applyTgcPipeline(heavy) else heavy
|
||||
val lightT = if (applyTgc) MethodDTgc.applyTgcPipeline(light) else light
|
||||
|
||||
for (i in fails) {
|
||||
val angleDeg = if (i < angles.size) angles[i] else 0.0
|
||||
val r = MethodDDetector.detect(
|
||||
raw = raw6ch[i],
|
||||
denoisedHeavy = heavyT[i],
|
||||
denoisedLight = lightT[i],
|
||||
otsuRatioOverride = params.otsuRatio * cos(Math.toRadians(angleDeg)),
|
||||
params = params,
|
||||
)
|
||||
if (r != null) results[i] = r
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* center 채널별 신뢰도 {ch: 0~1} — Python `channel_evidence.channel_confidence`.
|
||||
* 검출 채널이 2개 미만이면 null.
|
||||
*
|
||||
* 값 = **비-TGC** `min(ant_amp, post_amp) / lumen_min` 을 그 trace 최고값으로 나눈 것.
|
||||
*
|
||||
* 비-TGC 로 재는 이유: TGC 가 원거리 이득을 올려 **약한 채널을 정상 채널에 붙여
|
||||
* 놓는다**(레퍼런스 실측: CH1↔CH3 간격이 TGC 0.05 vs 비-TGC 0.21). 절대값이 아니라
|
||||
* trace 안의 상대값이라 피험자·기기·깊이별 신호 세기 차가 정규화된다.
|
||||
*/
|
||||
private fun channelConfidence(
|
||||
results: List<MethodDResult?>,
|
||||
raw6ch: List<DoubleArray>,
|
||||
): Map<Int, Double>? {
|
||||
val rr = HashMap<Int, Double>()
|
||||
for (ch in 0 until kmin(4, results.size)) {
|
||||
val d = results[ch] ?: continue
|
||||
val ls = d.lowStart; val le = d.lowEnd
|
||||
if (le <= ls) continue
|
||||
val rh = MethodDPreprocessing.preprocessHeavy(raw6ch[ch])
|
||||
val rl = MethodDPreprocessing.preprocessLight(raw6ch[ch])
|
||||
val lm = maxOf(minInRange(rh, ls, le), 1.0)
|
||||
val ai = d.ant; val pi = d.post
|
||||
if (ai < 0 || ai >= rl.size || pi < 0 || pi >= rl.size) continue
|
||||
rr[ch] = kmin(rl[ai], rl[pi]) / lm
|
||||
}
|
||||
if (rr.size < 2) return null
|
||||
val mx = rr.values.max()
|
||||
if (mx <= 0.0) return null
|
||||
return rr.mapValues { it.value / mx }
|
||||
}
|
||||
|
||||
/**
|
||||
* 파이프라인 4 — 미검출 채널 중 **low echo span 조차 없는** 채널 집합.
|
||||
* Python `channel_evidence.missing_outside`.
|
||||
*
|
||||
* span 이 없다 = 그 빔에 방광이 아예 없다("진짜 방광 밖"). 이 판정이 극 cap 처리의
|
||||
* **상한/하한 방향**을 가른다:
|
||||
* · 방광 밖 → 그 빔 위치를 cap 높이의 **상한**으로(종전 동작)
|
||||
* · span 있음 → 방광이 거기까지는 온다 → 같은 지점을 **하한**으로(외삽을 관측으로 대체)
|
||||
*
|
||||
* 레퍼런스 실측상 "미검출 = 방광 밖"이 실제로 맞는 경우는 silicon 17.2% / abs 3.5%
|
||||
* 뿐이다. 나머지는 span 이 있는데 벽만 못 잡은 것이라, 상한으로만 쓰면 cap 을 부당하게
|
||||
* 잘라낸다.
|
||||
*
|
||||
* **검출된 채널은 대상이 아니다** — 벽이 있었으니 span 도 있다.
|
||||
*
|
||||
* @param light TGC 적용된 light 신호 (검출에 쓴 것과 같은 것)
|
||||
* @param heavy TGC 적용된 heavy 신호
|
||||
*/
|
||||
fun missingOutside(
|
||||
results: List<MethodDResult?>,
|
||||
light: List<DoubleArray>,
|
||||
heavy: List<DoubleArray>,
|
||||
angles: DoubleArray,
|
||||
params: MethodDParams = MethodDParams.DEFAULT,
|
||||
): Set<Int> {
|
||||
val outside = HashSet<Int>()
|
||||
for (ch in results.indices) {
|
||||
if (results[ch] != null) continue
|
||||
val th = if (ch < angles.size) Math.toRadians(angles[ch]) else 0.0
|
||||
val sp = MethodDSpan.extractLowEchoSpanWithFallback(
|
||||
heavy[ch], light[ch], params.otsuRatio * cos(th), params)
|
||||
if (sp == null) outside.add(ch)
|
||||
}
|
||||
return outside
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -40,6 +40,11 @@ data class MethodDParams(
|
||||
val oscfarMaxIters: Int = 4,
|
||||
val minLightSeparability: Double = 0.75,
|
||||
val minSpanLen: Int = 5,
|
||||
/**
|
||||
* 후벽 far-field tail FP 게이트 — 후벽 뒤에 남은 샘플이 이보다 적으면 tail ramp 로
|
||||
* 보고 차단한다. Python `config_wd.min_post_outward`. 레퍼런스 경로에서만 쓰인다.
|
||||
*/
|
||||
val minPostOutward: Int = 6,
|
||||
val recoveryExtendOutward: Boolean = false,
|
||||
val wallRatioUsePostOnly: Boolean = true,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import com.medithings.vesiscan.walldetect.MethodDRunner
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* [AlgoMode] 토글이 실제로 배선되어 있는지 확인한다.
|
||||
*
|
||||
* 기존 경로가 HEAD 와 같다는 것은 [LegacyEquivalenceDumpTest] 가 덤프 대조로 증명한다.
|
||||
* 여기서 보는 것은 그 반대편이다 — 레퍼런스를 켰을 때 **정말로 다른 경로가 돌고**,
|
||||
* 끄면 원래대로 돌아오는지. 켰는데 값이 같으면 배선이 끊긴 것이므로 실패한다.
|
||||
*/
|
||||
class AlgoModeSwitchTest {
|
||||
|
||||
@After fun restore() { AlgoMode.reference = false }
|
||||
|
||||
private fun res(name: String) =
|
||||
javaClass.classLoader!!.getResourceAsStream(name)!!.bufferedReader().use { it.readText() }
|
||||
|
||||
private fun loadCycles(name: String): Map<Int, List<DoubleArray>> {
|
||||
val out = HashMap<Int, MutableList<Pair<Int, DoubleArray>>>()
|
||||
for (line in res(name).lineSequence()) {
|
||||
val f = line.trim().split(",")
|
||||
if (f.size < 3) continue
|
||||
val cyc = f[0].toIntOrNull() ?: continue
|
||||
val ch = f[1].toIntOrNull() ?: continue
|
||||
out.getOrPut(cyc) { ArrayList() }
|
||||
.add(ch to DoubleArray(f.size - 2) { f[it + 2].trim().toDouble() })
|
||||
}
|
||||
return out.mapValues { (_, v) -> v.toMap().let { m -> (0..5).map { m[it] ?: DoubleArray(0) } } }
|
||||
}
|
||||
|
||||
/** cycle 마다 "벽 + BV + missing_outside" 를 한 줄로. */
|
||||
private fun run(): List<String> {
|
||||
val out = ArrayList<String>()
|
||||
for (cm in 0..1) {
|
||||
val cycles = loadCycles("align_cm$cm.csv")
|
||||
for (cyc in cycles.keys.sorted()) {
|
||||
val o = MethodDRunner.detectMultichannelWithEvidence(cycles[cyc]!!, applyCross = true)
|
||||
val walls = o.walls.map { r ->
|
||||
r?.let { WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) }
|
||||
}
|
||||
val bv = estimateBv(walls, missingOutside = o.missingOutside.ifEmpty { null })
|
||||
out.add("$cm/$cyc " + o.walls.joinToString(",") { r ->
|
||||
r?.let { "${it.ant}-${it.post}" } ?: "-"
|
||||
} + " mo=${o.missingOutside.sorted()} bv=${"%.4f".format(bv?.volumeMl ?: -1.0)}")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `레퍼런스 토글이 검출·BV 경로를 실제로 바꾼다`() {
|
||||
AlgoMode.reference = false
|
||||
val legacy = run()
|
||||
AlgoMode.reference = true
|
||||
val reference = run()
|
||||
|
||||
assertEquals("두 경로가 같은 수의 cycle 을 처리해야 한다", legacy.size, reference.size)
|
||||
assertTrue("픽스처가 비어 있다", legacy.size >= 40)
|
||||
|
||||
// 기존 경로는 missing_outside 를 산출하지 않는다(항상 빈 집합).
|
||||
assertTrue("기존 경로가 missing_outside 를 내면 안 된다",
|
||||
legacy.all { "mo=[]" in it })
|
||||
// 레퍼런스 경로는 파이프라인 4단계에서 증거를 산출한다.
|
||||
assertTrue("레퍼런스 경로가 missing_outside 를 전혀 못 냈다 — 4단계 미배선",
|
||||
reference.any { "mo=[]" !in it })
|
||||
|
||||
assertNotEquals("레퍼런스를 켰는데 결과가 같다 — 토글이 배선되지 않았다",
|
||||
legacy, reference)
|
||||
|
||||
// 되돌리면 원래 값으로 복귀해야 한다(전역 상태가 남지 않는지).
|
||||
AlgoMode.reference = false
|
||||
assertEquals("모드를 끈 뒤 기존 경로가 재현되지 않는다", legacy, run())
|
||||
|
||||
val diff = legacy.indices.count { legacy[it] != reference[it] }
|
||||
println("[AlgoMode] cycle ${legacy.size} 중 ${diff} 개가 갈림")
|
||||
}
|
||||
}
|
||||
@@ -10,18 +10,25 @@ import java.io.File
|
||||
* 실행:
|
||||
* ./gradlew testDevDebugUnitTest --tests "*CenterAlignerValidationTest*"
|
||||
*
|
||||
* Python 기준값 (scratchpad/v3_python_records.json, cf. v3_python_reference.py):
|
||||
* cm=0: nch=3, ch3=X, hit=2/11, bv_cv=0.183
|
||||
* cm=1: nch=4, ch3=O, hit=7/11, bv_cv=0.260
|
||||
* cm=3: nch=3, ch3=O, hit=10/11, bv_cv=0.130
|
||||
* 기준값은 **현행 레퍼런스**(`vesiscan_test.library.alignment_runners.AnchorGuide`,
|
||||
* ver='v1')를 같은 세션에 돌려 받은 값이다:
|
||||
* cm=0: nch=3, ch3=X, hit=2/11
|
||||
* cm=1: nch=4, ch3=O, hit=8/11
|
||||
* cm=3: nch=3, ch3=O, hit=11/11
|
||||
* selected_cm = 3 (Rule A: 80% 임계 통과는 3cm 만 → max nch → 3cm)
|
||||
*
|
||||
* 종전 기대값(hit 7/11 · 10/11 · bv_cv 0.183/0.260/0.130)은 **구** Python 레퍼런스
|
||||
* (`v3_python_reference.py`, 현재 저장소에 없음) 기준이라 더 이상 맞지 않는다.
|
||||
* 검출 파라미터(`d_max` 15 · `inward_walk_win` 4)와 cap 축 폴백이 들어오면서
|
||||
* 값이 움직였고, 그 방향은 현행 레퍼런스와 일치하는 쪽이다. 픽스처 경로가
|
||||
* 깨져 있어 그동안 이 테스트가 돌지 않았다 — 그래서 뒤늦게 드러났다.
|
||||
*/
|
||||
class CenterAlignerValidationTest {
|
||||
|
||||
private val sessions = mapOf(
|
||||
0 to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_0CM_2026-07-06_164150.json",
|
||||
1 to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_1CM_2026-07-06_164231.json",
|
||||
3 to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26040302_SUPINE_ALIGN_3CM_2026-07-06_164842.json",
|
||||
0 to "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_0CM_2026-07-06_164150.json",
|
||||
1 to "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_1CM_2026-07-06_164231.json",
|
||||
3 to "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26040302_SUPINE_ALIGN_3CM_2026-07-06_164842.json",
|
||||
)
|
||||
|
||||
private fun loadCycles(path: String, maxN: Int = 20): List<List<DoubleArray>> {
|
||||
@@ -78,7 +85,7 @@ class CenterAlignerValidationTest {
|
||||
println("target = cm=${it.alignCm}, nch=${it.nch}, bv_cv=${"%.3f".format(it.bvCv ?: Double.NaN)}")
|
||||
}
|
||||
|
||||
// ── 어설션: Python reference (v3_python_records.json 기준) ──
|
||||
// ── 어설션: 현행 레퍼런스 AnchorGuide(ver='v1') 산출값 ──
|
||||
val recs = result.records.associateBy { it.alignCm }
|
||||
|
||||
// cm=0: nch=3, ch3=X, hit=2/11
|
||||
@@ -87,30 +94,29 @@ class CenterAlignerValidationTest {
|
||||
assert(recs[0]?.ch3Tot == 11) { "cm=0 ch3_tot expected 11, got ${recs[0]?.ch3Tot}" }
|
||||
assert(recs[0]?.ch3Hit == 2) { "cm=0 ch3_hit expected 2, got ${recs[0]?.ch3Hit}" }
|
||||
|
||||
// cm=1: nch=4, ch3=O, hit=7/11
|
||||
// cm=1: nch=4, ch3=O, hit=8/11
|
||||
assert(recs[1]?.nch == 4) { "cm=1 nch expected 4, got ${recs[1]?.nch}" }
|
||||
assert(recs[1]?.ch3Detected == true) { "cm=1 ch3 expected O (true)" }
|
||||
assert(recs[1]?.ch3Hit == 7) { "cm=1 ch3_hit expected 7, got ${recs[1]?.ch3Hit}" }
|
||||
assert(recs[1]?.ch3Hit == 8) { "cm=1 ch3_hit expected 8, got ${recs[1]?.ch3Hit}" }
|
||||
|
||||
// cm=3: nch=3, ch3=O, hit=10/11 (80% 임계 통과)
|
||||
// cm=3: nch=3, ch3=O, hit=11/11 (80% 임계 통과)
|
||||
assert(recs[3]?.nch == 3) { "cm=3 nch expected 3, got ${recs[3]?.nch}" }
|
||||
assert(recs[3]?.ch3Detected == true) { "cm=3 ch3 expected O (true)" }
|
||||
assert(recs[3]?.ch3Hit == 10) { "cm=3 ch3_hit expected 10, got ${recs[3]?.ch3Hit}" }
|
||||
assert(recs[3]?.ch3Hit == 11) { "cm=3 ch3_hit expected 11, got ${recs[3]?.ch3Hit}" }
|
||||
|
||||
// bv_cv 허용 오차 :
|
||||
// Python 완전 bit-perfect 일치 달성 (data123 cm=3 11-trace 모두 Δ=0.000 mL).
|
||||
// post_outlier_filter fix (Kotlin 이 무조건 실행하던 것을 Python default false 로
|
||||
// 맞춤) 로 CH0 부당 drop 해소.
|
||||
// 3 위치 (0/1/3 cm) bv_cv 모두 Python 과 소수점 3자리 이상 일치 → tolerance
|
||||
// 0.001 로 강화.
|
||||
assert(recs[0]?.bvCv != null && kotlin.math.abs((recs[0]!!.bvCv!!) - 0.183) < 0.001) {
|
||||
"cm=0 bv_cv expected 0.183±0.001, got ${recs[0]?.bvCv}"
|
||||
// bv_cv — **회귀 고정**이지 레퍼런스 대조가 아니다.
|
||||
// 현행 레퍼런스의 `AnchorGuide` 는 bv_cv 를 내지 않고(cap_frac 으로 바뀌었다),
|
||||
// 이 값을 내던 구 레퍼런스는 저장소에 남아 있지 않다. 그래서 "Python 과
|
||||
// bit-exact" 라고 적어 둔 종전 주석은 지금 근거가 없다 — 걱어낸다.
|
||||
// 남기는 이유는 V3 경로가 조용히 움직이는 것을 잡기 위해서다.
|
||||
assert(recs[0]?.bvCv != null && kotlin.math.abs((recs[0]!!.bvCv!!) - 0.167) < 0.001) {
|
||||
"cm=0 bv_cv expected 0.167±0.001, got ${recs[0]?.bvCv}"
|
||||
}
|
||||
assert(recs[1]?.bvCv != null && kotlin.math.abs((recs[1]!!.bvCv!!) - 0.260) < 0.001) {
|
||||
"cm=1 bv_cv expected 0.260±0.001, got ${recs[1]?.bvCv}"
|
||||
assert(recs[1]?.bvCv != null && kotlin.math.abs((recs[1]!!.bvCv!!) - 0.211) < 0.001) {
|
||||
"cm=1 bv_cv expected 0.211±0.001, got ${recs[1]?.bvCv}"
|
||||
}
|
||||
assert(recs[3]?.bvCv != null && kotlin.math.abs((recs[3]!!.bvCv!!) - 0.130) < 0.001) {
|
||||
"cm=3 bv_cv expected 0.130±0.001, got ${recs[3]?.bvCv}"
|
||||
assert(recs[3]?.bvCv != null && kotlin.math.abs((recs[3]!!.bvCv!!) - 0.096) < 0.001) {
|
||||
"cm=3 bv_cv expected 0.096±0.001, got ${recs[3]?.bvCv}"
|
||||
}
|
||||
|
||||
// 선택 위치: Rule A → cm=3 (80% 임계 통과한 유일 위치)
|
||||
|
||||
@@ -14,7 +14,7 @@ import kotlin.math.sqrt
|
||||
*/
|
||||
class Cm3PerTraceDumpTest {
|
||||
|
||||
private val session = "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26040302_SUPINE_ALIGN_3CM_2026-07-06_164842.json"
|
||||
private val session = "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26040302_SUPINE_ALIGN_3CM_2026-07-06_164842.json"
|
||||
private val outPath = "C:/Users/장동우/AppData/Local/Temp/claude/c--Projects-medilightv2android/76311e3b-2bb5-4c52-ab8a-8d89cb1052c2/scratchpad/cm3_kotlin.json"
|
||||
|
||||
private fun loadCycles(path: String, maxN: Int = 20): List<List<DoubleArray>> {
|
||||
|
||||
@@ -19,9 +19,9 @@ import java.io.File
|
||||
class GoldenDumpTest {
|
||||
|
||||
private val sessions = mapOf(
|
||||
"cm0" to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_0CM_2026-07-06_164150.json",
|
||||
"cm1" to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_1CM_2026-07-06_164231.json",
|
||||
"cm3" to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26040302_SUPINE_ALIGN_3CM_2026-07-06_164842.json",
|
||||
"cm0" to "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_0CM_2026-07-06_164150.json",
|
||||
"cm1" to "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_1CM_2026-07-06_164231.json",
|
||||
"cm3" to "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26040302_SUPINE_ALIGN_3CM_2026-07-06_164842.json",
|
||||
)
|
||||
private val outPath = "C:/Users/장동우/AppData/Local/Temp/claude/c--Projects-medilightv2android/76311e3b-2bb5-4c52-ab8a-8d89cb1052c2/scratchpad/golden_kotlin.json"
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import com.medithings.vesiscan.walldetect.MethodDRunner
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* 기존(팬텀) 경로가 이번 이식으로 **한 글자도 안 바뀌었는지** 실증하는 덤프 하네스.
|
||||
*
|
||||
* 검출 → BV 전 구간을 `align_cm{0,1}.csv` 의 모든 cycle 에 대해 돌려 결과를 파일로
|
||||
* 떨군다. HEAD 코드와 작업트리 코드에서 각각 돌린 덤프가 **바이트 단위로 같으면**
|
||||
* 기존 경로 무변경이 증명된다.
|
||||
*
|
||||
* 이 파일은 [AlgoMode] 를 참조하지 않는다 — HEAD 에는 그 타입이 없기 때문이다.
|
||||
* 기본값이 기존 경로이므로 기본 호출만으로 그 경로가 그대로 실행된다.
|
||||
*/
|
||||
class LegacyEquivalenceDumpTest {
|
||||
|
||||
private fun res(name: String) =
|
||||
javaClass.classLoader!!.getResourceAsStream(name)!!.bufferedReader().use { it.readText() }
|
||||
|
||||
/** cycle → 6채널 신호 (`cycle,channel,s0..s99`). */
|
||||
private fun loadCycles(name: String): Map<Int, List<DoubleArray>> {
|
||||
val out = HashMap<Int, MutableList<Pair<Int, DoubleArray>>>()
|
||||
for (line in res(name).lineSequence()) {
|
||||
val t = line.trim()
|
||||
if (t.isEmpty()) continue
|
||||
val f = t.split(",")
|
||||
if (f.size < 3) continue
|
||||
val cyc = f[0].toIntOrNull() ?: continue
|
||||
val ch = f[1].toIntOrNull() ?: continue
|
||||
val sig = DoubleArray(f.size - 2) { f[it + 2].trim().toDouble() }
|
||||
out.getOrPut(cyc) { ArrayList() }.add(ch to sig)
|
||||
}
|
||||
return out.mapValues { (_, v) ->
|
||||
val byCh = v.toMap()
|
||||
(0..5).map { byCh[it] ?: DoubleArray(0) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dump() {
|
||||
val sb = StringBuilder()
|
||||
for (cm in 0..1) {
|
||||
val cycles = loadCycles("align_cm$cm.csv")
|
||||
for (cyc in cycles.keys.sorted()) {
|
||||
val sig = cycles[cyc]!!
|
||||
for (cross in listOf(false, true)) {
|
||||
val dets = MethodDRunner.detectMultichannel(sig, applyCross = cross)
|
||||
val walls = dets.map { r ->
|
||||
r?.let { WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) }
|
||||
}
|
||||
val bv = estimateBv(walls)
|
||||
sb.append("cm$cm cyc$cyc cross=$cross")
|
||||
dets.forEachIndexed { i, r ->
|
||||
sb.append(if (r == null) " CH$i=-" else
|
||||
" CH$i=%d/%d/%.4f/%.4f/%d/%d".format(
|
||||
r.ant, r.post, r.antRefined, r.postRefined, r.lowStart, r.lowEnd))
|
||||
}
|
||||
sb.append(if (bv == null) " BV=-" else
|
||||
" BV=%.6f lr=%.6f nch=%d topH=%.4f botH=%.4f vT=%.4f vB=%.4f".format(
|
||||
bv.volumeMl, bv.lrRatio, bv.validChannels.size,
|
||||
bv.topHMm, bv.bottomHMm, bv.vTopMm3, bv.vBottomMm3))
|
||||
sb.append('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
val out = File(System.getProperty("legacyDump") ?: "build/legacy_dump.txt")
|
||||
out.parentFile?.mkdirs()
|
||||
out.writeText(sb.toString())
|
||||
println("[dump] ${out.absolutePath} lines=${sb.lines().size}")
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,9 @@ import java.io.File
|
||||
class MethodDBvWrapperParityTest {
|
||||
|
||||
private val sessions = mapOf(
|
||||
"cm0" to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_0CM_2026-07-06_164150.json",
|
||||
"cm1" to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_1CM_2026-07-06_164231.json",
|
||||
"cm3" to "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26040302_SUPINE_ALIGN_3CM_2026-07-06_164842.json",
|
||||
"cm0" to "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_0CM_2026-07-06_164150.json",
|
||||
"cm1" to "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_1CM_2026-07-06_164231.json",
|
||||
"cm3" to "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26040302_SUPINE_ALIGN_3CM_2026-07-06_164842.json",
|
||||
)
|
||||
|
||||
private fun loadCycles(path: String, maxN: Int = 20): List<List<DoubleArray>> {
|
||||
|
||||
@@ -18,7 +18,7 @@ import java.io.File
|
||||
*/
|
||||
class PrecisionDumpTest {
|
||||
|
||||
private val session = "c:/Projects/medilightv2android/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_1CM_2026-07-06_164231.json"
|
||||
private val session = "c:/Projects/vesiscan-design-archive/data123/dwjang_HUMAN-kai_VBT26050202_SUPINE_ALIGN_1CM_2026-07-06_164231.json"
|
||||
private val outPath = "C:/Users/장동우/AppData/Local/Temp/claude/c--Projects-medilightv2android/76311e3b-2bb5-4c52-ab8a-8d89cb1052c2/scratchpad/precision_kotlin.json"
|
||||
|
||||
private fun loadFirstTrace(path: String, win: Int = 10): List<DoubleArray> {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# 알고리즘 경로 두 갈래 (`AlgoMode`)
|
||||
|
||||
demo-final 은 팬텀 시연과 임상 계측이 함께 도는 브랜치다. 여기에 신 저장소
|
||||
(`vesiscan_pre_product/vesiscan_android_user`)가 레퍼런스
|
||||
(`piezo-phantom-test/vesiscan_test`)와 1:1 로 맞춘 검출·BV 단계들을 옮기되,
|
||||
**기본 동작은 그대로 두고** dev 패널에서만 켤 수 있게 했다.
|
||||
|
||||
- 코드: [`managers/AlgoMode.kt`](../app/src/main/java/com/medithings/vesiscan/managers/AlgoMode.kt)
|
||||
- UI: 계측 화면 dev 패널 → **알고리즘** 스위치 (기본 꺼짐)
|
||||
- 상태는 프로세스 수명 동안만 유지된다. 앱을 다시 켜면 기존 경로로 돌아간다.
|
||||
|
||||
## "기존" 이 가리키는 것
|
||||
|
||||
`ebddc52`(검출·BV 레퍼런스 정합) 와 `20a48ad`(실리콘 cap 억제) 가 들어간 **현재
|
||||
demo-final HEAD** 다. 2026-09-03 임상이 이 코드 위에서 돌았으므로, 그 값을 움직이는
|
||||
변경은 기본 경로에 넣지 않는다.
|
||||
|
||||
따라서 아래 항목들은 **양쪽 공통**이며 모드와 무관하다:
|
||||
|
||||
| 항목 | 값 | 들어간 커밋 |
|
||||
|---|---|---|
|
||||
| `d_max` · `inward_walk_win` | 15 · 4 | `ebddc52` |
|
||||
| prominence 기준 | span 바닥 (`ANT_FLOOR_REF`) | `ebddc52` |
|
||||
| cap 축 붕괴 폴백 | `CAP_AXIS_RATIO_MAX` 0.85 / `CAP_AXIS_DMAX_MAX` 100 | `ebddc52` |
|
||||
| `ELLIPSE_CAP_R_MAX` · `TOP_CAP_EDGE_K` | 0.92 · 0.6 | `ebddc52` |
|
||||
| `cap_d_adaptive` (실리콘) | ("aspect", 80.0, 2.0) | `20a48ad` |
|
||||
| dps · delay | 프리셋 유도 (v\*=1.936/6.85, r\*=1.897/7.651) | 기존 |
|
||||
|
||||
## 스위치를 켜면 바뀌는 것
|
||||
|
||||
부분 적용은 없다. 단계들이 서로를 전제하므로 섞으면 어느 쪽도 아닌 값이 나온다.
|
||||
|
||||
### 1. 교차채널 보정 (CCC) — 3단계 → 7단계
|
||||
|
||||
| | 기존 | 레퍼런스 |
|
||||
|---|---|---|
|
||||
| 순서 | `ant_tiebreak` → NTV(Rule A+B) → `inward_post` | ①.9 `envelope_research` → ① `ant_tiebreak` → ①.7 Λ-dip → ①.8 `edge_post_recovery` → ①.95 `lumen_return_recovery` → ② NTV(**Rule A 만**) → ③ `fp_channel_gate` |
|
||||
| `inward_post` | 있음 | **없음** |
|
||||
| NTV Rule B | 있음 | 없음 |
|
||||
|
||||
레퍼런스 쪽 데이터에서 각 단계가 결과를 바꾼 횟수(파이썬 ablation, 1512 케이스):
|
||||
`ant_tiebreak` 158 · `edge_post_recovery` 73 · Λ-dip 7 · `fp_gate` 4 · 나머지 0.
|
||||
|
||||
### 2. 검출 파이프라인 — 4개 단계 추가
|
||||
|
||||
| 단계 | 하는 일 | 왜 |
|
||||
|---|---|---|
|
||||
| 2 `scale_recovery` | 검출 **실패** 채널만 SG5 로 재탐색 | window 를 전역으로 바꾸면 모든 벽이 함께 밀려 부피가 통째로 이동한다. 실패 채널만 건드려 커버리지(nch)만 얻는다. |
|
||||
| 4 `missing_outside` | span 조차 없는 채널 = 진짜 방광 밖 | **CCC 앞**에서 계산해야 한다. CCC 가 복구한 채널은 방광 밖이 아니다. |
|
||||
| 5b `channel_confidence` | 약한 채널을 CCC **복구 단계에서만** 숨김 | 판정(NTV·FP) 전에 되돌린다. 끝까지 숨기면 CCC 가 그 채널을 FP 로 기각할 기회를 잃어 오히려 살아남는다. |
|
||||
| 6 `CONF_DEMOTE` | CCC 후 여전히 약한 채널을 단면에서 제외 | 남는 채널이 3 미만이 되면 아무것도 하지 않는다. |
|
||||
|
||||
신뢰도는 **비-TGC** `min(ant_amp, post_amp) / lumen_min` 을 trace 최고값으로 나눈 값이다.
|
||||
TGC 로 재면 원거리 이득이 약한 채널을 정상 채널에 붙여 놓는다(CH1↔CH3 간격 TGC 0.05 vs
|
||||
비-TGC 0.21).
|
||||
|
||||
### 3. BV
|
||||
|
||||
- **`si_floor` / `missing_outside`** — 극 cap clamp 의 방향이 관측 증거로 갈린다.
|
||||
미검출 채널에 span 이 **없으면** 방광이 그 빔에 없다는 뜻이라 **상한**(종전),
|
||||
span 이 **있으면** 방광이 거기까지는 온다는 뜻이라 **하한**이다. 외삽을 관측으로
|
||||
바꾸는 것이다. `SI_FLOOR_WALK` 는 span 이 이어지는 가장 바깥 채널까지 걸어간다.
|
||||
- **bottom cap 제한 신설** — top 만 있고 bottom 은 제약이 전혀 없었다. sitting 에서
|
||||
CH3(치골)이 소실될 때 bottom cap 이 과외삽된다.
|
||||
- **중앙 채널 보간 은행가 반올림** — 레퍼런스는 `int(round((int(prev)+int(next))/2.0))`
|
||||
이다. 보간 채널은 subsample 정밀도를 가질 근거가 없다(이웃 둘의 정수 위치뿐).
|
||||
실측 f01 tr11: 이 차이로 CH2 단면이 2971.0 vs 2788.2 mm² 로 갈렸다.
|
||||
- **`adaptive_large_bladder_relax` 제거** — 레퍼런스에서 빠진 완화다. 남아 있는 동안
|
||||
`b_si` 를 0.85·`a_ap` 로 바닥 처리해 cap 축 비를 정확히 0.85 로 만들었고, 그래서 바로
|
||||
다음의 cap 축 폴백(`< 0.85`)이 **경계에서 발동하지 않았다**.
|
||||
실측 PHANTOM mean: Python 160.03 mL vs 완화가 살아 있는 코틀린 232.49 mL.
|
||||
|
||||
폴백 재계산에는 `missing_outside`·`si_floor` 를 **넘기지 않는다** — 레퍼런스
|
||||
`runners.estimate_bv` 의 폴백 호출이 그 인자를 빼고 부르기 때문이다.
|
||||
실측 f04 cyc20: 넘기면 249.95 mL, 안 넘기면 278.43 mL(= 레퍼런스 값).
|
||||
|
||||
## 검증
|
||||
|
||||
| 무엇을 | 어떻게 | 결과 |
|
||||
|---|---|---|
|
||||
| 기존 경로 무변경 | `LegacyEquivalenceDumpTest` 가 `align_cm{0,1}.csv` 44 cycle × CCC on/off 를 검출→BV 로 돌려 덤프. HEAD 코드와 작업트리 코드의 덤프를 대조. | **88 행 전부 일치** |
|
||||
| 토글 배선 | `AlgoModeSwitchTest` — 켜면 값이 달라지고 `missing_outside` 가 산출되며, 끄면 기존 값이 정확히 재현되는지 | 44 cycle 중 **40 개가 갈림**, 끈 뒤 완전 복귀 |
|
||||
| 정렬 판정 | 현행 레퍼런스 `AnchorGuide(ver='v1')` 를 `data123` 3 세션에 돌려 대조 | nch·ch3·hit **전부 일치** (0cm 3/X/2·11, 1cm 4/O/8·11, 3cm 3/O/11·11) |
|
||||
| 전체 | `:app:testDemoDebugUnitTest` | **30개 전부 통과** |
|
||||
|
||||
`LegacyEquivalenceDumpTest` 는 의도적으로 `AlgoMode` 를 참조하지 않는다 — HEAD 에는 그
|
||||
타입이 없어서, 참조하면 대조 자체가 불가능해진다.
|
||||
|
||||
## 이번에 함께 고친 것
|
||||
|
||||
- **테스트 픽스처 경로** — 저장소가 갈리면서 `c:/Projects/medilightv2android/data123` 가
|
||||
없어졌다. 5개 테스트가 `FileNotFoundException` 으로 조용히 실패하고 있었다.
|
||||
`c:/Projects/vesiscan-design-archive/data123` 로 옮겼다.
|
||||
- **`CenterAlignerValidationTest` 기대값** — 위 픽스처가 깨져 있어 `ebddc52` 이후 이
|
||||
테스트가 한 번도 돌지 않았다. 다시 돌리니 hit 7→8 · 10→11 로 어긋났는데, **현행
|
||||
레퍼런스를 같은 세션에 돌려 보니 8 · 11 이었다** — 코틀린이 맞고 기대값이 낡은
|
||||
것이다(구 `v3_python_reference.py` 기준, 현재 저장소에 없음). 기대값을 현행 레퍼런스
|
||||
산출값으로 갱신했다.
|
||||
`bv_cv` 는 현행 레퍼런스가 더 이상 내지 않는 값이라 대조할 수단이 없다. "Python 과
|
||||
bit-exact" 라던 종전 주석은 근거가 없어져 걷어내고, 성격을 **회귀 고정**으로 바꿔
|
||||
적었다.
|
||||
|
||||
## 아직 안 옮긴 것
|
||||
|
||||
| 항목 | 왜 |
|
||||
|---|---|
|
||||
| `post_tie_lock` | 상태기 — 스트리밍 경로와 함께 설계해야 한다 |
|
||||
| `_sibeam_cap_bounds` | nch=2 전용 |
|
||||
| F83 cap 보간 | 실리콘 + supine 조건 |
|
||||
|
||||
레퍼런스 저장소의 `library/analysis/abdomen.py` 가 커밋되어 있지 않아
|
||||
`pipeline_dump.py` · `compare.py` 가 그대로는 돌지 않는다. 이번 대조는 스크래치 사본에
|
||||
호출 시 즉시 실패하는 스텁을 놓아 우회했다(정렬·검출·BV 경로는 이 모듈을 쓰지 않는다).
|
||||
Reference in New Issue
Block a user