refactor: 데모 브랜치 패키지 통일 com.example.medilightv2android → com.medithings.vesiscan

사용자앱 (feature/tab-navigation) 과 패키지 이름 일치. namespace + applicationId
둘 다 com.medithings.vesiscan 로 변경 (기존 데모 앱은 재설치 필요).

## 변경 범위
- Kotlin 148 파일: package + import 문 (714 occurrences)
- 디렉토리 이동: com/example/medilightv2android → com/medithings/vesiscan
  (main, test, androidTest 각각)
- app/build.gradle.kts: namespace, applicationId
- docs/FLAVOR_DEMO_STABLE.md: 참조 갱신
- V41DetectorCH4Test: BvDispatchResult.methodChosen → method (dto field name fix)

## 주의
- applicationId 가 바뀌므로 기존 데모 앱 (com.example.medilightv2android.demo) 은
  Android 관점에서 다른 앱으로 취급 — 재설치 시 PIN/설정 초기화됨.
- Fresh install 권장. 기존 앱 (com.example...) 은 별도로 uninstall 필요.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 14:30:43 +09:00
parent 55d55225ab
commit bcbc7b440c
159 changed files with 2865 additions and 717 deletions
@@ -0,0 +1,434 @@
/*
* 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.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
/** 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
/**
* 6채널 (또는 N채널) raw 신호 → 채널별 MethodDResult? 리스트.
* raw[ch] 길이가 다르면 그대로 처리 (각 채널 독립).
*/
fun detectMultichannel(
signals: List<DoubleArray>,
params: MethodDParams = MethodDParams.DEFAULT,
beamAnglesDeg: DoubleArray? = null,
applyTgc: Boolean = true,
): List<MethodDResult?> {
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,
)
}
// 5) cross-channel 후처리 (Python `apply_cross_channel` 순서 고정)
applyCrossChannel(results, angles, lightTgc, heavyTgc, signals, params)
return results
}
/**
* Cross-channel 3-stage 후처리 (Python `apply_cross_channel`).
* ① ant_tiebreak → ② neighbor_top_validate → ③ inward_post
*/
private fun applyCrossChannel(
results: MutableList<MethodDResult?>,
angles: DoubleArray,
light: List<DoubleArray>,
heavy: List<DoubleArray>,
raw6ch: List<DoubleArray>,
params: MethodDParams,
) {
applyAntTiebreak(results, angles)
if (NEIGHBOR_TOP_VALIDATE) {
applyNeighborTopValidate(results, angles, light, heavy, raw6ch, params)
}
if (INWARD_POST_SEARCH) {
applyInwardPost(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,
) {
val n = kmin(4, results.size)
val origNone = (0 until n).filter { results[it] == null }.toHashSet()
val det = (0 until n).filter { results[it] != null }.sorted()
// ---- Rule B ----
if (det.size >= 3) {
val top = det[0]; val nb1 = det[1]; val nb2 = det[2]
val rTop = results[top]!!
val rNb1 = results[nb1]!!
val rNb2 = results[nb2]!!
val zTop = zOf(angles, top, rTop.post)
val zNb1 = zOf(angles, nb1, rNb1.post)
val zNb2 = zOf(angles, nb2, rNb2.post)
val slope = zNb1 - zNb2
val pred = zNb1 + slope
if (slope <= NTV_SLOPE_EPS && (zTop - pred) > NTV_DEV_DELTA) {
val rr = researchPostInSpan(
light[top], heavy[top], raw6ch[top], rNb1.lowStart, rNb1.lowEnd, params
)
if (rr == null || (zOf(angles, top, rr.post) - pred) > NTV_DEV_DELTA) {
results[top] = null
} else {
rTop.post = rr.post
rTop.postRefined = rr.post.toDouble()
rTop.postType = rr.postType
rTop.urineLen = rTop.post - rTop.ant - 1
}
}
}
// ---- 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) = (DELAY_OFFSET_MM + idx * DISTANCE_PER_SAMPLE) * cos(angle_i) — sample_to_ap_depth. */
private fun zOf(angles: DoubleArray, ch: Int, idx: Int): Double {
val ang = if (ch < angles.size) angles[ch] else 0.0
return (PiezoHW.delayOffsetMm + idx * PiezoHW.distancePerSample) * cos(Math.toRadians(ang))
}
/** 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
}
}