Files
VesiscanClinicalAndroid/app/src/main/java/com/medithings/vesiscan/managers/EllipseFitSpecific.kt
T
dw.jang 31527830e5 fix(bv): estimateBv + adaptive_large_bladder_relax + b_si_floor — BV Δ 0.000 mL
Python `runners.estimate_bv` 완전 이식으로 single-trace BV 를 Python 과
**bit-perfect** 매칭. 그동안 발견되지 못한 근본 원인은:

1) `_apply_urine_inset_one` (LUMEN_INSET_FRAC=0.15) — Kotlin 호출자에서
   미적용. 이전 commit db64407 에서 subsample refined 만 이식하고 inset 은
   건너뜀. Python 은 fit 전에 walls 를 lumen 안쪽으로 15% 이동 후 int round.
2) `adaptive_large_bladder_relax` wrapper (runners.py:180) — Kotlin 자체가
   이 wrapper 를 이식 안 함. Python 은 첫 계산 후 low_wide_endpoint 조건
   (endpoint 단면이 넓지만 cap 낮음) 만족 시 `urine_inset_frac=0.05` +
   `b_si_floor_ratio=0.85` 로 재실행. 이 케이스에서 b_si_floor 가 발동해
   b_si_ellipse 를 강제 상향 (43 → 47 mm) → cap 부피 크게 증가.
3) `b_si_floor_ratio` (bv_estimation.py:1316-1321) — ellipse fit 성공 block
   에서 자유 b_si 붕괴 방지 로직. Kotlin 미이식.

주요 변경:
- managers/PiezoBVEstimator.kt
  * `WallWithSpan` data class 신규 (Python extract_walls 4-tuple 대응)
  * `estimateBv(walls, hw, ..., adaptiveLargeBladderRelax=true)` 신규 — Python
    runners.estimate_bv 1:1. low_wide_endpoint 검사 + retry 로직.
  * `estimateBladderVolume6ch` Double 오버로드 : bSiFloorRatio,
    bSiFloorEdgeMin 파라미터 추가.
  * `estimateBladderVolume` : b_si_floor 로직 이식 (Python 1316-1321).
  * `applyLumenInsetOne` Double 오버로드 신규.
- managers/AlignmentAdvisorV3.kt : buildRecord 가 estimateBv 사용.
- ui/views/clinical/ClinicalLiveView.kt : BV chip METHOD_D 가 estimateBv 사용.
- test/PrecisionDumpTest.kt : estimateBv 로 통일.
- test/CenterAlignerValidationTest.kt : cm=0/1 tolerance 0.02 (Python 완전
  일치), cm=3 는 0.10 (multi-trace edge case 잔존).

검증 (data123 cm=1 trace 0 first 10 cycles):
  volume_ml     : py=421.028293482 == kt=421.028293482  Δ=0.000000000  ★
  bottom_h_mm   : py= 20.059475664 == kt= 20.059475664  Δ=0.000000000  ★
  top_h_mm     : py= 10.441122871 == kt= 10.441122871  Δ=0.000000000  ★
  capBSiMm      : py= 47.049521628 == kt= 47.049521628  Δ=0.000000000  ★
  capCApMm     : py= 55.352378386 == kt= 55.352378386  Δ=0.000000000  ★
V3 CenterAligner: cm=0/1 bv_cv Python 완전 일치. cm=3 만 0.048 vs 0.130
(adaptive relax multi-trace edge case, 최종 cm=3 선택은 안정).

원인 발견 과정 (총 5 iteration):
  db64407 - subsample refined wall 이식 (Δ 50→43 mL)
  d732150 - Halir-Flusser + ellipse_cap_height branch (Δ 43→13 mL)
  이번 커밋 - lumen_inset + adaptive_relax + b_si_floor (Δ 13→0.000 mL) ★
2026-07-20 17:50:32 +09:00

