/* * 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, params: MethodDParams = MethodDParams.DEFAULT, beamAnglesDeg: DoubleArray? = null, applyTgc: Boolean = true, applyCross: Boolean = true, tieLock: PostTieLock? = null, ): List = detectMultichannelWithEvidence( signals, params, beamAnglesDeg, applyTgc, applyCross, tieLock).walls fun detectMultichannelWithEvidence( signals: List, 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? = if (ref && applyCross) missingOutside(results, lightTgc, heavyTgc, angles, params) else null // ── 5) 교차채널 보정 ─────────────────────────────────────────────── if (applyCross) { // 5b) 약한 채널을 CCC 복구 단계에서 숨긴다(레퍼런스 경로 전용). var untrusted: Set? = 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, val missingOutside: Set?, ) /** * 교차채널 보정 — 모드에 따라 **다른 순서**로 돈다. * * 기존(팬텀 시연): ① 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, angles: DoubleArray, light: List, heavy: List, raw6ch: List, params: MethodDParams, untrusted: Set? = 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() 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, 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() 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> = 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, angles: DoubleArray, light: List, heavy: List, raw6ch: List, 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, 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() 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 { 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, angles: DoubleArray, light: List, heavy: List, raw6ch: List, 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() 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, raw6ch: List, 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, angles: DoubleArray, light: List, heavy: List, raw6ch: List, 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>() 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, 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, angles: DoubleArray) { val det = results.indices.filter { results[it] != null } if (det.size < 3) return val za = HashMap() val zp = HashMap() 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() 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 { 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, raw6ch: List, 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, raw6ch: List, ): Map? { val rr = HashMap() 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, light: List, heavy: List, angles: DoubleArray, params: MethodDParams = MethodDParams.DEFAULT, ): Set { val outside = HashSet() 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 } }