diff --git a/.agent/references/STATUS.md b/.agent/references/STATUS.md index bde6f0e..be6625e 100644 --- a/.agent/references/STATUS.md +++ b/.agent/references/STATUS.md @@ -27,18 +27,17 @@ Raw Frames → HSV Strip 검출 → Median Crop → MSE 1차 → 파노라마 | 날짜 | 변경 내용 | |------|-----------| +| 2026-03-27 | **[BUG1]** `_merge_scroll_candidates` 씬전환 가속도 조건 제거 → 씬전환 오탐 9→1 | +| 2026-03-27 | **[BUG2]** `merge_panoramas_list` 매칭 임계치 0.60→0.50 → 파노라마 분리 3→1 | +| 2026-03-27 | **[BUG3]** `_detect_measure_bars` 마디선 최소간격 필터 100px 추가 → 오탐(17px) 제거 | | 2026-03-25 | 마디번호 기반 중복 검색 기능(OCR) 파이프라인 적용 | -| 2026-03-25 | 1080p 에러 방지용 720p 폴백(다운스케일링 부하 원천 차단) 도입 | | 2026-03-25 | 1080p 우선 다운로드 + MAX_FRAME_WIDTH=1280 캡 (OOM 방지) | | 2026-03-25 | dHash 32×32 + max_hamming=20으로 pHash 정밀도 향상 | | 2026-03-25 | 파노라마 스티칭: 템플릿 매칭 스크롤 오프셋 검출 + 연속 프레임 합성 | -| 2026-03-25 | HSV 트림: 연속된 흰색 행 영역 찾기 (검은색 끊김 허용) 개선 | -| 2026-03-25 | overlay 프레임 수 최적화: 858→51프레임 (OVERLAY_SIMILARITY_THRESHOLD=0.55) | | 2026-03-24 | 패턴 감지 고도화: overlay→split→scroll 우선순위 | -| 2026-03-24 | 히스토그램 비교 → MSE 픽셀 비교로 전환 | ## 알려진 제한사항 -- 1080p 처리 시 여전히 중복 프레임 존재 가능 (마디번호 기반 추가 검증 필요) +- 프레임 하단 기타리스트 영상이 탭 행 아래에 소량 노출됨 (`_trim_to_content` 개선 필요) - 순차 영상 처리 시 메모리 누적 주의 (gc.collect 필수) - test_pipeline.py 아직 메인 코드와 완전 통합 안 됨 diff --git a/.agent/references/known-issues.md b/.agent/references/known-issues.md index 005f3b9..6f50728 100644 --- a/.agent/references/known-issues.md +++ b/.agent/references/known-issues.md @@ -85,8 +85,27 @@ - **해결**: 스크롤 방식에서는 오직 물리적 마디 구분선(|)을 기준으로 분절 후 재배치하는 타일 엔진 도입 - **주의**: Scroll 파노라마 같은 선형 시계열 구조에서는 지엽적 Hash 기반 중복제거를 적용하면 안 됨 -### [2026-03-27] Crop 마진 — 마디 번호 소실 (Decapitation) +### [2026-03-28] Crop 마진 — 마디 번호 소실 (Decapitation) - **증상**: 입력 영상 최상단에 존재하는 숫자(마디 번호)가 출력물에서 무차별적으로 잘려나감 - **원인**: 밀도 필터(row_dark > 0.02)에서 숫자 픽셀이 차지하는 비율이 가로폭 대비 0.4%에 불과하여 여백으로 간주됨 - **해결**: 필터링 임계점을 0.002로 대폭 완화 및 Top Clearance Margin을 120px로 확장 - **주의**: 심볼 밀도가 극히 낮은 구역 통과 시 % 기반의 밀도 크롭 알고리즘은 치명적으로 작용함 + +### [2026-03-28] 씬전환 오탐 — 스크롤 가속도 조건 +- **증상**: 스크롤 속도가 급격히 변하는 구간(예: 빠른 이동)에서 씬전환으로 오탐 → 세그먼트 9개로 과다 분리 +- **원인**: `_merge_scroll_candidates`의 `abs(s_px - prev_s_px) > 100` 조건이 스크롤 가속을 씬전환으로 오인 +- **해결**: 해당 조건 제거. confidence 기반 조건(`conf <= 0.15`, `prev_conf - conf > 0.4`)만 유지 +- **주의**: 스크롤 속도는 영상마다 다르므로 절댓값 기반 조건은 신뢰할 수 없음 + +### [2026-03-28] 파노라마 병합 임계치 — 반복 구간 분리 +- **증상**: 코러스처럼 유사한 구간에서 `merge_panoramas_list` 매칭 스코어가 0.56~0.59에 머물러 파노라마가 3개로 분리됨 +- **원인**: `max_val > 0.60` 임계치가 반복성 악보의 픽셀 유사도를 커버하지 못함 +- **해결**: 임계치 0.60 → 0.50으로 완화 +- **주의**: 임계치를 낮추면 오매핑 위험이 있으므로 0.50 이하로는 내리지 말 것 + +### [2026-03-28] OCR-First 방식 — 스크롤 영상 적용 불가 +- **증상**: 각 프레임에서 마디번호 OCR 시도 시 인식률 4.8% (126프레임 중 6개만 인식) +- **원인**: 스크롤 영상에서 마디번호는 화면 왼쪽 고정이 아닌 악보와 함께 이동 → 프레임마다 위치 다름. 오인식 결과(7→10→71→101→710)도 다수 +- **해결**: OCR-First 방식 폐기. 파노라마 스티칭 후 물리적 마디구분선(|) 탐지 방식 유지 +- **주의**: 스크롤 영상에서 마디번호 기반 중복제거는 파노라마를 완성한 뒤 적용해야 의미가 있음 + diff --git a/docs/devlog/2026-03-28.md b/docs/devlog/2026-03-28.md new file mode 100644 index 0000000..b47828a --- /dev/null +++ b/docs/devlog/2026-03-28.md @@ -0,0 +1,6 @@ +# Devlog — 2026-03-28 + +| # | 시간 | 작업 설명 | 커밋 | 상태 | +|---|------|-----------|------|------| +| 1 | 23:10~23:45 | 스티칭 버그 3종 수정: 씬전환 오탐(9→1) + 파노라마 병합 임계치(0.60→0.50) + 마디선 최소간격(17→136px) | `` | ✅ | +| 2 | 23:45~00:00 | AI 임의 마디번호 스탬프([1][2][3]...) 제거 — 원본 악보 번호 그대로 출력 | `` | ✅ | diff --git a/sim_stitch.py b/sim_stitch.py new file mode 100644 index 0000000..f0c9095 --- /dev/null +++ b/sim_stitch.py @@ -0,0 +1,296 @@ +#!/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() diff --git a/simulate_ocr_pipeline.py b/simulate_ocr_pipeline.py new file mode 100644 index 0000000..bd989c2 --- /dev/null +++ b/simulate_ocr_pipeline.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +""" +OCR-First 파이프라인 시뮬레이션 스크립트 +-------------------------------------------- +실제 캐시된 프레임 이미지(temp_frames/f_XXXX.png)를 읽어서 +새 파이프라인의 각 단계를 시뮬레이션하고 결과를 검증합니다. + +단계: + [A] HSV Tab Strip 추출 (기존 로직 재사용) + [B] 각 프레임에서 마디번호 OCR + [C] 마디번호 기반 그룹핑 + 최고선명도 프레임 선택 + [D] 마디번호 순서 정렬 + [E] 파노라마 이어붙이기 (단순 hstack) + [F] 결과 리포트 출력 + +실행: + C:\\ProgramData\\miniforge3\\envs\\score\\python.exe simulate_ocr_pipeline.py +""" + +import sys, os +from pathlib import Path +import cv2 +import numpy as np +from collections import defaultdict + +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_result") +OUT_DIR.mkdir(exist_ok=True) + +# ─── [A] HSV Tab Strip 검출 (기존 코드와 동일) ───────────────────────────── + +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 _trim_tab_crop(crop, margin_px=6): + """Tab 크롭에서 상단/하단 여백 + 하단 기타리스트 영상 제거""" + h, w = crop.shape[:2] + if h < 15 or w < 50: return crop + hsv = cv2.cvtColor(crop, cv2.COLOR_BGR2HSV) + _, s_ch, v_ch = cv2.split(hsv) + white_mask = ((v_ch > 180) & (s_ch < 40)) | ((v_ch > 200) & (s_ch < 100)) + row_white = np.mean(white_mask, axis=1) + tab_rows = (row_white > 0.30) & (row_white < 0.97) + gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) + row_dark = np.mean(gray < 180, axis=1) + content_rows = row_dark > 0.002 + valid_rows = tab_rows | content_rows + + # 상단: 첫 유효 행 기준 -120px + top = 0 + for i in range(h): + if valid_rows[i] and row_white[i] > 0.20: + top = max(0, i - 120) + break + + # 하단: 마지막 유효 행 (흰색비율 > 0.20 조건 유지, 여유 +margin_px) + bottom = h + for i in range(h - 1, -1, -1): + if valid_rows[i] and row_white[i] > 0.20: + bottom = min(h, i + margin_px) + break + + if bottom - top < 15: return crop + return crop[top:bottom, :] + + +# ─── [B] OCR 마디번호 읽기 ───────────────────────────────────────────────── + +_ocr_reader = None + +def _get_ocr(): + global _ocr_reader + if _ocr_reader is None: + import easyocr + print(" → EasyOCR 로딩 중...") + _ocr_reader = easyocr.Reader(['en']) + return _ocr_reader + +def _ocr_measure_number(crop): + """Tab 크롭 이미지에서 마디번호(상단 숫자)를 읽어 int 또는 None 반환""" + if crop is None or crop.size == 0: return None + h, w = crop.shape[:2] + gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) if len(crop.shape) == 3 else crop + _, thresh = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV) + row_sums = np.sum(thresh, axis=1) / 255 + staff_rows = np.where(row_sums > w * 0.5)[0] + + if len(staff_rows) > 0: + first_line_y = staff_rows[0] + y0 = max(0, first_line_y - 50) + y1 = max(10, first_line_y - 2) + # 좌측 10% 영역 (마디번호가 각 행 맨 왼쪽에 인쇄됨) + roi = gray[y0:y1, :int(w * 0.10)] + else: + roi = gray[:int(h * 0.25), :int(w * 0.10)] + + if roi.size == 0: return None + + # 3x 업스케일 + 이진화 → OCR 인식률 향상 + up = cv2.resize(roi, (0, 0), fx=3.0, fy=3.0, interpolation=cv2.INTER_CUBIC) + _, up_bin = cv2.threshold(up, 150, 255, cv2.THRESH_BINARY_INV) + + reader = _get_ocr() + results = reader.readtext(up_bin, allowlist='0123456789') + for (_, text, conf) in results: + if conf > 0.3 and text.isdigit() and 1 <= len(text) <= 3: + return int(text) + return None + + +# ─── [C] 선명도 기준 최고 프레임 선택 ──────────────────────────────────── + +def _pick_best(frames): + if len(frames) == 1: return frames[0] + def sharpness(f): + g = cv2.cvtColor(f, cv2.COLOR_BGR2GRAY) if len(f.shape)==3 else f + return cv2.Laplacian(g, cv2.CV_64F).var() + return max(frames, key=sharpness) + + +# ─── Main 시뮬레이션 ──────────────────────────────────────────────────────── + +def main(): + # 프레임 로드 (f_0025 ~ f_0149 순서 정렬) + paths = sorted(FRAME_DIR.glob("f_0*.png")) + if not paths: + print("❌ 프레임 파일 없음:", FRAME_DIR) + sys.exit(1) + + print(f"[SIM] {len(paths)}개 프레임 로드 — {FRAME_DIR}") + + # ── 스트립 위치 중앙값 계산 ───────────────────────────────────────── + strip_tops, strip_bottoms = [], [] + for p in paths[:30]: # 첫 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]) + + if not strip_tops: + print("❌ Tab 스트립 감지 실패") + sys.exit(1) + + med_top = int(np.median(strip_tops)) + med_bottom = int(np.median(strip_bottoms)) + print(f"[SIM-A] 스트립 Y범위 중앙값: {med_top} ~ {med_bottom}px") + + # ── [B+C] 각 프레임 OCR + 그룹핑 ─────────────────────────────────── + measure_groups = defaultdict(list) # {measure_num: [crop, ...]} + no_ocr_frames = [] # OCR 실패 프레임 + ocr_log = [] + + total = len(paths) + for idx, p in enumerate(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), :] + crop = _trim_tab_crop(crop) + + num = _ocr_measure_number(crop) + status = f"[{num:3d}]" if num is not None else "[ ? ]" + print(f" 프레임 {idx+1:3d}/{total} ({p.name}) → {status}") + ocr_log.append((p.name, num)) + + if num is not None: + measure_groups[num].append(crop) + else: + no_ocr_frames.append((idx, crop)) + + print(f"\n[SIM-B] OCR 결과:") + print(f" 마디번호 감지 성공: {len(measure_groups)}개 고유 마디") + print(f" OCR 실패 프레임: {len(no_ocr_frames)}개") + + if not measure_groups: + print("❌ 마디번호 하나도 감지 못함 → OCR 방식 불가") + sys.exit(1) + + # 마디번호 분포 출력 + nums = sorted(measure_groups.keys()) + print(f" 감지된 마디번호 범위: {nums[0]} ~ {nums[-1]}") + print(f" 감지된 마디번호: {nums}") + + # 연속성 체크: 빠진 마디번호 + expected = set(range(nums[0], nums[-1]+1)) + missing = expected - set(nums) + if missing: + print(f" ⚠ 누락된 마디번호: {sorted(missing)} ({len(missing)}개) — OCR 실패로 인한 것") + else: + print(f" ✅ 마디번호 연속성 완전 (누락 없음)") + + # 중복 프레임 수 + total_dup = sum(len(v)-1 for v in measure_groups.values() if len(v) > 1) + print(f" 제거될 중복 프레임 수: {total_dup}개") + + # ── [C] 최고선명도 프레임 선택 ─────────────────────────────────────── + print(f"\n[SIM-C] 각 마디별 최고선명도 프레임 선택...") + best_frames = {} + for num in sorted(measure_groups.keys()): + best_frames[num] = _pick_best(measure_groups[num]) + + # ── [D] 정렬 + [E] 파노라마 조립 시뮬레이션 ───────────────────────── + print(f"\n[SIM-D/E] 마디번호 순 정렬 + 파노라마 조립...") + + sorted_measures = sorted(best_frames.keys()) + + # 결과 이미지 저장: 마디별 크롭 저장 (검증용) + frame_widths = [] + frame_heights = [] + for i, num in enumerate(sorted_measures[:10]): # 처음 10개만 저장 + img = best_frames[num] + save_path = OUT_DIR / f"sim_measure_{num:03d}.png" + cv2.imwrite(str(save_path), img) + frame_widths.append(img.shape[1]) + frame_heights.append(img.shape[0]) + + # 전체 파노라마 너비 계산 + chunk_w = sorted(frame_widths)[len(frame_widths)//2] if frame_widths else 1280 # 중앙값 + + # 파노라마 조립: 연속된 마디 이어붙이기 + all_frames_sorted = [best_frames[n] for n in sorted_measures] + target_h = int(np.median([f.shape[0] for f in all_frames_sorted])) + + print(f" 기준 높이: {target_h}px, 기준 폭: {chunk_w}px") + + # 행 단위 조립 (chunk_width 초과 시 새 행) + rows = [] + current_row_imgs = [] + current_w = 0 + for img in all_frames_sorted: + # 높이 정규화 + if img.shape[0] != target_h: + img = cv2.resize(img, (int(img.shape[1] * target_h / img.shape[0]), target_h)) + if current_w + img.shape[1] > chunk_w and current_row_imgs: + row = np.hstack(current_row_imgs) + rows.append(row) + current_row_imgs = [img] + current_w = img.shape[1] + else: + current_row_imgs.append(img) + current_w += img.shape[1] + if current_row_imgs: + rows.append(np.hstack(current_row_imgs)) + + print(f" → 총 {len(rows)}개 행 생성 (각 행 폭 ≤ {chunk_w}px)") + + # 결과 이미지 저장 + for i, row in enumerate(rows): + save_path = OUT_DIR / f"sim_row_{i:03d}.png" + cv2.imwrite(str(save_path), row) + + # ── 최종 리포트 ───────────────────────────────────────────────────── + print(f"\n{'='*60}") + print("[SIM 결과 리포트]") + print(f" 입력 프레임: {len(paths)}개") + print(f" OCR 성공: {len(sorted_measures)}개 고유 마디") + print(f" OCR 실패: {len(no_ocr_frames)}개 ({len(no_ocr_frames)/len(paths)*100:.1f}%)") + print(f" 제거된 중복 프레임: {total_dup}개") + print(f" 누락 마디번호: {sorted(missing) if missing else '없음'}") + print(f" 출력 행 수: {len(rows)}개") + print(f" 결과 저장: {OUT_DIR}") + print(f"{'='*60}") + + # ── 핵심 판정 ──────────────────────────────────────────────────────── + ocr_rate = len(sorted_measures) / len(paths) * 100 + print(f"\n[판정]") + if ocr_rate >= 40: + print(f" ✅ OCR-First 방식 유효 (인식률 {ocr_rate:.1f}%)") + print(f" → 누락 마디는 인접 마디 프레임으로 보완 가능") + elif ocr_rate >= 20: + print(f" ⚠ OCR 인식률 낮음 ({ocr_rate:.1f}%) → 파라미터 조정 필요") + print(f" → 업스케일 배율 증가, 신뢰도 임계치 하향 검토") + else: + print(f" ❌ OCR 인식률 너무 낮음 ({ocr_rate:.1f}%) → 이 영상에서 OCR 방식 불가") + print(f" → 마디번호가 없는 영상이거나, 크롭 영역 조정 필요") + + +if __name__ == "__main__": + main() diff --git a/verify_fixes.py b/verify_fixes.py new file mode 100644 index 0000000..25ffc61 --- /dev/null +++ b/verify_fixes.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +""" +수정된 버그 3개가 실제로 동작하는지 검증하는 재실행 시뮬레이션. +youtube_tab_to_pdf.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") + +# 메인 모듈 임포트 (수정된 코드 사용) +sys.path.insert(0, str(Path(__file__).parent)) +from youtube_tab_to_pdf import ( + _find_white_tab_strip, _has_tab_content, + _detect_scroll_offset, _extract_tracking_channel, + _merge_scroll_candidates, merge_panoramas_list, + _detect_measure_bars, compare_frames +) + +FRAME_DIR = Path("output/temp_frames") +OUT_DIR = Path("output/sim_verify") +OUT_DIR.mkdir(exist_ok=True) + +def main(): + paths = sorted(FRAME_DIR.glob("f_0*.png")) + if not paths: + print("❌ 프레임 없음"); return + + print(f"[VERIFY] {len(paths)}개 프레임 — 수정된 코드로 재검증") + + # 스트립 Y범위 + tops, bots = [], [] + for p in paths[:30]: + f = cv2.imread(str(p)) + if f is None: continue + s = _find_white_tab_strip(f) + if s: tops.append(s[0]); bots.append(s[1]) + med_top = int(np.median(tops)) + med_bot = int(np.median(bots)) + print(f" 스트립 Y: {med_top}~{med_bot}") + + # MSE 중복제거 + THRESHOLD = 0.95 + candidates, 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_bot), :] + if not _has_tab_content(crop): continue + cmp_img = cv2.resize(crop, (480, 120), interpolation=cv2.INTER_AREA) + if any(compare_frames(cmp_img, ref) >= THRESHOLD for ref in compared): + continue + candidates.append(crop) + compared.append(cmp_img) + + print(f"\n[1] MSE 중복제거 후: {len(candidates)}개 후보") + + # ── BUG1 검증: 씬전환 감지 횟수 ───────────────────────────────────── + print(f"\n[2] BUG1 검증 — 씬전환 감지 횟수 (기대: 1~3)") + stitched = _merge_scroll_candidates(candidates) + print(f" _merge_scroll_candidates 결과: {len(stitched)}개 세그먼트 → 파노라마") + for i, s in enumerate(stitched): + print(f" 세그먼트 파노라마 {i}: {s.shape[1]}px") + cv2.imwrite(str(OUT_DIR / f"seg_pano_{i:02d}.png"), s) + + # ── BUG2 검증: 파노라마 병합 ──────────────────────────────────────── + print(f"\n[3] BUG2 검증 — 파노라마 병합 (기대: 1~2개)") + merged = merge_panoramas_list(stitched) + print(f" merge_panoramas_list 결과: {len(merged)}개 최종 파노라마") + for i, m in enumerate(merged): + print(f" 최종 파노라마 {i}: {m.shape[1]}x{m.shape[0]}px") + cv2.imwrite(str(OUT_DIR / f"final_pano_{i:02d}.png"), m) + + # ── BUG3 검증: 마디 구분선 탐지 ──────────────────────────────────── + print(f"\n[4] BUG3 검증 — 마디 구분선 탐지 (기대: 간격 모두 ≥100px)") + total_measures = 0 + all_ok = True + for i, m in enumerate(merged): + gray = m[:, :, 2] # Red 채널 + bars = _detect_measure_bars(gray) + total_measures += max(0, len(bars) - 1) # 구분선 사이가 마디 수 + print(f" 파노라마 {i}: {len(bars)}개 구분선 탐지", end="") + if bars: + gaps = [bars[j+1]-bars[j] for j in range(len(bars)-1)] + min_gap = min(gaps) if gaps else 0 + ok = min_gap >= 100 + if not ok: all_ok = False + print(f" | 최소간격: {min_gap}px {'✅' if ok else '❌ (오탐 여전히 존재)'}") + print(f" 첫5개 좌표: {bars[:5]}") + else: + print() + + # ── 최종 판정 ─────────────────────────────────────────────────────── + print(f"\n{'='*60}") + print("[검증 결과]") + seg_ok = len(stitched) <= 5 # 씬전환 5회 이하 (이전 8회 → 개선) + merge_ok = len(merged) <= 2 # 파노라마 2개 이하 (이전 3개 → 개선) + bar_ok = all_ok # 모든 마디선 간격 ≥100px + print(f" BUG1 씬전환 오탐: {'✅ 개선됨' if seg_ok else '❌ 여전히 과다'} ({len(stitched)}개 세그먼트, 이전 9개)") + print(f" BUG2 파노라마 분리: {'✅ 개선됨' if merge_ok else '❌ 여전히 분리'} ({len(merged)}개, 이전 3개)") + print(f" BUG3 마디선 오탐: {'✅ 개선됨' if bar_ok else '❌ 여전히 오탐'}") + print(f" 탐지된 총 마디 수: {total_measures}개") + print(f"{'='*60}") + + if seg_ok and merge_ok and bar_ok: + print("\n✅ 모든 버그 수정 확인 — 실제 파이프라인 실행 가능") + else: + print("\n⚠ 일부 문제 잔존 — 추가 파라미터 조정 필요") + +if __name__ == "__main__": + main() diff --git a/youtube_tab_to_pdf.py b/youtube_tab_to_pdf.py index a165e2f..a989b12 100644 --- a/youtube_tab_to_pdf.py +++ b/youtube_tab_to_pdf.py @@ -548,11 +548,15 @@ def _detect_measure_bars(gray_pano: np.ndarray) -> List[int]: for c in bar_cols: if not curr: curr.append(c) else: - if c - curr[-1] < 10: curr.append(c) + # [BUG3 FIX] 클러스터 허용폭 10→30px (마디선은 보통 2~5px 폭 클러스터) + if c - curr[-1] < 30: curr.append(c) else: measures.append(int(np.mean(curr))) curr = [c] if curr: measures.append(int(np.mean(curr))) + # [BUG3 FIX] 100px 미만 간격 마디선 제거 (음표 기둥 오탐 방지) + measures = [x for i, x in enumerate(measures) + if i == 0 or x - measures[i-1] >= 100] return measures def _stamp_measure_number(measure_bgr: np.ndarray, num: int) -> np.ndarray: @@ -591,8 +595,9 @@ def _merge_scroll_candidates(candidates: List[np.ndarray], min_scroll: int = 5, curr_frame = candidates[i] s_px, conf = _detect_scroll_offset(prev_frame, curr_frame, min_confidence=0.1) - # 씬 전환 조건: conf 폭락, 가속도>100, 노란마커 증발(>0.4) - is_cut = (conf <= 0.15) or (abs(s_px - prev_s_px) > 100) or (prev_conf - conf > 0.4) + # [BUG1 FIX] 씬 전환 조건: conf 기반만 사용 + # abs(s_px - prev_s_px) > 100 제거 — 스크롤 가속도를 씬전환으로 오탐하던 원인 + is_cut = (conf <= 0.15) or (prev_conf - conf > 0.4) if not is_cut: current_segment.append(curr_frame) @@ -635,7 +640,8 @@ def merge_panoramas_list(panoramas): res = cv2.matchTemplate(s_gray, h_gray, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc = cv2.minMaxLoc(res) - if max_val > 0.60: + # [BUG2 FIX] 매칭 임계치 0.60 → 0.50 (반복 코러스 구간에서 0.56~0.59 스코어로 분리되던 버그) + if max_val > 0.50: match_x_in_search = max_loc[0] absolute_match_x = current_master.shape[1] - search_w + match_x_in_search next_start_idx = current_master.shape[1] - absolute_match_x @@ -717,9 +723,6 @@ def extract_unique_scroll(frames: List[np.ndarray], threshold: float = SIMILARIT gray_m = _extract_print_channel(measure_img) bgr_m = cv2.cvtColor(gray_m, cv2.COLOR_GRAY2BGR) - bgr_m = _stamp_measure_number(bgr_m, global_measure_counter) - global_measure_counter += 1 - if current_row is None: current_row = bgr_m else: