82634c2107
새 알고리즘 모듈 (7개): - ImpulseReject: 2-pass Hampel filter (lumen 내 impulse 제거) - MedianFilter: running-median pre-filter (window=7, speckle 제거) - MorphClose: morphological closing on lumen mask (disabled) - StaLta: STA/LTA impulse detector (Allen 1978, wall edge vs reverberation) - ChordConsensus: Tukey MAD outlier + Fischler-Bolles pair test - DetectionSanity: detection sanity checks - SweepStabilizer: sweep temporal stability 변경된 모듈 (9개): - V41Detector: 2-pass pipeline (ImpulseReject.detectWithLumenClean), BModeScore V41_WEIGHTS (wamp+stalta), ChordConsensus filtering - BvEstimation: ChordConsensus filter → trusted channel only - DetectLumenFirst: running-median pre-filter, V41_PARAMS, mergeGapMax=5, Kremkau half-amplitude ant fallback - AnatomicalGate: antDepthMin 22→10mm, strict threshold 제거 - BModeScore: V41_WEIGHTS (far=0.15, dark=0.10, wamp=0.45, stalta=0.20) - WallSelect: MAX_PEAK_CANDIDATES_POST=64 - SpanUtils, Otsu, BvDispatchResult 업데이트 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
93 lines
3.2 KiB
Kotlin
93 lines
3.2 KiB
Kotlin
/*
|
|
* Copyright (c) 2026 Medithings Co., Ltd.
|
|
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
|
|
* Project: CharlesKWONsLaw — wall-detect live compare
|
|
*
|
|
* Stability check on V41 detection outputs. Complements SweepStabilizer:
|
|
* the stabilizer guards the INPUT, this guards the OUTPUT.
|
|
*
|
|
* Per-channel rules (relative to the previous live frame):
|
|
* • antIdx jump > maxIndexJump → unstable
|
|
* • postIdx jump > maxIndexJump → unstable
|
|
* • |chord change| / chord > maxChordRatio → unstable
|
|
*
|
|
* "unstable" is a flag for the diagnostic log, NOT a hard reject — V41's
|
|
* own anatomical gate already drops genuinely-bad detections. This layer
|
|
* just surfaces "this channel's reading just jumped, double-check".
|
|
*/
|
|
package com.example.medilightv2android.walldetect
|
|
|
|
import com.example.medilightv2android.walldetect.dto.ChannelResult
|
|
import kotlin.math.abs
|
|
|
|
class DetectionSanity(
|
|
private val maxIndexJump: Int = 8,
|
|
private val maxChordRatio: Double = 0.20,
|
|
private val channels: Int = 6,
|
|
) {
|
|
private val lastAnt = IntArray(channels) { -1 }
|
|
private val lastPost = IntArray(channels) { -1 }
|
|
|
|
data class ChannelReport(
|
|
val channel: Int,
|
|
val antJump: Int?,
|
|
val postJump: Int?,
|
|
val chordChangeRatio: Double?,
|
|
val unstable: Boolean,
|
|
)
|
|
|
|
fun reset() {
|
|
for (i in lastAnt.indices) { lastAnt[i] = -1; lastPost[i] = -1 }
|
|
}
|
|
|
|
fun check(perChannel: List<ChannelResult>): List<ChannelReport> {
|
|
val reports = mutableListOf<ChannelReport>()
|
|
for ((ch, cr) in perChannel.withIndex()) {
|
|
val ant = cr.antIdx
|
|
val post = cr.postIdx
|
|
|
|
val prevA = lastAnt.getOrElse(ch) { -1 }
|
|
val prevP = lastPost.getOrElse(ch) { -1 }
|
|
|
|
var antJump: Int? = null
|
|
var postJump: Int? = null
|
|
var chordRatio: Double? = null
|
|
var unstable = false
|
|
|
|
if (ant != null && prevA >= 0) {
|
|
val jump = abs(ant - prevA)
|
|
antJump = jump
|
|
if (jump > maxIndexJump) unstable = true
|
|
}
|
|
if (post != null && prevP >= 0) {
|
|
val jump = abs(post - prevP)
|
|
postJump = jump
|
|
if (jump > maxIndexJump) unstable = true
|
|
}
|
|
if (ant != null && post != null && prevA >= 0 && prevP >= 0) {
|
|
val curChord = (post - ant).toDouble()
|
|
val prevChord = (prevP - prevA).toDouble()
|
|
if (prevChord > 0.0) {
|
|
val r = abs(curChord - prevChord) / prevChord
|
|
chordRatio = r
|
|
if (r > maxChordRatio) unstable = true
|
|
}
|
|
}
|
|
|
|
reports += ChannelReport(
|
|
channel = ch,
|
|
antJump = antJump,
|
|
postJump = postJump,
|
|
chordChangeRatio = chordRatio,
|
|
unstable = unstable,
|
|
)
|
|
|
|
// Update state ONLY when we have a valid current detection,
|
|
// otherwise comparison against -1 sentinel resumes after gap.
|
|
if (ant != null) lastAnt[ch] = ant
|
|
if (post != null) lastPost[ch] = post
|
|
}
|
|
return reports
|
|
}
|
|
}
|