441f249410
레퍼런스에서 남겨 뒀던 세 가지를 옮겼다. 셋 다 레퍼런스 경로 전용이고 기존(현행
임상) 경로는 그대로다.
· 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>
1151 lines
55 KiB
Kotlin
1151 lines
55 KiB
Kotlin
/*
|
||
* 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
|
||
}
|
||
|
||
}
|