From 64c15a8d46e94972adfc6a058848c65b24494b06 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Mon, 29 Jun 2026 14:52:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(method-d):=20appshare=2002bed02=20(4494098?= =?UTF-8?q?=20VBTWD201)=20=EC=99=84=EC=A0=84=20=ED=8F=AC=ED=8C=85=20?= =?UTF-8?q?=E2=80=94=20wall=20detection=20=EC=95=88=EC=A0=95=EC=84=B1=20?= =?UTF-8?q?=ED=99=95=EB=B3=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python source: piezo-phantom-test commit 02bed02 (= 4494098 vesiscan-test PR). Kotlin V2 alignment 결과가 Python 과 1:1 일치하도록 포팅 (값·로직 동일). [1] MethodDParams (config_d.py 매칭) - oscfarWin: 5 → 9 heavy median 강화로 span_e 안정화 → post 벽 검출 일관성 (CH0 post SD 1.45→0.36, v2 cycle CV ↓) - shoulderPromGamma: 1.0 → 1.5 peak(promGamma=1.5) 와 정렬해 강한 shoulder 가 약한 peak 에 지지 않음 peak↔shoulder 재분류 토글 제거 (v2 cycle CV 12.8→9.5%) [2] MethodDResult (detector.py 매칭) - 신규 필드: antCandidates: List> detect() 가 recovery 전 wall_select 후보를 score 내림차순으로 노출 Python: tuple((int(c[0]), float(c[4])) for c in sorted(ant_res['candidates'], key=-x[4])) - ant / antRefined / urineLen 을 var 로 (tie-break post-process 가 in-place 갱신) [3] AlignmentConstants (config_6ch.py 매칭) - ANT_TIEBREAK_RATIO = 1.3 - ANT_NEARFIELD_TOL_MM = 8.0 [4] MethodDRunner.applyAntTiebreak() (runners.py _apply_ant_tiebreak 매칭) - cross-channel 전벽 보정 (in-place): (1) 동률 (top1/top2 < 1.3) → 이웃 center 채널 ant z 합의 median 으로 선택 (2) near-field 아티팩트 (cz - 8mm 보다 얕음) → 압승이어도 후보 교체 - 방향성: urine 쪽(깊은) 이탈은 dome 곡률로 정상이라 보존, near-field 만 기각 - detectMultichannel() 끝에 호출 — Python method_d() 와 동일 순서 - z(ch, idx) = (DELAY_OFFSET_MM + idx * DISTANCE_PER_SAMPLE) * cos(angle_ch) - median 은 numpy.median 동작 매칭 (짝수 길이면 두 가운데 평균) [5] PiezoBVEstimator.distancePerSample (config_6ch.py 매칭) - default 1.968 → 1.936 (HW 샘플링레이트 변경, FW VBTFW118+) Co-Authored-By: Claude Opus 4.7 --- .../managers/AlignmentAdvisorV2.kt | 8 ++ .../managers/PiezoBVEstimator.kt | 4 +- .../walldetect/MethodDDetector.kt | 18 +++- .../walldetect/MethodDRunner.kt | 102 +++++++++++++++++- .../walldetect/algo/methodd/MethodDParams.kt | 10 +- 5 files changed, 134 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/example/medilightv2android/managers/AlignmentAdvisorV2.kt b/app/src/main/java/com/example/medilightv2android/managers/AlignmentAdvisorV2.kt index 6c86676..38d4c43 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/AlignmentAdvisorV2.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/AlignmentAdvisorV2.kt @@ -57,6 +57,14 @@ object AlignmentConstants { const val LAT_TOL = 8 // 좌우 균형 허용 |u4-u5| (samples, ≈16mm) val CENTER_CH = intArrayOf(0, 1, 2, 3) val LATERAL_CH = intArrayOf(4, 5) + + // 02bed02 (4494098 VBTWD201): cross-channel ant tie-break — config_6ch.py 매칭. + /** Cross-channel 전벽 tie-break: top1/top2 score 비율 < TIEBREAK_RATIO 이면 동률 → 합의 z 로 선택. + * 1.3 = 명확한 동률(top1/top2 1.1~1.2)만 잡고 압승(>=1.5)은 측정값 보존. */ + const val ANT_TIEBREAK_RATIO = 1.3 + /** Near-field 가드: ant 가 이웃 합의 z 보다 이 값 이상 "얕은" (probe 쪽) 경우 entry 복합체 + * 아티팩트로 보고 후보 교체. 깊은(urine 쪽) 이탈은 dome 곡률로 정상이라 허용. 8mm ≈ 4 sample. */ + const val ANT_NEARFIELD_TOL_MM = 8.0 } /** stateless 단일 스냅샷 advisor — alignment.py:alignment_advice(). */ diff --git a/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt b/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt index 95aaefe..3c173ac 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt @@ -103,7 +103,9 @@ object PiezoHW { * 서로 다른 dps 로 계산되어 측정이 어긋난다. */ // `.also` 로 초기값을 walldetect.core.WdConfig 측에도 동시에 반영 — 시작 // 시점부터 두 경로의 dps 가 일치한다. - @Volatile private var _distancePerSample: Double = 1.968.also { WdConfig.DPS_DEFAULT = it } + // 02bed02 (config_6ch.py): DISTANCE_PER_SAMPLE 1.981 → 1.936 (HW 샘플링레이트 변경). + // 우리 default 도 1.968 → 1.936 으로 정렬 (FW VBTFW118+ 기준). + @Volatile private var _distancePerSample: Double = 1.936.also { WdConfig.DPS_DEFAULT = it } var distancePerSample: Double get() = _distancePerSample set(value) { diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/MethodDDetector.kt b/app/src/main/java/com/example/medilightv2android/walldetect/MethodDDetector.kt index 13d6321..4a76374 100644 --- a/app/src/main/java/com/example/medilightv2android/walldetect/MethodDDetector.kt +++ b/app/src/main/java/com/example/medilightv2android/walldetect/MethodDDetector.kt @@ -24,22 +24,28 @@ import com.example.medilightv2android.walldetect.algo.methodd.MethodDWallSelect. import kotlin.math.max data class MethodDResult( - val ant: Int, + // ant / antRefined / urineLen 는 02bed02 의 cross-channel _apply_ant_tiebreak 가 + // 사후에 갱신하므로 var. Python `r.ant = pick[0]`, `r.ant_refined = float(pick[0])`, + // `r.urine_len = int(r.post - pick[0] - 1)` 와 1:1 매칭. + var ant: Int, val post: Int, - val antRefined: Double, + var antRefined: Double, val postRefined: Double, val lowStart: Int, val lowEnd: Int, val lowAmp: Double, val inwardWalkAnt: Int, val inwardWalkPost: Int, - val urineLen: Int, + var urineLen: Int, val antProm: Double, val postProm: Double, val antType: CType, val postType: CType, val sgHeavy: DoubleArray, val sgLight: DoubleArray, + /** 초기 ant 후보 [(idx, score), ...] score 내림차순 — 02bed02 cross-channel tie-break 용. + * recovery 전 wall_select 결과를 그대로 노출 (Python: detector.py `ant_candidates`). */ + val antCandidates: List> = emptyList(), ) object MethodDDetector { @@ -85,6 +91,11 @@ object MethodDDetector { ) if (antRes.best == null || postRes.best == null) return null + // 02bed02: 초기 ant 후보 (recovery 전, score 내림차순) — cross-channel tie-break 용. + // Python: tuple((int(c[0]), float(c[4])) for c in sorted(ant_res['candidates'], key=-x[4])) + val antCandidates: List> = + antRes.candidates.sortedByDescending { it.score }.map { it.idx to it.score } + var antIdx = antRes.best.idx var antProm = antRes.best.prom var antType = antRes.best.type @@ -200,6 +211,7 @@ object MethodDDetector { postType = postType, sgHeavy = sgHeavy, sgLight = sgLight, + antCandidates = antCandidates, ) } } diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/MethodDRunner.kt b/app/src/main/java/com/example/medilightv2android/walldetect/MethodDRunner.kt index b1c2389..7fd21ef 100644 --- a/app/src/main/java/com/example/medilightv2android/walldetect/MethodDRunner.kt +++ b/app/src/main/java/com/example/medilightv2android/walldetect/MethodDRunner.kt @@ -3,11 +3,12 @@ * * 입력: List (6채널 raw ADC, 길이 100 가정) * 처리 (Python 1:1): - * 1) heavy = SG(7,3) + oscfar_median(win=5, iter=4) + * 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) [02bed02] _apply_ant_tiebreak — cross-channel 전벽 보정 (in-place) * * TGC 는 default ON (Python 과 동일). `applyTgc=false` 로 비활성화 가능. * @@ -16,10 +17,12 @@ */ package com.example.medilightv2android.walldetect +import com.example.medilightv2android.managers.AlignmentConstants import com.example.medilightv2android.managers.PiezoHW import com.example.medilightv2android.walldetect.algo.methodd.MethodDParams import com.example.medilightv2android.walldetect.algo.methodd.MethodDPreprocessing import com.example.medilightv2android.walldetect.algo.methodd.MethodDTgc +import kotlin.math.abs import kotlin.math.cos object MethodDRunner { @@ -42,7 +45,7 @@ object MethodDRunner { 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 - return List(signals.size) { ch -> + 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( @@ -53,5 +56,100 @@ object MethodDRunner { params = params, ) } + // 5) [02bed02] cross-channel 전벽 tie-break — in-place 보정 + applyAntTiebreak(results, angles) + return results + } + + /** + * Cross-channel 전벽 보정 (in-place, method_d 전용) — 02bed02 runners.py `_apply_ant_tiebreak`. + * + * 이웃 center 채널의 전벽 좌표(z=AP깊이) 합의(leave-one-out median)를 기준으로 두 경우 개입: + * + * (1) **동률** (top1/top2 score < tieRatio): 동률 band 후보 중 합의 z 에 가장 가까운 것 선택. + * 단일 채널 scoring 이 인접 sub-peak 사이에서 토글하는 모호함 해소. + * (2) **near-field 아티팩트** (압승이어도): 선택된 ant 가 합의보다 nfTolMm 이상 "얕은" + * (probe/near-field 쪽) 경우 entry 복합체 오검출로 보고, near-field 후보를 제외하고 + * 합의에 가장 가까운 후보로 교체. + * + * **방향성**: "더 깊은(urine 쪽)" 이탈은 보정하지 않음 — 바깥 채널(CH0/CH3)은 방광 dome + * 곡률로 이웃보다 urine 쪽에 전벽이 잡히는 게 정상이라 실측 보존. 얕은(near-field) 쪽 이탈만 기각. + * + * 압승이고 near-field 아티팩트도 아니면 측정값 그대로 둠 → "실측 보존, 모호함·near-field 오검출만 해소". + */ + private fun applyAntTiebreak( + results: MutableList, + angles: DoubleArray, + tieRatio: Double = AlignmentConstants.ANT_TIEBREAK_RATIO, + nfTolMm: Double = AlignmentConstants.ANT_NEARFIELD_TOL_MM, + ) { + if (results.isEmpty()) return + + // z(i, idx) = (DELAY_OFFSET_MM + idx * DISTANCE_PER_SAMPLE) * cos(angle_i) + val delay = PiezoHW.delayOffsetMm + val dps = PiezoHW.distancePerSample + fun z(ch: Int, idx: Int): Double { + val ang = if (ch < angles.size) angles[ch] else 0.0 + return (delay + idx * dps) * cos(Math.toRadians(ang)) + } + + // 현재 center 채널 ant z 좌표 (leave-one-out median 의 base) + val baseZ = HashMap() + for (i in AlignmentConstants.CENTER_CH) { + if (i < results.size) { + val r = results[i] ?: continue + baseZ[i] = z(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 + + // 이웃 center 채널 (i 제외) 들의 base z median + val cons = AlignmentConstants.CENTER_CH + .filter { it != i } + .mapNotNull { baseZ[it] } + if (cons.isEmpty()) continue + val cz = median(cons) + + // 동률 / near-field 판정 + val top1 = cands[0].second + val top2 = cands[1].second + val isTie = top1 / maxOf(top2, 1e-9) < tieRatio + val isNearfield = z(i, r.ant) < cz - nfTolMm + + if (!isTie && !isNearfield) continue // 압승 & near-field 아님 → 유지 + + val pool: List> = if (isTie) { + val thr = top1 / tieRatio + cands.filter { it.second >= thr } + } else { + cands.toList() + } + + // near-field (합의보다 nfTol 이상 얕은) 후보 제외, urine 쪽(깊은)은 허용 + val eligPrelim = pool.filter { z(i, it.first) >= cz - nfTolMm } + val elig = if (eligPrelim.isEmpty()) pool else eligPrelim + val pick = elig.minBy { abs(z(i, it.first) - cz) } + + // post > pick 보장 (urine_len > 0) + 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 + } + } + } + + /** 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 } } diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/algo/methodd/MethodDParams.kt b/app/src/main/java/com/example/medilightv2android/walldetect/algo/methodd/MethodDParams.kt index f027554..7ef3a83 100644 --- a/app/src/main/java/com/example/medilightv2android/walldetect/algo/methodd/MethodDParams.kt +++ b/app/src/main/java/com/example/medilightv2android/walldetect/algo/methodd/MethodDParams.kt @@ -17,7 +17,10 @@ data class MethodDParams( val distDecay: Double = 0.1, val promGamma: Double = 1.5, val shoulderDistDecay: Double = 0.3, - val shoulderPromGamma: Double = 1.0, + // 02bed02 (4494098 VBTWD201): shoulder 후보 prominence 가중 1.0→1.5 + // peak(promGamma=1.5) 와 정렬해 강한 shoulder 가 약한 peak 에 지지 않음. + // peak↔shoulder 재분류 토글 제거 (v2 cycle CV 12.8→9.5%). + val shoulderPromGamma: Double = 1.5, val minShoulderProm: Double = 100.0, val minPeakProm: Double = 50.0, val shoulderScoreHandicap: Double = 0.15, @@ -26,7 +29,10 @@ data class MethodDParams( val minPostRawRatio: Double = 1.08, val inwardWalkWin: Int = 3, val inwardWalkSlopeTol: Double = 10.0, - val oscfarWin: Int = 5, + // 02bed02 (4494098 VBTWD201): OS-CFAR window 5→9 + // heavy median 강화로 span_e 안정화 → post 벽 검출 일관성 (CH0 post SD 1.45→0.36). + // urine→후벽 전이부 평탄면이 threshold 를 스쳐 span_e 가 cycle 마다 튀던 것 제거. + val oscfarWin: Int = 9, val oscfarMaxIters: Int = 4, val minLightSeparability: Double = 0.75, val minSpanLen: Int = 5,