Files
VesiscanClinicalAndroid/app/src/main/java/com/medithings/vesiscan/walldetect/MethodDRunner.kt
T
dw.jang 441f249410 feat(algo): 미이식 3건 마저 이식 — post_tie_lock · sibeam · F83
레퍼런스에서 남겨 뒀던 세 가지를 옮겼다. 셋 다 레퍼런스 경로 전용이고 기존(현행
임상) 경로는 그대로다.

  · post_tie_lock  후벽 상위 두 후보의 score 가 구조적으로 붙는 구간(전 데이터
                   5773 결정 중 격차<10% 가 17.9%)에서 직전 trace 선택을 유지한다.
                   방향을 강제하지 않는다 — 깊은-peak 고정·인접 골 tie-break 은
                   전부 계통 편향을 낳았고, tie 밴드에서는 정의상 두 후보의 증거가
                   같기 때문이다. 상태기를 넘길 때만 동작하며, 레퍼런스도 streaming
                   strict pass 에서만 건다. streaming.py 이식 전까지 호출부는 없다.
  · _sibeam_cap_bounds  검출 채널이 정확히 2개일 때 미검출 이웃 빔까지의 SI 거리를
                   cap 상한으로. 2개뿐이면 끝 채널이 서로의 이웃이라 기존 top/bottom
                   clamp 가 성립하지 않아 cap 이 무제한 외삽된다. supine 은 건너뛴다.
  · F83            적도-밖 bottom cap 연속 보간(실리콘·supine). 전제인
                   mirror_bottom_cap 도 함께 옮겼다.

좌표계가 두 개라는 것을 확인했다

레퍼런스 cross_channel._z 는 sample_to_ap_depth 를 인자 없이 불러 교차채널 좌표가
언제나 모듈 기본값(1.936/6.85)으로 떨어진다. BV 는 프리셋 값을 명시로 넘긴다.
abs 는 두 값이 같아 표가 안 나지만 실리콘(1.897/7.651)에서는 갈린다 — 실측
f06/r1 단일 cycle 에서 CH0 전벽이 8 vs 14 로 갈려 439.88 vs 416.95 mL 이 됐다.
is_deep 판정이 34.209 > 34.042 처럼 아슬아슬한 자리라 작은 좌표 차가 선택을 뒤집는다.
의도인지 누락인지는 알고리즘팀 확인이 필요하다. 확인 전까지 레퍼런스와 같은 값을
내는 쪽을 택했고, 기존 경로는 프리셋 값을 그대로 쓴다.

missing_outside 의 null 과 빈 집합을 구분했다

null = 계산 안 함 → cap clamp 를 언제나 상한으로. 빈 집합 = 계산했고 '방광 밖'
채널이 없다 → 하한 쪽으로. ifEmpty { null } 로 뭉개면 "미검출이지만 span 은 있다"는
가장 흔한 경우에 si_floor 가 통째로 죽는다.

검증

  · 레퍼런스 대조 6종 전부 불일치 0 — tie 1568행(lock 24건) · 실데이터 BV 784행
    (nch=2 6건) · sibeam 120행(상한 102건) · 합성 BV 640행(mirror 226건) ·
    F83 blend 440행 · F83 apply 12행. 실데이터가 미러·nch=2 에 거의 안 닿아
    그 경로는 합성 입력으로 레퍼런스 함수를 직접 호출한 기대값으로 덮었다.
  · 기존 경로 무변경 — 44 cycle × CCC on/off 덤프가 HEAD 와 88행 전부 일치.
  · 전체 36개 통과 · demoDebug APK 빌드 성공.

LegacyEquivalenceDumpTest 가 PiezoHW.activePreset 을 명시로 고정하게 했다. 전역
가변 상태라 다른 테스트가 먼저 돌면 덤프가 통째로 달라진다 — 실제로 한 번 착시를
만들었다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:09:26 +09:00

