""" 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)