feat(alignment): V3 CenterAligner 2-Pass 이식 (piezophantomtest #35 BVCV)

Python piezophantomtest ffd436b (2026-07-15 PR #35 'alignment bvcv 규칙 추가')
의 `vesiscan_test.alignment.CenterAligner` 를 Kotlin 으로 이식.

기존 V2 (실시간 sliding window 6-stage) 와 병행. V3 는 Clinical batch 전용:

Pass 1 (scanSelect):
  - 5 위치 (0/1/2/3/4 cm) × 20 cycle → sliding window 10 → 11 trace 씩
  - mean-scan 으로 base 검출 (apply_cross=false) → nch, ch3 판정
  - per-trace cross 검출 → BV 계산 → std/mean = bv_cv
  - Rule A (ch3 필수 + per-trace 검출률 ≥ CH3_HIT_MIN 0.80 + max nch + min bv_cv tie)

Pass 2 (guide):
  - 재측정 한 위치 → target 기준 (ch3='O' & 검출률≥80% & nch≥t.nch &
    bv_cv ≤ t.bv_cv × (1 + bvCvTol)) 충족 시 STOP, 아니면 MOVE_UP.

포팅 원칙:
  - MethodDRunner (기존) 재사용, apply_cross=false/true 두 모드 활용.
  - PiezoBVEstimator.estimateBladderVolume6ch (기존) 재사용.
  - alignment_selection.py 의 select_cm(rule='A', tie='bvcv') 로직 완전 이식.

검증 (CenterAlignerValidationTest, data123 인체 3 세션):
  - Python reference 와 nch/ch3/ch3_hit 완전 일치.
  - bv_cv 오차 <2% (정상 case). BV 발산 케이스 (ch3=X) 만 큰 오차 —
    Rule A 에서 어차피 탈락하므로 최종 선택 영향 없음.
  - 최종 선택 위치 = cm=3 (Python 과 일치).

주의: test 실행 전 PiezoHW.activePreset 를 V1 로 명시 설정 필요.
      실기기는 BleManager 가 device name 으로 autoDetectPreset 처리.

UI 통합 (Pass 1 위치 안내 + Pass 2 판정 화면) 은 후속 작업.
This commit is contained in:
2026-07-15 12:13:34 +09:00
parent 1cf45df80c
commit 9387552e18
2 changed files with 365 additions and 0 deletions
@@ -0,0 +1,245 @@
/*
* Alignment Advisor V3 — Center 축 2-Pass 정렬.
*
* Python 원본: piezophantomtest `vesiscan_test/alignment.py:CenterAligner`
* (commit 32e22a1 / PR #35 "feat: alignment bvcv 규칙 추가", 2026-07-15)
* 선택 로직 원본: piezophantomtest `vesiscan_test/library/alignment_selection.py`
* — Rule A + bvcv tie 를 단일 진실원천으로 사용.
*
* V1 (`computePlacementGuide`, 3-stage) / V2 (`AlignGuide4Stage`, 6-stage) 와 병행.
* - V1: 일반 사용자 실시간 화살표 안내
* - V2: Clinical 세션 실시간 sliding-window 6-stage
* - V3: Clinical 세션 batch 2-Pass — "위치 명시 → 5 위치 모두 스캔 → 최적 선택 → 재측정 검증"
*
* 알고리즘 요약:
*
* Pass 1 (scanSelect)
* 사용자가 치골 위 0/1/2/3/4 cm 각각에서 20 cycle 씩 측정.
* 각 위치마다:
* - 20 cycle → sliding window 10 → 11 trace
* - mean scan 으로 base 검출 (apply_cross=false) → nch (center 4 중 검출 채널 수), ch3 여부
* - per-trace base 검출 → ch3 hit count
* - per-trace cross 검출 → BV 계산 → per-trace volume_ml 리스트 → bv_cv = std/mean
* Rule A + bvcv tie 로 최적 위치 선택:
* (1) ch3 필수 (base 검출)
* (2) per-trace ch3 검출률 ≥ 80% (임계 통과 없으면 ch3=O 전체 fallback)
* (3) max nch
* (4) 동률 → min bv_cv
* 선택 위치의 record 를 target 으로 저장 (Pass 2 기준값).
*
* Pass 2 (guide)
* 사용자가 다시 0 cm 부터 한 위치씩 재측정 (한 위치 = 20 cycle).
* 각 위치가 target 기준 충족 시 STOP:
* - ch3 = O
* - ch3 검출률 ≥ 80%
* - nch ≥ target.nch
* - bv_cv ≤ target.bv_cv × (1 + bvCvTol) (bvCvTol 기본 0.15)
* 미충족: MOVE_UP (다음 위치로 이동 안내).
*
* 참고: 이 클래스는 순수 알고리즘 라이브러리. UI 통합 (Pass 1 위치 안내 · 진행률 ·
* 결과 화면, Pass 2 위치 선택 · 판정 배지) 은 후속 작업.
*/
package com.medithings.vesiscan.managers
import com.medithings.vesiscan.walldetect.MethodDRunner
import com.medithings.vesiscan.walldetect.algo.methodd.MethodDParams
import kotlin.math.sqrt
/** 위치별 record — Python `position_records()` 반환 dict 대응.
* 현재 V3 (Rule A + bvcv) 에 필요한 필드만 포함. cap_frac / dcv / in15 은 미포팅. */
data class AlignPositionRecord(
val alignCm: Int,
/** center 4 채널 (CH0~CH3) 중 (전벽·후벽 모두 검출된) 채널 수. mean-scan base 검출 기준. */
val nch: Int,
/** mean-scan base 검출에서 CH3 검출 여부. Rule A 진입 조건. */
val ch3Detected: Boolean,
/** per-trace CH3 검출 카운트 (base 검출). */
val ch3Hit: Int,
/** 총 trace 수. */
val ch3Tot: Int,
/** per-trace BV (cross 보정) 리스트의 std/mean. valid BV < 2 면 null. */
val bvCv: Double?,
/** per-trace BV 평균 (mL). 표시용. valid BV = 0 이면 null. */
val meanBvMl: Double?,
/** BV 산출 성공한 trace 수 (BV 리스트 크기). */
val validBvTraces: Int,
) {
val ch3HitRate: Double get() = if (ch3Tot > 0) ch3Hit.toDouble() / ch3Tot else 0.0
}
/** V3 정렬 상태머신. */
class CenterAligner(
/** Pass 2 재측정 기준 BV CV 여유. Python `bvcv_tol` 기본 0.15. */
val bvCvTol: Double = 0.15,
/** Method D 검출 파라미터. */
private val methodDParams: MethodDParams = MethodDParams.DEFAULT,
/** trace sliding window 크기. Python `_traces()` win=10. */
private val slidingWindow: Int = 10,
) {
var target: AlignPositionRecord? = null
private set
var selectedCm: Int? = null
private set
var lastScan: List<AlignPositionRecord> = emptyList()
private set
/** Pass 1 결과. */
data class SelectResult(
val selectedCm: Int?,
val target: AlignPositionRecord?,
val records: List<AlignPositionRecord>,
)
/** Pass 2 결과. */
data class GuideResult(
val action: AlignAction, // STOP (기준 충족) 또는 MOVE_UP (다음 위치로)
val reason: String?, // null 이면 충족. 미충족 사유 표시용 (한글)
val record: AlignPositionRecord,
val ok: Boolean,
)
/**
* Pass 1 — 5 위치 (또는 그 이하) 각 20 cycle 을 스캔해 최적 위치 선택.
*
* cyclesByCm: {align_cm: cycles(cycles × 6 채널 × sample)}.
* cycles 는 20 이 표준이지만 다른 값도 허용 (sliding window 자동 축소).
*/
fun scanSelect(cyclesByCm: Map<Int, List<List<DoubleArray>>>): SelectResult {
val recs = cyclesByCm
.toSortedMap()
.map { (cm, cycles) -> buildRecord(cm, cycles) }
val cm = selectCm(recs)
lastScan = recs
selectedCm = cm
target = recs.firstOrNull { it.alignCm == cm }
return SelectResult(cm, target, recs)
}
/**
* Pass 2 — 재측정한 한 위치 (20 cycle) 를 target 기준으로 판정.
* scanSelect() 를 먼저 호출해 target 이 설정돼 있어야 유의미한 결과.
*/
fun guide(cycles: List<List<DoubleArray>>, cm: Int): GuideResult {
val rec = buildRecord(cm, cycles)
val reason = failReason(rec)
val action = if (reason == null) AlignAction.STOP else AlignAction.MOVE_UP
return GuideResult(action = action, reason = reason, record = rec, ok = reason == null)
}
fun reset() {
target = null; selectedCm = null; lastScan = emptyList()
}
// ─── internal ──────────────────────────────────────────────
/** Python `alignment_selection.select_cm(rule='A', tie='bvcv')` 이식. */
private fun selectCm(recs: List<AlignPositionRecord>): Int? {
// ch3 필수
val cand = recs.filter { it.ch3Detected }
// per-trace ch3 검출률 80% 이상 (임계 통과 없으면 ch3=O 전체 fallback)
val passed = cand.filter { it.ch3HitRate >= CH3_HIT_MIN }
val pool = if (passed.isNotEmpty()) passed else cand
if (pool.isEmpty()) return null
// max nch
val maxNch = pool.maxOf { it.nch }
val topNch = pool.filter { it.nch == maxNch }
// 동률 → min bv_cv (null 은 +inf → 맨 뒤). 그것도 같으면 min align_cm.
return topNch.minWithOrNull(
compareBy({ it.bvCv ?: Double.POSITIVE_INFINITY }, { it.alignCm })
)?.alignCm
}
/** Python `CenterAligner._fail_reason()` 이식. 충족이면 null. */
private fun failReason(rec: AlignPositionRecord): String? {
val t = target ?: return "기준 미설정 (scanSelect 먼저)"
if (!rec.ch3Detected) return "ch3 미검출"
if (rec.ch3Tot > 0 && rec.ch3HitRate < CH3_HIT_MIN) {
return "ch3 불안정 ${"%.0f".format(rec.ch3HitRate * 100)}%<${"%.0f".format(CH3_HIT_MIN * 100)}%"
}
if (rec.nch < t.nch) return "nch ${rec.nch}<${t.nch}"
val tb = t.bvCv
if (tb != null && tb.isFinite()) {
val rb = rec.bvCv
val limit = tb * (1 + bvCvTol)
if (rb == null || !rb.isFinite() || rb > limit) {
return "bv_cv ${fmt(rb)}>${fmt(limit)}"
}
}
return null
}
/** Python `position_records()` 의 한 위치 처리 부분 이식. */
private fun buildRecord(cm: Int, cycles: List<List<DoubleArray>>): AlignPositionRecord {
val n = cycles.size
val w = minOf(slidingWindow, n)
// Python: [arr[i:i+w].mean(0) for i in range(C - w + 1)]
val traces: List<List<DoubleArray>> =
if (n <= w) listOf(meanScan(cycles))
else (0..n - w).map { start -> meanScan(cycles.subList(start, start + w)) }
// mean-scan 검출 (pre-CCC / apply_cross=false) — nch, ch3 판단
val meanCycles = meanScan(cycles)
val wallsMeanBase = MethodDRunner.detectMultichannel(
meanCycles, methodDParams, applyTgc = true, applyCross = false)
val nch = (0..3).count { it < wallsMeanBase.size && wallsMeanBase[it] != null }
val ch3Detected = wallsMeanBase.size > CH3_INDEX && wallsMeanBase[CH3_INDEX] != null
// per-trace: ch3 hit count (base) + BV (cross) → bv_cv
var ch3Hit = 0
val bvs = ArrayList<Double>(traces.size)
for (tr in traces) {
val trBase = MethodDRunner.detectMultichannel(
tr, methodDParams, applyTgc = true, applyCross = false)
if (trBase.size > CH3_INDEX && trBase[CH3_INDEX] != null) ch3Hit++
val trCross = MethodDRunner.detectMultichannel(
tr, methodDParams, applyTgc = true, applyCross = true)
val allWalls: List<Pair<Int, Int>?> = trCross.map { r ->
r?.let { Pair(it.ant, it.post) }
}
val bv = estimateBladderVolume6ch(allWalls)
bv?.volumeMl?.let { if (it.isFinite() && it > 0) bvs.add(it) }
}
val bvCv: Double? = if (bvs.size >= 2) {
val m = bvs.average()
if (m != 0.0) {
val sd = sqrt(bvs.map { (it - m) * (it - m) }.average())
sd / m
} else null
} else null
val meanBv = if (bvs.isNotEmpty()) bvs.average() else null
return AlignPositionRecord(
alignCm = cm, nch = nch, ch3Detected = ch3Detected,
ch3Hit = ch3Hit, ch3Tot = traces.size,
bvCv = bvCv, meanBvMl = meanBv, validBvTraces = bvs.size,
)
}
private fun meanScan(cycles: List<List<DoubleArray>>): List<DoubleArray> {
val nCh = cycles[0].size
return List(nCh) { ch ->
val len = cycles[0][ch].size
DoubleArray(len) { i ->
var s = 0.0
for (c in cycles) s += c[ch][i]
s / cycles.size
}
}
}
private fun fmt(v: Double?): String =
if (v == null || !v.isFinite()) "-" else "%.3f".format(v)
companion object {
/** Rule A: per-trace ch3 검출률 최소 임계 (Python `CH3_HIT_MIN`). */
const val CH3_HIT_MIN = 0.80
/** 표준 위치 당 cycle 수. Python 원본 규약 (20 cycle → 11 trace @ win=10). */
const val DEFAULT_TARGET_CYCLES = 20
/** 표준 스캔 위치 수 (0, 1, 2, 3, 4 cm). */
const val DEFAULT_SCAN_POSITIONS = 5
private const val CH3_INDEX = 3
}
}