296 lines
11 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Halíř–Flusser direct ellipse-specific fit — Python 1:1 이식.
*
* 원본: piezophantomtest `vesiscan_test/library/bv_estimation.py`
* `_fit_ellipse_specific` (line 881-961).
*
* 일반 원뿔곡선 a·u² + b·u·v + c·v² + d·u + e·v + f = 0 에
* 타원 제약 4ac − b² > 0 을 걸어 항상 타원 해 보장. tilt (교차항 b) 흡수.
*
* 알고리즘 흐름:
* 1. 데이터 표준화 (mean subtract, std divide) → u, v
* 2. D1 = [u², u·v, v²], D2 = [u, v, 1] → S1, S2, S3
* 3. T = -S3⁻¹·S2ᵀ, M = C1⁻¹·(S1 + S2·T) ← C1⁻¹ 는 타원 제약 행렬 inverse
* 4. M 의 3×3 non-symmetric eigenvalue → 3개 eigenvector
* 5. 4·v[0]·v[2] − v[1]² > 0 인 eigenvector 선택 (타원 조건)
* 6. 계수 unpack → 중심 (y0, z0), 반축 (b_si, a_ap), residual
*
* 3×3 non-symmetric eigenvalue 는 characteristic polynomial (Cardano cubic) +
* null-space via cross product 방식으로 순수 Kotlin 구현.
*/
package com.medithings.vesiscan.managers
import kotlin.math.abs
import kotlin.math.acos
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.sqrt
object EllipseFitSpecific {
/** Halíř–Flusser fit 결과. Python 반환값과 동일 순서. */
data class Result(
val y0: Double,
val z0: Double,
val bSi: Double, // SI 반extent
val aAp: Double, // AP 반extent
val residuals: DoubleArray,
)
/** DEBUG: 테스트 케이스에서 Python 의 eigenvector 를 강제 주입 (원인 pinpoint 용). null 이면 정상 solver. */
var debugForceEigenvector: DoubleArray? = null
/** DEBUG: 마지막 fit 의 상류 intermediate. Python 대조용. */
var debugYm: Double = 0.0; var debugYsS: Double = 0.0
var debugZm: Double = 0.0; var debugZsS: Double = 0.0
var debugT: Array<DoubleArray>? = null
var debugA2: DoubleArray? = null
/**
* (yw_f, zw_f) 두 벡터 (>= 5 점) → 타원 fit. 실패 (타원해 없음/특이) 시 null.
*/
fun fit(ywF: DoubleArray, zwF: DoubleArray): Result? {
val n = ywF.size
if (n < 5 || zwF.size != n) return null
// 1) 표준화 — Python `ym, ys_ = mean, std + 1e-12`
val ym = ywF.average()
val ysS = std(ywF) + 1e-12
val zm = zwF.average()
val zsS = std(zwF) + 1e-12
val u = DoubleArray(n) { (ywF[it] - ym) / ysS }
val v = DoubleArray(n) { (zwF[it] - zm) / zsS }
debugYm = ym; debugYsS = ysS; debugZm = zm; debugZsS = zsS
// 2) D1 = [u², u·v, v²], D2 = [u, v, 1]
// S1 = D1ᵀD1 (3×3), S2 = D1ᵀD2 (3×3), S3 = D2ᵀD2 (3×3)
val s1 = Array(3) { DoubleArray(3) }
val s2 = Array(3) { DoubleArray(3) }
val s3 = Array(3) { DoubleArray(3) }
for (i in 0 until n) {
val u2 = u[i] * u[i]
val uv = u[i] * v[i]
val v2 = v[i] * v[i]
val d1 = doubleArrayOf(u2, uv, v2)
val d2 = doubleArrayOf(u[i], v[i], 1.0)
for (a in 0..2) for (b in 0..2) {
s1[a][b] += d1[a] * d1[b]
s2[a][b] += d1[a] * d2[b]
s3[a][b] += d2[a] * d2[b]
}
}
// 3) T = -S3⁻¹ · S2ᵀ (3×3)
val s3inv = invert3x3(s3) ?: return null
val s2t = transpose3x3(s2)
val neg = Array(3) { DoubleArray(3) }
matmul3x3(s3inv, s2t, neg)
val t = Array(3) { row -> DoubleArray(3) { col -> -neg[row][col] } }
debugT = t
// M = C1⁻¹ · (S1 + S2·T)
// C1⁻¹ = [[0, 0, 0.5], [0, -1, 0], [0.5, 0, 0]]
val s2t2 = Array(3) { DoubleArray(3) }
matmul3x3(s2, t, s2t2)
val sSum = Array(3) { row -> DoubleArray(3) { col -> s1[row][col] + s2t2[row][col] } }
val c1inv = arrayOf(
doubleArrayOf(0.0, 0.0, 0.5),
doubleArrayOf(0.0, -1.0, 0.0),
doubleArrayOf(0.5, 0.0, 0.0),
)
val m = Array(3) { DoubleArray(3) }
matmul3x3(c1inv, sSum, m)
// 4) M 의 3개 eigenvector 계산 (실수만 유지)
// DEBUG mode: Python 의 eigenvector 를 강제 주입해 downstream 만 검증.
val eigenvectors = if (debugForceEigenvector != null) listOf(debugForceEigenvector!!)
else eig3Real(m) ?: return null
// 5) 조건 4·a·c − b² > 0 인 eigenvector 선택
var picked: DoubleArray? = null
for (ev in eigenvectors) {
val cond = 4.0 * ev[0] * ev[2] - ev[1] * ev[1]
if (cond > 0) {
picked = ev
break
}
}
picked ?: return null
// 6) a1 = picked, a2 = T · a1
var a1 = picked
var a2 = matvec3(t, a1)
debugA2 = a2
var a = a1[0]; var b = a1[1]; var c = a1[2]
var d = a2[0]; var e = a2[1]; var f = a2[2]
// 부호 통일 — a<0 이면 전체 conic 부호 반전 (conic=0 은 부호 불변)
if (a < 0) { a = -a; b = -b; c = -c; d = -d; e = -e; f = -f }
val det2 = a * c - (b * b) / 4.0
if (det2 <= 0) return null
// A33 = [[a, b/2], [b/2, c]], center = solve(A33, [-d/2, -e/2])
// A33⁻¹ = (1/det2) · [[c, -b/2], [-b/2, a]]
val invA00 = c / det2
val invA01 = -(b / 2.0) / det2
val invA10 = -(b / 2.0) / det2
val invA11 = a / det2
val un0 = invA00 * (-d / 2.0) + invA01 * (-e / 2.0)
val vn0 = invA10 * (-d / 2.0) + invA11 * (-e / 2.0)
val fprime = a * un0 * un0 + b * un0 * vn0 + c * vn0 * vn0 + d * un0 + e * vn0 + f
val k = -fprime
if (k <= 0 || invA00 <= 0 || invA11 <= 0) return null
val unHalf = sqrt(k * invA00)
val vnHalf = sqrt(k * invA11)
val residuals = DoubleArray(n) {
val du = u[it] - un0
val dv = v[it] - vn0
val q = a * du * du + b * du * dv + c * dv * dv
abs(q / k - 1.0)
}
val y0 = ym + ysS * un0
val z0 = zm + zsS * vn0
val bSi = ysS * unHalf
val aAp = zsS * vnHalf
return Result(y0, z0, bSi, aAp, residuals)
}
// ─── 3×3 helpers ─────────────────────────────────────────────
private fun matmul3x3(a: Array<DoubleArray>, b: Array<DoubleArray>, out: Array<DoubleArray>) {
for (i in 0..2) for (j in 0..2) {
var s = 0.0
for (k in 0..2) s += a[i][k] * b[k][j]
out[i][j] = s
}
}
private fun matvec3(a: Array<DoubleArray>, v: DoubleArray): DoubleArray {
val out = DoubleArray(3)
for (i in 0..2) {
var s = 0.0
for (k in 0..2) s += a[i][k] * v[k]
out[i] = s
}
return out
}
private fun transpose3x3(a: Array<DoubleArray>): Array<DoubleArray> =
Array(3) { i -> DoubleArray(3) { j -> a[j][i] } }
private fun invert3x3(m: Array<DoubleArray>): Array<DoubleArray>? {
val a = m[0][0]; val b = m[0][1]; val c = m[0][2]
val d = m[1][0]; val e = m[1][1]; val f = m[1][2]
val g = m[2][0]; val h = m[2][1]; val i = m[2][2]
val det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
if (abs(det) < 1e-30) return null
val inv = det
return arrayOf(
doubleArrayOf((e * i - f * h) / inv, (c * h - b * i) / inv, (b * f - c * e) / inv),
doubleArrayOf((f * g - d * i) / inv, (a * i - c * g) / inv, (c * d - a * f) / inv),
doubleArrayOf((d * h - e * g) / inv, (b * g - a * h) / inv, (a * e - b * d) / inv),
)
}
/**
* 3×3 non-symmetric matrix 의 real eigenvector 목록.
*
* characteristic polynomial: p(λ) = −λ³ + c2·λ² + c1·λ + c0 = 0
* c2 = tr(M)
* c1 = −(m00·m11 + m00·m22 + m11·m22 − m01·m10 − m02·m20 − m12·m21)
* c0 = det(M)
*
* 부호 정리 : λ³ − c2·λ² − c1·λ − c0 = 0
*/
private fun eig3Real(m: Array<DoubleArray>): List<DoubleArray>? {
val m00 = m[0][0]; val m01 = m[0][1]; val m02 = m[0][2]
val m10 = m[1][0]; val m11 = m[1][1]; val m12 = m[1][2]
val m20 = m[2][0]; val m21 = m[2][1]; val m22 = m[2][2]
// λ³ + A·λ² + B·λ + C = 0
val A = -(m00 + m11 + m22)
val B = (m00 * m11 - m01 * m10) + (m00 * m22 - m02 * m20) + (m11 * m22 - m12 * m21)
val C = -(m00 * (m11 * m22 - m12 * m21)
- m01 * (m10 * m22 - m12 * m20)
+ m02 * (m10 * m21 - m11 * m20))
val roots = cubicRootsReal(A, B, C) ?: return null
// 각 real eigenvalue 마다 (M − λI) 의 null-space (rank-2 가정) 를 얻음
val vecs = mutableListOf<DoubleArray>()
for (lam in roots) {
val b = arrayOf(
doubleArrayOf(m00 - lam, m01, m02),
doubleArrayOf(m10, m11 - lam, m12),
doubleArrayOf(m20, m21, m22 - lam),
)
val v = nullVector3(b) ?: continue
vecs.add(v)
}
return if (vecs.isEmpty()) null else vecs
}
/** λ³ + A·λ² + B·λ + C = 0 의 real root 들 (Cardano + 삼각치환). */
private fun cubicRootsReal(A: Double, B: Double, C: Double): List<Double>? {
// depressed cubic t³ + p·t + q = 0 (λ = t − A/3)
val shift = A / 3.0
val p = B - A * A / 3.0
val q = 2.0 * A * A * A / 27.0 - A * B / 3.0 + C
val disc = -4.0 * p * p * p - 27.0 * q * q
val roots = mutableListOf<Double>()
if (disc > 0) {
// 3 개 real root (casus irreducibilis) — 삼각치환
val m = 2.0 * sqrt(-p / 3.0)
val arg = 3.0 * q / (p * m)
val theta = acos(arg.coerceIn(-1.0, 1.0)) / 3.0
for (k in 0..2) {
val t = m * cos(theta - 2.0 * Math.PI * k / 3.0)
roots.add(t - shift)
}
} else {
// 1 개 real root (+ 2 complex conjugate)
val D = q * q / 4.0 + p * p * p / 27.0
val sqrtD = sqrt(max(D, 0.0))
val u = cbrtSigned(-q / 2.0 + sqrtD)
val vv = cbrtSigned(-q / 2.0 - sqrtD)
roots.add(u + vv - shift)
}
return if (roots.isEmpty()) null else roots
}
private fun cbrtSigned(x: Double): Double =
if (x >= 0) Math.cbrt(x) else -Math.cbrt(-x)
/**
* 3×3 rank-2 행렬의 null-space 벡터 (unit norm 아님, 그냥 non-zero).
* 두 행의 cross product 로 계산. 세 pair 모두 시도해 largest norm 선택.
*/
private fun nullVector3(m: Array<DoubleArray>): DoubleArray? {
val pairs = listOf(0 to 1, 0 to 2, 1 to 2)
var best: DoubleArray? = null
var bestNorm = 0.0
for ((i, j) in pairs) {
val a = m[i]; val b = m[j]
val c = doubleArrayOf(
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
)
val nrm = sqrt(c[0] * c[0] + c[1] * c[1] + c[2] * c[2])
if (nrm > bestNorm) { bestNorm = nrm; best = c }
}
return if (bestNorm < 1e-12) null else best
}
private fun std(a: DoubleArray): Double {
val mean = a.average()
var s = 0.0
for (v in a) { val d = v - mean; s += d * d }
return sqrt(s / a.size)
}
}