Files
VesiscanClinicalAndroid/tools/align_analyze.py
T
dw.jang 4fc40219f2 fix(labdb): 600·601 의 sensor.imu 를 labdb 표준 모양(샘플 하나)으로 — CSV 내보내기에 IMU 가 비어 있었다
labdb 에서 CSV 로 받으면 ax..gz 열이 빈칸이고 raw JSON 에는 IMU 가 있다(2026-09-29
보고). 사이트의 파싱 문제가 아니다 — labdb 표준(labdb.md "000" 레코드)은
`sensor.imu` 가 샘플 하나 `{ax,ay,az,gx,gy,gz}` 이고 CSV 내보내기는 그 여섯 값을
편다. 병원 임상(600)·정렬(601)은 2026-09-21 부터 거기에 회차 샘플 전부(배열)를
넣고 있었다. 사내 임상(001)은 처음부터 표준대로 보내고 있었다.

  · LabdbSensor.of(samples): imu = 마지막 샘플 하나(표준·CSV 에 나옴),
    imu_samples = 전부(FIFO 순), imu_sample_count. 없으면 빈 객체(0 으로 안 채움).
    001 의 LabdbUploader·tools/labdb_upload.py 와 같은 모양.
  · HospitalLabdbPayload·AlignLabdbPayload 가 이것을 쓴다.
  · tools/align_analyze.py 는 imu_samples 를 우선 읽고, 09-21~28 분(imu 가 배열)도
    그대로 받는다.
  · docs/LABDB_DATATYPES.md §IMU 를 새 모양으로. 09-21~28 업로드분은 CSV 에서
    IMU 가 비는 이유와, 내보내기에서 배열이면 마지막 원소를 쓰는 선택 사항을 적었다.

시험: LabdbSensorTest 3건 신규, Align/Hospital 페이로드 시험을 새 모양으로
(services.labdb 38건 통과). 전체 168건 중 실패 3건은 옛 세션 임시 경로에 쓰는
덤프 시험(Cm3PerTraceDump·GoldenDump·PrecisionDump)으로 이 변경과 무관하다.
병원 폰(SM-A155N)에 설치 완료.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 09:26:41 +09:00

388 lines
16 KiB
Python

