#!/usr/bin/env python3 """ 파노라마 스티칭 정밀 진단 스크립트 ----------------------------------- 실제 캐시 프레임(temp_frames/f_XXXX.png)을 이용해 파노라마 스티칭의 각 단계를 정밀 추적합니다. 목적: 1. 스크롤 오프셋이 제대로 감지되는가? 2. `_merge_scroll_candidates` 씬 전환 감지가 정확한가? 3. `merge_panoramas_list` 템플릿 매칭이 반복 구간을 제대로 이어붙이는가? 4. 최종 파노라마에 실제로 누락된 마디가 있는가? 실행: C:\\ProgramData\\miniforge3\\envs\\score\\python.exe sim_stitch.py """ import sys from pathlib import Path import cv2 import numpy as np if sys.platform == "win32": sys.stdout.reconfigure(encoding="utf-8", errors="replace") sys.stderr.reconfigure(encoding="utf-8", errors="replace") FRAME_DIR = Path("output/temp_frames") OUT_DIR = Path("output/sim_stitch") OUT_DIR.mkdir(exist_ok=True) # ─── 기존 코드와 동일한 함수들 ──────────────────────────────────────────── def _find_white_tab_strip(frame, min_strip_ratio=0.10): h, w = frame.shape[:2] margin_x = int(w * 0.1) hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) _, s_ch, v_ch = cv2.split(hsv) roi_v = v_ch[:, margin_x:w - margin_x] roi_s = s_ch[:, margin_x:w - margin_x] pure_white = (roi_v > 180) & (roi_s < 40) bright_pastel = (roi_v > 200) & (roi_s < 100) tab_mask = pure_white | bright_pastel row_tab_ratio = np.mean(tab_mask, axis=1) bright_mask = row_tab_ratio > 0.5 max_gap = int(h * 0.02) regions, start, gap_count = [], None, 0 for i in range(h): if bright_mask[i]: if start is None: start = i gap_count = 0 else: if start is not None: gap_count += 1 if gap_count > max_gap: length = (i - gap_count) - start if length >= h * min_strip_ratio: regions.append((start, i - gap_count)) start = None if start is not None: length = (h - gap_count) - start if length >= h * min_strip_ratio: regions.append((start, h - gap_count)) if not regions: return None best = max(regions, key=lambda r: r[1] - r[0]) pad = int(h * 0.03) return (max(0, best[0] - pad), min(h, best[1] + pad)) def _has_tab_content(region): if region is None or region.size == 0: return False gray = cv2.cvtColor(region, cv2.COLOR_BGR2GRAY) if len(region.shape) == 3 else region h, w = gray.shape if h < 15 or w < 50: return False dark_ratio = np.sum(gray < 180) / gray.size return 0.02 < dark_ratio < 0.30 def compare_frames(f1, f2): g1 = cv2.cvtColor(f1, cv2.COLOR_BGR2GRAY) if len(f1.shape)==3 else f1 g2 = cv2.cvtColor(f2, cv2.COLOR_BGR2GRAY) if len(f2.shape)==3 else f2 if g1.shape != g2.shape: g2 = cv2.resize(g2, (g1.shape[1], g1.shape[0])) target_w = 480 if g1.shape[1] > target_w: scale = target_w / g1.shape[1] sz = (target_w, int(g1.shape[0] * scale)) g1 = cv2.resize(g1, sz) g2 = cv2.resize(g2, sz) mse = np.mean(((g1.astype(np.float32) - g2.astype(np.float32)) / 255.0)**2) return max(0.0, 1.0 - min(mse * 8.0, 1.0)) def _extract_tracking_channel(frame): if len(frame.shape) != 3: return frame return frame[:, :, 0] # Blue channel def _detect_scroll_offset(frame_a, frame_b, min_confidence=0.1): h, w = frame_a.shape[:2] ga = _extract_tracking_channel(frame_a) gb = _extract_tracking_channel(frame_b) tmpl_w = int(w * 0.5) template = ga[:, w - tmpl_w:] result = cv2.matchTemplate(gb, template, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc = cv2.minMaxLoc(result) scroll_px = (w - tmpl_w) - max_loc[0] if max_val < min_confidence or scroll_px <= 0: return (0, max_val) return (scroll_px, max_val) # ─── Main ──────────────────────────────────────────────────────────────────── def main(): paths = sorted(FRAME_DIR.glob("f_0*.png")) if not paths: print("❌ 프레임 없음:", FRAME_DIR); return print(f"[STITCH-SIM] {len(paths)}개 프레임") # 스트립 Y범위 계산 strip_tops, strip_bottoms = [], [] for p in paths[:30]: f = cv2.imread(str(p)) if f is None: continue s = _find_white_tab_strip(f) if s: strip_tops.append(s[0]); strip_bottoms.append(s[1]) med_top = int(np.median(strip_tops)) med_bottom = int(np.median(strip_bottoms)) print(f" 스트립 Y: {med_top} ~ {med_bottom}") # 탭 크롭 추출 + MSE 중복 제거 (기존 로직) SIMILARITY_THRESHOLD = 0.95 candidates, all_compared = [], [] for p in paths: f = cv2.imread(str(p)) if f is None: continue h = f.shape[0] crop = f[max(0, med_top):min(h, med_bottom), :] if not _has_tab_content(crop): continue compare_img = cv2.resize(crop, (480, 120), interpolation=cv2.INTER_AREA) if any(compare_frames(compare_img, ref) >= SIMILARITY_THRESHOLD for ref in all_compared): continue candidates.append(crop) all_compared.append(compare_img) print(f"\n[1단계] MSE 중복제거 후 후보: {len(candidates)}개 프레임") # 스크롤 오프셋 분석 — 연속 프레임 간 이동량 측정 print(f"\n[2단계] 연속 프레임 스크롤 오프셋 분석:") print(f" {'idx':>4} {'scroll_px':>10} {'conf':>6} {'씬전환':>8}") print(f" {'-'*40}") scroll_data = [] prev_s = 0 prev_conf = 1.0 for i in range(1, len(candidates)): s, conf = _detect_scroll_offset(candidates[i-1], candidates[i]) is_cut = (conf <= 0.15) or (abs(s - prev_s) > 100) or (prev_conf - conf > 0.4) scroll_data.append((i, s, conf, is_cut)) mark = "✂ CUT" if is_cut else "" print(f" {i:>4} {s:>10}px {conf:>6.3f} {mark}") prev_s = s prev_conf = conf n_cuts = sum(1 for _, _, _, cut in scroll_data if cut) print(f"\n → 씬 전환 감지 횟수: {n_cuts}개 (예상: 1~3개)") print(f" → 분절 세그먼트: {n_cuts+1}개") # 세그먼트별 파노라마 스티칭 print(f"\n[3단계] 세그먼트 파노라마 스티칭:") segments = [] current_seg = [candidates[0]] for i, (idx, s, conf, is_cut) in enumerate(scroll_data): if is_cut: segments.append(current_seg) current_seg = [candidates[idx]] else: current_seg.append(candidates[idx]) segments.append(current_seg) panos = [] for seg_i, seg in enumerate(segments): if len(seg) == 1: panos.append(seg[0]) print(f" 세그먼트 {seg_i}: 1프레임 → 스티칭 불필요 ({seg[0].shape[1]}px)") continue min_h = min(f.shape[0] for f in seg) panorama = seg[0][:min_h, :] for i in range(1, len(seg)): curr = seg[i][:min_h, :] scroll_px, conf = _detect_scroll_offset(seg[i-1][:min_h, :], curr) if scroll_px > 0 and conf > 0.15: new_strip = curr[:, curr.shape[1] - scroll_px:] panorama = np.hstack([panorama, new_strip]) else: panorama = np.hstack([panorama, curr]) panos.append(panorama) print(f" 세그먼트 {seg_i}: {len(seg)}프레임 → 파노라마 {panorama.shape[1]}px") cv2.imwrite(str(OUT_DIR / f"raw_pano_{seg_i:02d}.png"), panorama) # merge_panoramas_list 단계 진단 print(f"\n[4단계] 파노라마 병합 (merge_panoramas_list):") print(f" 병합 전: {len(panos)}개 파노라마") if len(panos) > 1: merged_list = [] current_master = panos[0].copy() for i in range(1, len(panos)): next_pano = panos[i].copy() head_w = min(800, next_pano.shape[1]) head = next_pano[:, :head_w] search_w = min(1500, current_master.shape[1]) search_region = current_master[:, -search_w:] h_gray = _extract_tracking_channel(head) s_gray = _extract_tracking_channel(search_region) matched = False if h_gray.shape[1] <= s_gray.shape[1] and h_gray.shape[0] == s_gray.shape[0]: res = cv2.matchTemplate(s_gray, h_gray, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc = cv2.minMaxLoc(res) print(f" 파노라마 {i}: 템플릿 매칭 max_val={max_val:.3f}", end="") if max_val > 0.60: match_x = max_loc[0] abs_x = current_master.shape[1] - search_w + match_x skip = current_master.shape[1] - abs_x append_part = next_pano[:, skip:] if append_part.shape[1] > 0: current_master = np.hstack([current_master, append_part]) matched = True print(f" → ✅ 매칭 성공 (이어붙임, skip={skip}px)") else: print(f" → ❌ 매칭 실패 (score 낮음, 새 파노라마로 분리)") else: print(f" 파노라마 {i}: 크기 불일치로 매칭 불가") if not matched: merged_list.append(current_master) current_master = next_pano merged_list.append(current_master) else: merged_list = panos print(f"\n 병합 후: {len(merged_list)}개 파노라마") for i, m in enumerate(merged_list): print(f" 최종 파노라마 {i}: {m.shape[1]}x{m.shape[0]}px") cv2.imwrite(str(OUT_DIR / f"final_pano_{i:02d}.png"), m) # 마디 구분선 탐지 결과 진단 print(f"\n[5단계] 마디 구분선(|) 탐지:") def _detect_measure_bars(gray_pano): _, thresh = cv2.threshold(gray_pano, 200, 255, cv2.THRESH_BINARY_INV) h, w = thresh.shape row_sums = np.sum(thresh, axis=1) / 255 staff_rows = np.where(row_sums > w * 0.5)[0] if len(staff_rows) < 2: return [] top_line = staff_rows[0] bottom_line = top_line for r in staff_rows: if r - top_line > 100: break bottom_line = r staff_region = thresh[top_line:bottom_line+1, :] expected_h = bottom_line - top_line + 1 if expected_h < 10: return [] col_sums = np.sum(staff_region, axis=0) / 255 bar_cols = np.where(col_sums >= expected_h * 0.8)[0] measures = [] curr = [] for c in bar_cols: if not curr: curr.append(c) else: if c - curr[-1] < 10: curr.append(c) else: measures.append(int(np.mean(curr))) curr = [c] if curr: measures.append(int(np.mean(curr))) return measures for i, m in enumerate(merged_list): # Red 채널 (출력용) gray = m[:, :, 2] bars = _detect_measure_bars(gray) print(f" 파노라마 {i} ({m.shape[1]}px): {len(bars)}개 마디 구분선 탐지") if bars: intervals = [bars[j+1]-bars[j] for j in range(len(bars)-1)] if intervals: print(f" 마디 간격: min={min(intervals)}, max={max(intervals)}, mean={np.mean(intervals):.0f}px") print(f" 처음 5개 좌표: {bars[:5]}") print(f"\n[STITCH-SIM 완료]") print(f" 결과 저장: {OUT_DIR}") print(f" 핵심 체크포인트:") print(f" - 씬 전환 {n_cuts}회 → {n_cuts+1}개 세그먼트 분리") print(f" - 최종 병합 파노라마: {len(merged_list)}개") total_bars = sum(len(_detect_measure_bars(m[:,:,2])) for m in merged_list) print(f" - 총 탐지 마디 구분선: {total_bars}개") if __name__ == "__main__": main()