1151 lines
55 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Method D multichannel runner + cross-channel corrections.
*
* 1:1 port of piezophantomtest `library/runners.py::method_d()` +
* `library/cross_channel.py` (2026-07-02 4830e7c + 5576a59)
*
* 파이프라인:
* 1) heavy = SG(7,3) + oscfar_median(win=9, iter=4) ← 02bed02 win 5→9
* light = SG(7,3)
* 2) apply_tgc_pipeline(heavy / light) — center_ch=None → all channels
* 3) 채널별 otsu_ratio × cos(beam_angle) ← PiezoHW.degreeAll
* 4) MethodDDetector.detect()
* 5) cross-channel 후처리 (순서 고정):
* ① applyAntTiebreak — 전벽 교차보정 (4830e7c 신 버전)
* ② applyNeighborTopValidate — 최상단 채널 FP/FN 검증 (신규 4830e7c)
* ③ applyInwardPost — 최상단 widest post 과확장 교정 (신규 5576a59)
*
* 출력 컨트랙트: alignment.py 가 `dets[i].urine_len` 만 의존 →
* MethodDResult.urineLen 또는 null 의 List 로 충분.
*/
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
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDWallSelect.CType
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDWallSelect.Side
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.min as kmin
object MethodDRunner {
// ── cross-channel tuning constants (cross_channel.py:30-45) ────────────────
/** 깊은쪽 교정 시 "합의에 이 이상 더 가까운 후보" 요구 (곡률 보존). */
private const val ANT_OUTLIER_IMPROVE_MARGIN_MM = 5.0
/** 교정 대상이 합의에서 이 이상 떨어지면 보정 보류 (얕은쪽은 제외). */
private const val ANT_CONSENSUS_CLOSE_TOL_MM = 6.0
/** dominant-pick 보호: raw 픽 score >= 이 배수 × 차순위 → 합의 교정에서 보존. */
private const val ANT_DOMINANT_RATIO = 2.0
// 레퍼런스 경로의 교차채널 z 좌표용 전역 캘리브레이션. [zAt] 주석 참조.
private const val CCC_DISTANCE_PER_SAMPLE = 1.936
private const val CCC_DELAY_OFFSET_MM = 6.85
/** neighbor_top_validate: nb-1→nb slope 가 이 이하면 "비증가 추세". */
private const val NTV_SLOPE_EPS = 2.0
/** neighbor_top_validate: top 이 예측보다 이만큼 깊으면 위반 (FP). */
private const val NTV_DEV_DELTA = 6.0
/** cross-channel 3개 스위치 (Python 과 동일 default). */
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] 길이가 다르면 그대로 처리 (각 채널 독립).
*
* @param applyCross false 면 교차채널 보정을 건너뛴 **base 검출**만 반환.
* 2026-07-06 이식 (piezophantomtest ea3f15c):
* 정렬 위치 선택 (`AlignGuide` / `select_alignment_position`) 은 base 검출로 수행해야 함
* — 교차채널 보정이 nch 를 바꿔 위치 선택이 흔들리는 것을 차단.
* BV 산출은 applyCross=true 로 별도 수행.
*/
/** 기존 호출부 호환 — 증거가 필요 없으면 이쪽. */
fun detectMultichannel(
signals: List<DoubleArray>,
params: MethodDParams = MethodDParams.DEFAULT,
beamAnglesDeg: DoubleArray? = null,
applyTgc: Boolean = true,
applyCross: Boolean = true,
tieLock: PostTieLock? = null,
): List<MethodDResult?> = detectMultichannelWithEvidence(
signals, params, beamAnglesDeg, applyTgc, applyCross, tieLock).walls
fun detectMultichannelWithEvidence(
signals: List<DoubleArray>,
params: MethodDParams = MethodDParams.DEFAULT,
beamAnglesDeg: DoubleArray? = null,
applyTgc: Boolean = true,
applyCross: Boolean = true,
/**
* 후벽 near-tie trace lock 상태기. **측정 하나마다** 새로 만들어 trace 를 순서대로
* 넘겨야 의미가 있다. null 이면 미적용 — 기존 경로 호출부는 넘기지 않는다.
*/
tieLock: PostTieLock? = null,
): DetectionOutcome {
val angles = beamAnglesDeg ?: PiezoHW.degreeAll
// 1) per-channel SG denoise (heavy + light)
val heavyList = signals.map { MethodDPreprocessing.preprocessHeavy(it, params) }
val lightList = signals.map { MethodDPreprocessing.preprocessLight(it) }
// 2) TGC per channel (Python apply_tgc_pipeline default center_ch=None → all)
val heavyTgc = if (applyTgc) MethodDTgc.applyTgcPipeline(heavyList) else heavyList
val lightTgc = if (applyTgc) MethodDTgc.applyTgcPipeline(lightList) else lightList
// 3+4) per-channel cos-angle adjusted otsu_ratio + detect
val results = MutableList(signals.size) { ch ->
val angleDeg = if (ch < angles.size) angles[ch] else 0.0
val chRatio = params.otsuRatio * cos(Math.toRadians(angleDeg))
MethodDDetector.detect(
raw = signals[ch],
denoisedHeavy = heavyTgc[ch],
denoisedLight = lightTgc[ch],
otsuRatioOverride = chRatio,
params = params,
)
}
val ref = com.medithings.vesiscan.managers.AlgoMode.reference
// ── 3) 후벽 near-tie trace lock ───────────────────────────────────
// 검출 뒤·교차채널 앞. 상태기를 넘겨야 동작하므로 넘기지 않는 기존 호출부는
// 아무 영향이 없다.
tieLock?.apply(results, params)
// ── 2) 검출 실패 채널만 SG5 로 재탐색 (레퍼런스 경로 전용) ─────────────
// 이미 검출된 채널은 건드리지 않는다 — 얻는 것은 커버리지뿐이고 측정면은 불변.
if (ref && SCALE_RECOVERY && applyCross) {
applyScaleRecovery(results, signals, angles, params, applyTgc)
}
// ── 4) 미검출 채널의 span 유무 판정 (레퍼런스 경로 전용) ───────────────
// **CCC 앞에서** 계산한다 — CCC 가 복구한 채널은 '방광 밖'이 아니다.
// **null 과 빈 집합은 다르다.** null = 계산 안 함 → cap clamp 를 언제나 상한으로.
// 빈 집합 = 계산했고 '방광 밖' 채널이 없다 → 미검출 채널은 전부 span 이 있다는
// 뜻이라 하한 쪽으로 간다. 뭉개면 si_floor 가 가장 흔한 경우에 통째로 죽는다.
val missingOut: Set<Int>? =
if (ref && applyCross) missingOutside(results, lightTgc, heavyTgc, angles, params)
else null
// ── 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)
}
/**
* 검출 결과 + **관측 증거**.
*
* [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?>,
angles: DoubleArray,
light: List<DoubleArray>,
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) {
// 레퍼런스의 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)
}
// ─────────────────────────────────────────────────────────────────────────
// ① ant_tiebreak — 전벽 교차보정 (2026-07-02: 4830e7c 신 버전)
// ─────────────────────────────────────────────────────────────────────────
/**
* 이웃 center 채널 전벽 z 합의 (LOO median) 기준 전벽 보정 (in-place).
*
* 단일 임계 `devTol=8mm` + 방향 분기 (구 nf_tol 8 / outlier_tol 9 통합):
* [최우선] dominant-pick 보호: raw 픽 score >= 2.0 × 차순위 → 보존
* (1) 동률(top1/top2 < tieRatio): 동률 band 중 합의 최근접 선택
* (2) 얕은쪽 (합의 - dev_tol 미만 = near-field 아티팩트): 무조건 재선택
* (3) 깊은쪽 (합의 + dev_tol 초과): improve_margin 이상 더 가까운 후보 있을 때만
*
* 얕은쪽 제외 후 합의 최근접 pick. 합의-근접 가드 (얕은쪽 교정은 항상, 그 외엔 pick 이
* 합의 근접일 때만).
*/
private fun applyAntTiebreak(
results: MutableList<MethodDResult?>,
angles: DoubleArray,
tieRatio: Double = AlignmentConstants.ANT_TIEBREAK_RATIO,
devTol: Double = AlignmentConstants.ANT_NEARFIELD_TOL_MM,
improveMargin: Double = ANT_OUTLIER_IMPROVE_MARGIN_MM,
) {
if (results.isEmpty()) return
// 현재 center 채널 ant z 좌표 (LOO median 의 base)
val baseZ = HashMap<Int, Double>()
for (i in AlignmentConstants.CENTER_CH) {
if (i < results.size) {
val r = results[i] ?: continue
baseZ[i] = zOf(angles, i, r.ant)
}
}
for (i in AlignmentConstants.CENTER_CH) {
if (i >= results.size) continue
val r = results[i] ?: continue
val cands = r.antCandidates
if (cands.size < 2) continue
val cons = AlignmentConstants.CENTER_CH
.filter { it != i }
.mapNotNull { baseZ[it] }
if (cons.isEmpty()) continue
val cz = median(cons)
// dominant-pick 보호 (최우선)
if (cands[0].second >= ANT_DOMINANT_RATIO * cands[1].second
&& r.ant == cands[0].first) {
continue
}
val cur = zOf(angles, i, r.ant)
val curDev = abs(cur - cz)
val bestDev = cands.minOf { abs(zOf(angles, i, it.first) - cz) }
val isTie = cands[0].second / maxOf(cands[1].second, 1e-9) < tieRatio
val isShallow = cur < cz - devTol
val isDeep = cur > cz + devTol
// 진입 + 후보 pool (방향 분기)
val pool: List<Pair<Int, Double>> = when {
isTie -> {
val thr = cands[0].second / tieRatio
cands.filter { it.second >= thr }
}
isShallow -> cands.toList() // 얕은 아티팩트 → 무조건
isDeep && bestDev < curDev - improveMargin -> cands.toList()
else -> continue // 압승 & 정상 범위 → 유지
}
// 얕은(아티팩트) 후보 제외, 합의 최근접 선택
val eligPrelim = pool.filter { zOf(angles, i, it.first) >= cz - devTol }
val elig = if (eligPrelim.isEmpty()) pool else eligPrelim
val pick = elig.minBy { abs(zOf(angles, i, it.first) - cz) }
// 합의-근접 가드: 얕은쪽 교정은 항상, 그 외엔 pick 이 합의 근접일 때만
val pickDev = abs(zOf(angles, i, pick.first) - cz)
if (pickDev > ANT_CONSENSUS_CLOSE_TOL_MM && !isShallow) continue
if (pick.first != r.ant && r.post > pick.first) {
r.ant = pick.first
r.antRefined = pick.first.toDouble()
r.urineLen = r.post - pick.first - 1
}
}
}
// ─────────────────────────────────────────────────────────────────────────
// ② neighbor_top_validate — 최상단 채널 FP/FN 검증 (신규 4830e7c)
// ─────────────────────────────────────────────────────────────────────────
/**
* 이웃 후위벽 trend 로 최상단 채널 검증 (in-place).
*
* Rule B (trend-FP): 검출 top 후위벽 z 를 아래 두 채널 기울기로 예측. 비증가추세인데
* top 이 예측보다 깊게(>6mm) jump → nb urine span 시드 재탐색 → drop/교체.
* Rule A (FN): 미검출 top 을 바로 아래 검출 채널 span 시드로 재탐색 복원(gate 통과 시).
*/
private fun applyNeighborTopValidate(
results: MutableList<MethodDResult?>,
angles: DoubleArray,
light: List<DoubleArray>,
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()
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
}
}
}
}
// ---- Rule A ----
val first = (0 until n).firstOrNull { results[it] != null }
if (first != null && first >= 1 && (first - 1) in origNone) {
val top = first - 1
val nb = first
val rNb = results[nb]!!
val ls = rNb.lowStart
val le = rNb.lowEnd
val rr = researchPostInSpan(light[top], heavy[top], raw6ch[top], ls, le, params)
if (rr != null) {
val lowAmp = minInRange(heavy[top], ls, le)
results[top] = MethodDResult(
ant = rr.ant,
post = rr.post,
antRefined = rr.ant.toDouble(),
postRefined = rr.post.toDouble(),
lowStart = ls,
lowEnd = le,
lowAmp = lowAmp,
inwardWalkAnt = 0,
inwardWalkPost = 0,
urineLen = rr.post - rr.ant - 1,
antProm = 0.0,
postProm = 0.0,
antType = rr.antType,
postType = rr.postType,
sgHeavy = heavy[top],
sgLight = light[top],
antCandidates = emptyList(),
)
}
}
}
/** _research_post_in_span 결과. */
private data class ResearchResult(val ant: Int, val post: Int, val antType: CType, val postType: CType)
/**
* seed span(ls, le) 에서 ant / post 재탐색 + wall/raw gate. 실패 시 null.
* detector 와 동일한 gate (_wall_gate_ok + min_post_raw_ratio) 로 재검증.
*/
private fun researchPostInSpan(
lt: DoubleArray, hv: DoubleArray, raw: DoubleArray,
ls: Int, le: Int, params: MethodDParams,
): ResearchResult? {
if (le <= ls) return null
val antRes = MethodDWallSelect.findWallPeakLocal(
lt, ls, le, Side.ANT, params,
dMaxOverride = params.antDMax, inwardWalk = 0,
)
val postRes = MethodDWallSelect.findWallPeakLocal(
lt, ls, le, Side.POST, params,
dMaxOverride = params.dMax, inwardWalk = 0,
)
val antBest = antRes.best ?: return null
val postBest = postRes.best ?: return null
val ai = antBest.idx
val pi = postBest.idx
if (pi <= ai || (pi - ai - 1) < params.minUrineLen) return null
// wall gate
val lumenMin = minInRange(hv, ls, le)
val wallAmp = if (params.wallRatioUsePostOnly) lt[pi]
else kmin(lt[ai], lt[pi])
if (wallAmp / maxOf(lumenMin, 1.0) < params.minWallLumenRatio) return null
// raw ratio gate (SG-only 재계산)
val rawHv = MethodDPreprocessing.preprocessHeavy(raw, params)
val rawLt = MethodDPreprocessing.preprocessLight(raw)
val rawLumen = minInRange(rawHv, ls, le)
if (rawLt[pi] / maxOf(rawLumen, 1.0) < params.minPostRawRatio) return null
return ResearchResult(ai, pi, antBest.type, postBest.type)
}
// ─────────────────────────────────────────────────────────────────────────
// ③ inward_post — 최상단 widest 후벽 과확장 교정 (신규 5576a59)
// ─────────────────────────────────────────────────────────────────────────
/**
* 최상단 검출 center 채널이 widest (적도가 fan 위) 면, 그 채널 후벽을
* [post-win..post] 범위의 더 inward 한 후보(urine-wall ratio ≥ gate)로 교체.
* (a) 분리형 peak, (b) shoulder.
*/
private fun applyInwardPost(
results: MutableList<MethodDResult?>,
angles: DoubleArray,
win: Int = 16,
ratioGate: Double = 1.15,
frac: Double = 0.75,
) {
val det = (0 until kmin(4, results.size))
.filter { results[it] != null }
.sorted()
if (det.size < 2) return
fun dOf(i: Int): Double {
val r = results[i]!!
val ang = if (i < angles.size) angles[i] else 0.0
return (r.post - r.ant) * cos(Math.toRadians(ang))
}
val s = det.associateWith { dOf(it) * dOf(it) }
val top = det[0]
val topS = s[top]!!
val maxS = s.values.max()
val secondS = s[det[1]]!!
if (topS < maxS || topS < secondS) return
val r = results[top]!!
val p = r.post
val ls = r.lowStart
val le = r.lowEnd
val lt = r.sgLight
val hv = r.sgHeavy
if (le <= ls || p - 2 <= ls + 3) return
val base = minInRange(hv, ls, le)
if (base <= 0 || base.isNaN() || base.isInfinite()) return
val postRatio = if (p in lt.indices) lt[p] / base else 0.0
val lo = maxOf(ls + 3, p - win)
val cands = mutableListOf<Int>()
for (i in lo until p - 2) {
if (i !in lt.indices) continue
if (lt[i] / base < ratioGate) continue
// (a) 분리형 peak: light local max, i~post 사이 valley, 깊은 peak 강도 frac 이상
val isPeak = i - 1 in lt.indices && i + 1 in lt.indices
&& lt[i] >= lt[i - 1] && lt[i] > lt[i + 1]
if (isPeak) {
var minLtIP = lt[i]
for (j in i..p) if (j in lt.indices && lt[j] < minLtIP) minLtIP = lt[j]
if (minLtIP < lt[i] * 0.97 && lt[i] >= postRatio * base * frac) {
cands.add(i)
continue
}
}
// (b) shoulder: 3-샘플 plateau + 앞쪽 상승 + 뒤에 더 깊은 peak
if (i + 3 <= p && i - 2 in lt.indices) {
var maxWin = lt[i]; var minWin = lt[i]
for (j in i until i + 3) {
if (j in lt.indices) {
if (lt[j] > maxWin) maxWin = lt[j]
if (lt[j] < minWin) minWin = lt[j]
}
}
val flat = (maxWin - minWin) < 0.02 * lt[i]
val risingBefore = lt[i] > lt[i - 2] + 0.03 * base
var maxAfter = lt[i + 3]
for (j in i + 3..p) if (j in lt.indices && lt[j] > maxAfter) maxAfter = lt[j]
val higherAfter = maxAfter > lt[i] * 1.03
if (flat && risingBefore && higherAfter) {
cands.add(i)
}
}
}
if (cands.isEmpty()) return
val newPost = cands.min()
if (newPost < p - 3) {
r.post = newPost
r.postRefined = newPost.toDouble()
r.urineLen = r.post - r.ant - 1
}
}
// ─────────────────────────────────────────────────────────────────────────
// 공통 helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* z(i, idx) — `sample_to_ap_depth`. 좌표계 선택은 [zAt] 이 한다(모드에 따라 갈린다).
* 여기서 따로 계산하면 `ant_tiebreak` 만 다른 좌표를 쓰게 된다.
*/
private fun zOf(angles: DoubleArray, ch: Int, idx: Int): Double =
zAt(if (ch < angles.size) angles[ch] else 0.0, idx)
/** numpy.median 동작 매칭 — 짝수 길이면 두 가운데 값의 평균. */
private fun median(xs: List<Double>): Double {
if (xs.isEmpty()) return 0.0
val sorted = xs.sorted()
val n = sorted.size
return if (n % 2 == 1) sorted[n / 2]
else (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
}
/** arr[from..to] 최소 (numpy min 매칭). 유효 범위 밖은 skip. 없으면 0.0. */
private fun minInRange(arr: DoubleArray, from: Int, to: Int): Double {
var m = Double.POSITIVE_INFINITY
val lo = maxOf(0, from)
val hi = kmin(arr.size - 1, to)
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`.
*
* ⚠ 레퍼런스 경로에서는 **프리셋이 아니라 전역 캘리브레이션 상수**를 쓴다.
* `wall_correction/cross_channel._z` 가 `sample_to_ap_depth(idx, angle)` 를 인자 없이
* 불러 모듈 기본값(1.936 / 6.85)으로 떨어지기 때문이다. BV 쪽은 프리셋 값을 명시로
* 넘기므로 두 좌표계가 서로 다르다 — abs(v*)는 값이 같아 표가 안 나지만 실리콘
* (r*: 1.897/7.651)에서는 갈린다. 실측 f06/r1 단일 cycle 에서 CH0 전벽이 8 vs 14 로
* 갈려 부피가 439.88 vs 416.95 mL 이 됐다.
*
* 기존 경로는 프리셋 값을 그대로 쓴다 — 오늘 임상이 그 위에서 돌았다.
*/
private fun zAt(angleDeg: Double, idx: Int): Double =
if (com.medithings.vesiscan.managers.AlgoMode.reference)
(CCC_DELAY_OFFSET_MM + idx * CCC_DISTANCE_PER_SAMPLE) * cos(Math.toRadians(angleDeg))
else
(PiezoHW.delayOffsetMm + idx * PiezoHW.distancePerSample) * cos(Math.toRadians(angleDeg))
/** numpy.median 동작 매칭 — 짝수 길이면 두 가운데 값의 평균. */
/**
* 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
}
}