"""
Alignment session 상세 분석 — Python method_d 파이프라인으로 재현.
목표:
1. 각 record 별 채널별 검출 여부 (CH3 flicker 원인 pin-point)
2. Cross-channel 보정 전/후 결과 비교 (base vs corrected)
3. RollingAligner 실행 시 phase 전이와 action 시퀀스
4. CH3 raw 신호 특성 (low_echo span, peak amplitude)
"""
import os, sys, json, glob
from collections import Counter
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
sys.path.insert(0, r"c:/Projects/piezophantomtest/piezo-phantom-test")
import numpy as np
from vesiscan_test.library.method_d import detect_walls_multichannel_full
from vesiscan_test.library.method_d.detector import detect as method_d_detect
from vesiscan_test.library.method_d.preprocessing import preprocess_heavy, preprocess_light
from vesiscan_test.library.method_d.dsp import apply_tgc_pipeline
from vesiscan_test.library.method_d.config_d import DEFAULT_PARAMS as MD_PARAMS
from vesiscan_test.library import cross_channel
from vesiscan_test.library.runners import _ch_angles
from vesiscan_test.library.config_6ch import DEGREE, DISTANCE_PER_SAMPLE
import pandas as pd
import math
def load_records(path):
"""새 스키마: {session, records[]}, 각 record 는 channels[6] 각각 data[100]."""
with open(path, encoding='utf-8') as f:
d = json.load(f)
session = d['session']
recs_raw = d['records']
out = []
for r in recs_raw:
by_ch = {}
for c in r['channels']:
v = c.get('data')
if v is None or len(v) < 100:
continue
by_ch[int(c['ch'])] = np.asarray(v, dtype=float)
if len(by_ch) == 6:
sigs = np.stack([by_ch[i] for i in range(6)])
out.append({
'row_index': r.get('row_index'),
'timestamp': r.get('timestamp'),
'command_type': r.get('command_type'),
'sigs': sigs,
# 2026-09-29 부터 sensor.imu 는 labdb 표준(샘플 하나)이고 전체는 imu_samples.
# 그 전(09-21~28) 업로드분은 imu 자체가 배열이다 — 둘 다 "샘플 목록"으로 받는다.
'imu': (r.get('sensor') or {}).get('imu_samples', (r.get('sensor') or {}).get('imu')),
})
return session, out
def detect_base(sigs):
"""cross-channel 보정 없이 base detection."""
n_ch = sigs.shape[0]
heavy = np.stack([preprocess_heavy(sigs[i]) for i in range(n_ch)])
light = np.stack([preprocess_light(sigs[i]) for i in range(n_ch)])
heavy = apply_tgc_pipeline(pd.DataFrame(heavy), n_ch=n_ch).values.astype(float)
light = apply_tgc_pipeline(pd.DataFrame(light), n_ch=n_ch).values.astype(float)
results = []
for i in range(n_ch):
angle_factor = math.cos(math.radians(DEGREE[i])) if i < len(DEGREE) else 1.0
r = method_d_detect(
sigs[i],
denoised_heavy=heavy[i],
denoised_light=light[i],
otsu_ratio=MD_PARAMS.otsu_ratio * angle_factor,
params=MD_PARAMS,
)
results.append(r)
return results, light, heavy
def detect_with_cross(sigs):
"""cross-channel 보정 포함 detection."""
results, light, heavy, _ = detect_walls_multichannel_full(sigs)
angles = _ch_angles(sigs.shape[0], None)
cross_channel.apply_cross_channel(
results, angles, light, heavy, sigs,
validate_top=True, inward_post=True)
return results
def summarize_session(path):
session, records = load_records(path)
print(f"\n{'='*100}")
print(f"Session : {session.get('session_name', os.path.basename(path)[:60])}")
print(f"Device : {session.get('device_name', '?')} ({session.get('device_id', '?')})")
print(f"Records : {len(records)} ({session.get('start_time', '?')} → {session.get('end_time', '?')})")
if not records:
print(" (empty session — skip)")
return None
print(f"{'='*100}")
# per-record 진단
rows = []
for rec in records:
sigs = rec['sigs']
# base + cross
base_results, light, heavy = detect_base(sigs)
cross_results = detect_with_cross(sigs)
row = {
'idx': rec['row_index'],
}
for i in range(6):
row[f'B{i}'] = 'X' if base_results[i] is None else '.'
row[f'C{i}'] = 'X' if cross_results[i] is None else '.'
if base_results[i] is not None:
r = base_results[i]
row[f'ant{i}'] = r.ant
row[f'post{i}'] = r.post
row[f'ulen{i}'] = int(max(0, r.post - r.ant - 1))
row[f'lowamp{i}'] = float(r.low_amp)
rows.append(row)
df = pd.DataFrame(rows)
# Detection matrix (base vs cross)
print(f"\n{'Detection matrix':-^80}")
print(f" X=missing, .=detected (Base = per-channel detect only, Cross = + cross-channel)")
cols_b = [f'B{i}' for i in range(6)]
cols_c = [f'C{i}' for i in range(6)]
print(f" {'idx':>3} base(0..5) cross(0..5)")
for _, r in df.iterrows():
base_str = ''.join(r[cols_b].tolist())
cross_str = ''.join(r[cols_c].tolist())
marker = ' <== ch3 flick' if r['B3'] != r['C3'] else (' <== ch3 miss' if r['C3'] == 'X' else '')
print(f" {int(r['idx']):>3} {base_str} {cross_str} {marker}")
# CH3 flicker stat
ch3_base_miss = (df['B3'] == 'X').sum()
ch3_cross_miss = (df['C3'] == 'X').sum()
n = len(df)
print(f"\n{'CH3 detection stats':-^80}")
print(f" Base : {n - ch3_base_miss}/{n} detected ({(n-ch3_base_miss)/n*100:.1f}%)")
print(f" Cross : {n - ch3_cross_miss}/{n} detected ({(n-ch3_cross_miss)/n*100:.1f}%)")
# transitions (flicker rate)
seq_base = (df['B3'] == '.').astype(int).tolist()
seq_cross = (df['C3'] == '.').astype(int).tolist()
trans_base = sum(1 for i in range(1, n) if seq_base[i] != seq_base[i-1])
trans_cross = sum(1 for i in range(1, n) if seq_cross[i] != seq_cross[i-1])
print(f" Base flick transitions: {trans_base}")
print(f" Cross flick transitions: {trans_cross}")
# per-record: full detection pattern
print(f"\n{'Per-channel detection rate (Cross)':-^80}")
for i in range(6):
det = (df[f'C{i}'] == '.').sum()
print(f" CH{i}: {det}/{n} ({det/n*100:.1f}%)")
# urine_len distribution
print(f"\n{'Urine_len (base) — median / range per channel':-^80}")
for i in range(6):
col = f'ulen{i}'
if col not in df.columns: continue
vals = df[col].dropna()
if len(vals) > 0:
print(f" CH{i}: median={vals.median():.0f} min={vals.min():.0f} max={vals.max():.0f} n={len(vals)}")
# CH3 low_amp
print(f"\n{'CH3 low_amp (base) — smaller = deeper lumen echo, larger = shallow/noisy':-^80}")
la = df['lowamp3'].dropna()
if len(la) > 0:
print(f" CH3 low_amp: median={la.median():.1f} min={la.min():.1f} max={la.max():.1f}")
return df
def rolling_align_replay(path, accum=10, legacy=False):
"""RollingAligner 를 새 Kotlin 로직 (majority + relaxed + soft-hint) 그대로 재현.
legacy=True 로 부르면 기존 (연속 3회) 로직."""
from vesiscan_test.library.method_d.config_d import DEFAULT_PARAMS as MD_PARAMS
session, records = load_records(path)
buf = []
print(f"\n{'RollingAligner replay ' + ('LEGACY' if legacy else 'NEW'):-^90}")
print(f" window = {accum}")
print(f" {'idx':>3} {'phase':<12} {'relax':>5} {'chN':>3} {'CH3':>3} "
f"{'ch0..3 chord':>28} {'CV':>6} {'action':<24}")
# ── 파라미터 (Kotlin AlignmentConstants 와 동일) ──
V_WIN, V_MAJ = 6, 3
L_WIN, L_MAJ = 4, 3
STUCK_TH = 20
SOFT_AFTER = 12
phase = 'INITIAL'
initial_count = 0
verticalHit = 0 # legacy 만 사용
verticalHistory = [] # new: bool 링버퍼
verticalStuck = 0
stabilize = 0
stabilizeLost = []
centerOptStreak = 0
centerOptLost = []
relaxed = False
def push(hist, v, w):
hist.append(v)
while len(hist) > w: hist.pop(0)
for rec in records:
buf.append(rec['sigs'])
while len(buf) > accum:
buf.pop(0)
avg = np.mean(buf, axis=0)
results = detect_with_cross(avg)
detected = [r is not None for r in results]
ch3 = detected[3]
chords = []
for ch in range(4):
r = results[ch]
if r is None:
chords.append(0.0)
else:
ulen = max(0, r.post - r.ant - 1)
chords.append(ulen * math.cos(math.radians(DEGREE[ch])) * DISTANCE_PER_SAMPLE)
action = 'STOP'
if phase == 'INITIAL':
initial_count += 1
action = 'wait'
if initial_count >= accum:
phase = 'VERTICAL'
elif phase == 'VERTICAL':
if legacy:
if not ch3:
verticalHit = 0
action = 'MOVE_UP'
else:
verticalHit += 1
if verticalHit >= 3:
phase = 'CH3_STAB'; stabilize = 0
action = 'STOP → CH3_STAB'
else:
action = f'STOP hit={verticalHit}/3'
else:
push(verticalHistory, ch3, V_WIN)
verticalStuck += 1
hit_cnt = sum(verticalHistory)
# relaxed 진입 조건
if (not relaxed and verticalStuck >= STUCK_TH
and hit_cnt < V_MAJ
and all(detected[i] for i in range(3))):
relaxed = True
phase = 'CENTER_OPT'
centerOptStreak = 0
action = 'RELAXED ON → CENTER_OPT'
elif hit_cnt >= V_MAJ:
phase = 'CH3_STAB'; stabilize = 0
verticalHistory.clear(); verticalStuck = 0
action = f'STOP → CH3_STAB (hit {hit_cnt}/{V_WIN})'
else:
if verticalStuck >= SOFT_AFTER:
action = f'MOVE_UP soft (hit {hit_cnt}/{V_WIN})'
else:
action = f'MOVE_UP (hit {hit_cnt}/{V_WIN})'
elif phase == 'CH3_STAB':
stabilize += 1
if legacy:
if not ch3:
phase = 'VERTICAL'; verticalHit = 0
action = 'MOVE_UP (lost)'
elif stabilize >= accum:
phase = 'CENTER_OPT'; centerOptStreak = 0
action = 'STOP → CENTER_OPT'
else:
action = f'STOP {stabilize}/{accum}'
else:
push(stabilizeLost, (not ch3), L_WIN)
lost = sum(stabilizeLost)
if lost >= L_MAJ:
phase = 'VERTICAL'; stabilize = 0
stabilizeLost.clear(); verticalHistory.clear(); verticalStuck = 0
action = f'MOVE_UP true-lost ({lost}/{L_WIN})'
elif stabilize >= accum:
phase = 'CENTER_OPT'; centerOptStreak = 0; stabilizeLost.clear()
action = 'STOP → CENTER_OPT'
else:
action = f'STOP {stabilize}/{accum} flick {lost}/{L_WIN}'
elif phase == 'CENTER_OPT':
center_range = range(3) if relaxed else range(4)
if not relaxed and not legacy:
push(centerOptLost, (not ch3), L_WIN)
lost = sum(centerOptLost)
if lost >= L_MAJ:
phase = 'CH3_STAB'; stabilize = 0; centerOptStreak = 0; centerOptLost.clear()
action = f'MOVE_DOWN true-lost ({lost}/{L_WIN})'
goto_next = True
elif not ch3:
centerOptStreak = 0
action = f'STOP soft-flick ({lost}/{L_WIN})'
goto_next = True
else:
goto_next = False
elif not relaxed and legacy:
if not ch3:
phase = 'CH3_STAB'; stabilize = 0; centerOptStreak = 0
action = 'MOVE_DOWN ch3 lost'
goto_next = True
else:
goto_next = False
else:
goto_next = False
if not goto_next:
cr_list = list(center_range)
if not all(detected[i] for i in cr_list):
centerOptStreak = 0
missing = [i for i in cr_list if not detected[i]]
top_only = all(m <= 1 for m in missing) if missing else False
action = f"MOVE_{'DOWN' if top_only else 'UP'} miss:{missing}"
else:
ch_used = [chords[i] for i in cr_list]
m = np.mean(ch_used)
if m <= 0.1:
action = 'MOVE_UP chord~0'
phase = 'VERTICAL'; verticalHistory.clear(); verticalStuck = 0
else:
std = np.std(ch_used)
cv = std / m if m > 0 else 999
if cv <= 0.15:
centerOptStreak += 1
if centerOptStreak >= 3:
phase = 'LR_BAL'
action = f'STOP → LR (cv={cv:.2f})'
else:
action = f'STOP cv={cv:.2f} ({centerOptStreak}/3)'
else:
centerOptStreak = 0
if ch_used[-1] > ch_used[0]:
action = f'MOVE_DOWN cv={cv:.2f}'
else:
action = f'MOVE_UP cv={cv:.2f}'
elif phase == 'LR_BAL':
action = 'LR ...'
chord_str = ' '.join(f'{c:5.1f}' for c in chords)
cv_val = 0
cr_disp = range(3) if relaxed else range(4)
cr_list = list(cr_disp)
if all(detected[i] for i in cr_list) and np.mean([chords[i] for i in cr_list]) > 0.1:
cv_val = np.std([chords[i] for i in cr_list]) / np.mean([chords[i] for i in cr_list])
print(f" {rec['row_index']:>3} {phase:<12} {('Y' if relaxed else '.'):>5} "
f"{sum(detected):>3} {'Y' if ch3 else 'N':>3} {chord_str:>28} "
f"{cv_val:>6.2f} {action:<24}")
if __name__ == '__main__':
import argparse
p = argparse.ArgumentParser()
p.add_argument('files', nargs='*')
p.add_argument('--replay', action='store_true')
p.add_argument('--legacy', action='store_true', help="legacy (연속 3회) 로직으로 replay")
p.add_argument('--both', action='store_true', help="new + legacy 둘 다 replay")
p.add_argument('--accum', type=int, default=10)
p.add_argument('--no-summary', action='store_true', help="detection matrix 등 요약 스킵")
args = p.parse_args()
files = args.files
if not files:
files = sorted(glob.glob(r"c:/Projects/medilightv2android/data123/*.json"))
for f in files:
if not args.no_summary:
df = summarize_session(f)
if df is None: continue
else:
_, records = load_records(f)
if not records:
print(f"\n== {os.path.basename(f)}: empty, skip")
continue
print(f"\n== {os.path.basename(f)}: {len(records)} records")
if args.replay or args.both:
if args.both:
rolling_align_replay(f, accum=args.accum, legacy=False)
rolling_align_replay(f, accum=args.accum, legacy=True)
else:
rolling_align_replay(f, accum=args.accum, legacy=args.legacy)