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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+8
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user