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,
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user