feat(method-d): appshare 02bed02 (4494098 VBTWD201) 완전 포팅 — wall detection 안정성 확보

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<Pair<Int, Double>>
    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 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 14:52:37 +09:00
parent 11795d1cef
commit 64c15a8d46
5 changed files with 134 additions and 8 deletions
@@ -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(). */
@@ -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) {
@@ -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<Pair<Int, Double>> = 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<Pair<Int, Double>> =
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,
)
}
}
@@ -3,11 +3,12 @@
*
* 입력: List<DoubleArray> (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<MethodDResult?>,
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<Int, Double>()
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<Pair<Int, Double>> = 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>): 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
}
}
@@ -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,