#!/usr/bin/env python3 """ YouTube Tab → PDF 캡처 파이프라인 YouTube 기타 TAB 영상에서 Tab 프레임을 추출하여 깔끔한 A4 PDF 악보로 만듭니다. 사용법: python youtube_tab_to_pdf.py "https://youtu.be/VIDEO_ID" python youtube_tab_to_pdf.py "https://youtu.be/VIDEO_ID" -o output.pdf --debug """ import argparse import os import sys import subprocess import shutil import re from pathlib import Path from typing import List, Tuple, Optional import cv2 import numpy as np import img2pdf from PIL import Image _ocr_reader = None def _get_ocr_reader(): global _ocr_reader if _ocr_reader is None: print(" → EasyOCR 모델 로딩 중 (초회 1번)...") try: import easyocr _ocr_reader = easyocr.Reader(['en']) except ImportError: print(" [경고] easyocr 라이브러리가 없습니다. OCR 중복 검증을 건너뜁니다.") return None return _ocr_reader def _dedup_by_measure_number(frames: List[np.ndarray]) -> List[np.ndarray]: """OCR을 이용해 Tab 좌측 상단의 마디 번호를 읽고, 연속으로 동일한 번호가 검출되면 중복으로 간주하고 제거합니다.""" reader = _get_ocr_reader() if not reader: return frames print(f" → 마디번호 기반 3차 중복 검증 시작 ({len(frames)} 프레임)") unique = [] last_measure_num = None for i, frame in enumerate(frames): h, w = frame.shape[:2] gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) if len(frame.shape) == 3 else frame # 동적 투영(Projection)을 통해 첫 번째 오선지(Staff line)의 Y좌표 스캔 _, thresh = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV) row_sums = np.sum(thresh, axis=1) / 255 # 폭의 50% 이상을 차지하는 검은 가로선을 오선지로 간주 staff_lines = np.where(row_sums > w * 0.5)[0] if len(staff_lines) > 0: first_line_y = staff_lines[0] # 오선지 바로 위 영역 45px ~ 오선지 까지 (여유공간 2px) + 좌측 8% 너비만 추출 (기타 코드 다이어그램 제외) crop_y_start = max(0, first_line_y - 45) crop_y_end = max(10, first_line_y - 2) crop = gray[crop_y_start:crop_y_end, :int(w * 0.08)] else: # 안전 장치: 오선지를 못 찾았을 경우 기존 하드코딩 비율 사용 crop = gray[:int(h * 0.25), :int(w * 0.08)] # 작은 마디번호의 인식률 극대화를 위해 3배 업스케일링 및 이진화 처리 upscaled = cv2.resize(crop, (0, 0), fx=3.0, fy=3.0, interpolation=cv2.INTER_CUBIC) _, upscaled_thresh = cv2.threshold(upscaled, 150, 255, cv2.THRESH_BINARY_INV) results = reader.readtext(upscaled_thresh, allowlist='0123456789') measure_num = None if results: # conf > 0.4 이면서 1~3자리의 숫자로만 이루어진 텍스트를 마디 번호로 간주 valid_results = [res[1] for res in results if res[2] > 0.4 and res[1].isdigit() and len(res[1]) <= 3] if valid_results: measure_num = valid_results[0] if measure_num is not None: if measure_num == last_measure_num: print(f" - 프레임 {i+1}: 마디번호 [{measure_num}] 중복 감지 (삭제)") continue last_measure_num = measure_num print(f" - 프레임 {i+1}: 마디번호 [{measure_num}] (유지)") else: print(f" - 프레임 {i+1}: 마디번호 미검출 (유지)") unique.append(frame) print(f" → OCR 3차: {len(unique)}개 고유 Tab 프레임") return unique # Windows 콘솔 인코딩 if sys.platform == "win32": sys.stdout.reconfigure(encoding="utf-8", errors="replace") sys.stderr.reconfigure(encoding="utf-8", errors="replace") # ─── 설정 ───────────────────────────────────────────────────────────────── DEFAULT_FPS = 2 SIMILARITY_THRESHOLD = 0.95 OVERLAY_SIMILARITY_THRESHOLD = 0.55 OVERLAY_MIN_AREA_RATIO = 0.05 OVERLAY_MAX_AREA_RATIO = 0.6 MIN_TAB_LINES = 4 # 프레임 추출 시 최대 폭 (1080p→1280p 다운스케일로 메모리 세이브) MAX_FRAME_WIDTH = 1280 # 검출용 업스케일 폭 (360p→960px, 1.5x → Tab 라인 두꺼워짐) DETECT_WIDTH = 960 PDF_DPI = 150 PDF_PAGE_WIDTH_MM = 210 PDF_PAGE_HEIGHT_MM = 297 PDF_MARGIN_MM = 10 TAB_GAP_MM = 3 # ─── Step 1: 다운로드 ───────────────────────────────────────────────────── def _find_yt_dlp() -> str: yt_dlp = shutil.which("yt-dlp") if yt_dlp: return yt_dlp for pyver in ["Python312", "Python311", "Python310"]: p = Path(os.environ.get("APPDATA", "")) / "Python" / pyver / "Scripts" / "yt-dlp.exe" if p.exists(): return str(p) p = Path(sys.executable).parent / "Scripts" / "yt-dlp.exe" if p.exists(): return str(p) raise RuntimeError("yt-dlp를 찾을 수 없습니다. pip install yt-dlp") def download_video(url: str, output_dir: Path) -> Tuple[Path, str]: """영상 다운로드 (1080p 우선)""" print("[1/5] 영상 다운로드 중...") yt_dlp = _find_yt_dlp() result = subprocess.run( [yt_dlp, "--get-title", "--encoding", "utf-8", url], capture_output=True, encoding="utf-8", errors="replace" ) title = (result.stdout or "").strip() or "untitled" safe_title = re.sub(r'[\\/:*?"<>|\x00-\x1f]', '_', title)[:80] video_path = output_dir / f"{safe_title}.mp4" if video_path.exists(): print(f" → 이미 다운로드됨: {video_path.name}") return video_path, safe_title # 720p 우선 (다운스케일링 부하 원천 차단) subprocess.run( [yt_dlp, "-f", "bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/" "best[height<=720]/best", "--merge-output-format", "mp4", "-o", str(video_path), url], encoding="utf-8", errors="replace", check=True ) print(f" → 다운로드 완료: {video_path.name}") return video_path, safe_title # ─── Step 2: 프레임 추출 ────────────────────────────────────────────────── def extract_frames(video_path: Path, fps: float = DEFAULT_FPS) -> List[np.ndarray]: print(f"[2/5] 프레임 추출 중 (fps={fps})...") cap = cv2.VideoCapture(str(video_path)) if not cap.isOpened(): raise RuntimeError(f"영상을 열 수 없습니다: {video_path}") video_fps = cap.get(cv2.CAP_PROP_FPS) total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) interval = max(1, int(video_fps / fps)) w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) # 4K 이상 → 1080p 다운스케일 (OOM 방지) need_resize = w > MAX_FRAME_WIDTH if need_resize: scale = MAX_FRAME_WIDTH / w target_size = (MAX_FRAME_WIDTH, int(h * scale)) print(f" → {w}x{h} → {target_size[0]}x{target_size[1]} 다운스케일") frames = [] idx = 0 while True: ret, frame = cap.read() if not ret: break if idx % interval == 0: if need_resize: frame = cv2.resize(frame, target_size, interpolation=cv2.INTER_AREA) frames.append(frame) if len(frames) % 50 == 0: print(f" ... {len(frames)}번째 프레임 추출 진행 중...", flush=True) idx += 1 cap.release() print(f" → {len(frames)}개 프레임 추출 ({w}x{h}, 원본 {video_fps:.0f}fps)") return frames # ─── 핵심: 흰색 배경 Tab 영역 검출 ─────────────────────────────────────── def _find_white_tab_strip(frame: np.ndarray, min_strip_ratio: float = 0.10) -> Optional[Tuple[int, int]]: """프레임에서 흰색 배경의 Tab 스트립 영역의 Y범위(top, bottom)를 반환. 전략: HSV 색공간에서 밝고(V>180) + 무채색(S<40)인 행을 찾아 연속된 흰색 영역이 일정 비율 이상인 영역을 Tab 영역으로 판정. grayscale 단독보다 노란 하이라이트, 컬러 배경을 정확히 배제. """ h, w = frame.shape[:2] margin_x = int(w * 0.1) # HSV 변환: 채도(S)와 명도(V) 동시 사용 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] # 2단계 흰색 마스크: # 1) 순수 흰색: V > 180, S < 40 (Tab 배경) # 2) 밝은 파스텔: V > 200, S < 100 (노란/초록 하이라이트 박스) pure_white = (roi_v > 180) & (roi_s < 40) bright_pastel = (roi_v > 200) & (roi_s < 100) tab_mask = pure_white | bright_pastel # 각 행의 Tab-like 픽셀 비율 row_tab_ratio = np.mean(tab_mask, axis=1) bright_mask = row_tab_ratio > 0.5 # 행의 50% 이상이 Tab-like # 연속된 흰색 행 영역 찾기 (검은색 탭 라인 및 음표로 인한 끊김 허용) max_gap = int(h * 0.02) # 약 2% (720p 기준 14px)까지의 흰색 끊김은 같은 영역으로 간주 regions = [] start = None gap_count = 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) top = max(0, best[0] - pad) bottom = min(h, best[1] + pad) return (top, bottom) def _trim_to_content(crop: np.ndarray, margin_px: int = 6) -> np.ndarray: """넓게 크롭된 Tab 이미지에서 Tab 콘텐츠 영역만 정밀 트림. 전략: HSV 기반으로 각 행의 '흰색 배경 비율'을 계산. - Tab 영역: 30~95%가 흰색 (흰 배경 + Tab 라인/숫자) - 기타 영상: 흰색 < 20% (어두운 배경) - 순수 여백: 흰색 > 97% 이를 통해 상/하단의 기타 영상과 빈 여백 모두 제거.""" 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) # 흰색/밝은 파스텔 픽셀 비율 (Tab 배경 감지) white_mask = ((v_ch > 180) & (s_ch < 40)) | ((v_ch > 200) & (s_ch < 100)) row_white = np.mean(white_mask, axis=1) # Tab 행 = 흰색 비율 30~97% (라인/숫자 + 흰 배경) tab_rows = (row_white > 0.30) & (row_white < 0.97) # 콘텐츠 존재 확인 (어두운 픽셀 > 0.2%) - 마디번호 같이 아주 작은 숫자도 보존하기 위해 스레스홀드 극단적 하향 gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) if len(crop.shape) == 3 else crop row_dark = np.mean(gray < 180, axis=1) content_rows = row_dark > 0.002 # Tab 행 OR 콘텐츠 행 valid_rows = tab_rows | content_rows # 상단: 첫 번째 유효 행 top = 0 for i in range(h): if valid_rows[i] and row_white[i] > 0.20: top = max(0, i - 120) # 상단 마디번호 보존을 위해 압도적인 120px 강제 보호 (숫자가 꽤 높이 떠있음) break # 하단: 마지막 유효 행 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, :] def _has_tab_content(region: np.ndarray) -> bool: """흰색 영역 내에 실제 Tab 내용이 있는지 검증. 방법: 흰색 배경 위의 어두운 픽셀(Tab 라인, 숫자, 코드명) 비율을 확인. Tab 영역은 일반적으로 3~25%의 어두운 콘텐츠를 포함.""" 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 # 어두운 픽셀 비율 (< 180 = 라인/숫자/코드 등) dark_pixels = np.sum(gray < 180) dark_ratio = dark_pixels / gray.size # Tab 영역: 3~25%가 어두운 콘텐츠 (순수 흰 배경이면 < 1%, 기타 영상이면 > 30%) return 0.02 < dark_ratio < 0.30 # ─── Step 3: 패턴 감지 ──────────────────────────────────────────────────── def _detect_tab_overlay(frame: np.ndarray) -> Optional[Tuple[int, int, int, int]]: """Tab을 포함한 흰색 오버레이 박스 검출""" gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) h, w = gray.shape _, thresh = cv2.threshold(gray, 220, 255, cv2.THRESH_BINARY) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15)) closed = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel) contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) total_area = h * w best = None best_area = 0 for cnt in contours: x, y, cw, ch = cv2.boundingRect(cnt) area = cw * ch ratio = area / total_area # 오버레이 = 프레임 폭의 85% 미만인 독립 박스 (전폭 스트립은 scroll) width_ratio = cw / w if (OVERLAY_MIN_AREA_RATIO < ratio < OVERLAY_MAX_AREA_RATIO and width_ratio < 0.85 and cw > ch * 0.5 and area > best_area): # Tab 내용 검증 region = frame[y:y + ch, x:x + cw] if _has_tab_content(region): best = (x, y, cw, ch) best_area = area return best def detect_pattern(frames: List[np.ndarray], sample_count: int = 20) -> str: """영상 패턴 감지: scroll (우선) vs overlay""" print("[3/5] 영상 패턴 분석 중...") if len(frames) < sample_count: sample_count = len(frames) indices = np.linspace(0, len(frames) - 1, sample_count, dtype=int) sample_frames = [frames[i] for i in indices] # 1) 흰색 Tab 스트립 감지 (scroll) — 우선 검사 tab_top_count = 0 tab_bottom_count = 0 for f in sample_frames: strip = _find_white_tab_strip(f) if strip is not None: top, bottom = strip h = f.shape[0] mid = (top + bottom) / 2 if mid < h * 0.5: tab_top_count += 1 else: tab_bottom_count += 1 tab_count = tab_top_count + tab_bottom_count tab_ratio = tab_count / sample_count # 60% 이상에서 흰색 스트립 → scroll if tab_ratio >= 0.6: position = "상단" if tab_top_count > tab_bottom_count else "하단" print(f" → 패턴: scroll (Tab {position}, 감지율: {tab_ratio:.0%})") return "scroll" # 2) 스트립 감지율 낮으면 오버레이 체크 overlay_count = sum(1 for f in sample_frames if _detect_tab_overlay(f) is not None) overlay_ratio = overlay_count / sample_count if overlay_ratio > 0.2: print(f" → 패턴: overlay (감지율: {overlay_ratio:.0%})") return "overlay" # 3) 둘 다 아니면 scroll 기본값 position = "상단" if tab_top_count > tab_bottom_count else "하단" print(f" → 패턴: scroll (fallback, Tab {position}, 감지율: {tab_ratio:.0%})") return "scroll" # ─── Step 4: 고유 Tab 프레임 추출 ───────────────────────────────────────── def compare_frames(frame1: np.ndarray, frame2: np.ndarray) -> float: """MSE 기반 유사도 (0~1, 1=동일)""" g1 = cv2.cvtColor(frame1, cv2.COLOR_BGR2GRAY) if len(frame1.shape) == 3 else frame1 g2 = cv2.cvtColor(frame2, cv2.COLOR_BGR2GRAY) if len(frame2.shape) == 3 else frame2 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 _dhash(image: np.ndarray, hash_size: int = 32) -> np.ndarray: """Difference Hash — 구조 기반 해시 (32×32 = 1024비트). 인접 픽셀의 밝기 차이를 기록하여 위치 이동에 강건한 fingerprint 생성. 16→32 확대로 마디번호/음표 위치까지 구분 가능.""" gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if len(image.shape) == 3 else image resized = cv2.resize(gray, (hash_size + 1, hash_size), interpolation=cv2.INTER_AREA) return (resized[:, 1:] > resized[:, :-1]).flatten() def _dedup_by_hash(frames: List[np.ndarray], max_hamming: int = 20) -> List[np.ndarray]: """pHash 기반 클러스터 중복 제거. 유사 프레임을 그룹핑하고, 각 그룹에서 가장 선명한(Laplacian 분산 최대) 1장만 선택. → 스크롤 중복 + 반복 연습 구간 모두 제거.""" if not frames: return [] hashes = [_dhash(f) for f in frames] n = len(frames) used = [False] * n clusters = [] for i in range(n): if used[i]: continue cluster = [i] used[i] = True for j in range(i + 1, n): if used[j]: continue dist = int(np.sum(hashes[i] != hashes[j])) if dist <= max_hamming: cluster.append(j) used[j] = True clusters.append(cluster) # 각 클러스터에서 최고 선명도 프레임 선택 result = [] for cluster in clusters: best_idx = max(cluster, key=lambda idx: cv2.Laplacian( cv2.cvtColor(frames[idx], cv2.COLOR_BGR2GRAY) if len(frames[idx].shape) == 3 else frames[idx], cv2.CV_64F).var()) result.append(frames[best_idx]) return result def _extract_print_channel(frame: np.ndarray) -> np.ndarray: """PDF 출력용 채널 (Red 채널): 노란색을 투명(White)하게 만듦""" if len(frame.shape) != 3: return frame return frame[:, :, 2] def _extract_tracking_channel(frame: np.ndarray) -> np.ndarray: """트래킹 전용 채널 (Blue 채널): 노란색을 거대한 검은색 마커로 만들어 반복적인 마디점프 시각적 오류를 영구차단""" if len(frame.shape) != 3: return frame return frame[:, :, 0] def _detect_scroll_offset(frame_a: np.ndarray, frame_b: np.ndarray, min_confidence: float = 0.1) -> Tuple[int, float]: """이전 프레임(A)과 현재 프레임(B) 사이의 X축 이동량(Scroll)을 추정합니다.""" h, w = frame_a.shape[:2] gb = _extract_tracking_channel(frame_b) ga = _extract_tracking_channel(frame_a) template_w = int(w * 0.5) template = ga[:, w - template_w:] result = cv2.matchTemplate(gb, template, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc = cv2.minMaxLoc(result) scroll_px = (w - template_w) - max_loc[0] if max_val < min_confidence or scroll_px <= 0: return (0, max_val) return (scroll_px, max_val) def _detect_measure_bars(gray_pano: np.ndarray) -> List[int]: """오직 기타 6현의 영역만 계산하여 세로로 쫙 채워진 마디 선(|)의 X좌표만 정밀하게 반환합니다.""" _, 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 def _stamp_measure_number(measure_bgr: np.ndarray, num: int) -> np.ndarray: """마디 이미지 좌측 상단의 빈 공간에 자동으로 순차 진행 마디번호를 파란색 도장(Stamp)으로 찍습니다.""" text = f"[{num}]" font = cv2.FONT_HERSHEY_SIMPLEX font_scale = 0.7 thickness = 2 color = (200, 0, 0) cv2.putText(measure_bgr, text, (15, 30), font, font_scale, color, thickness, cv2.LINE_AA) return measure_bgr def _stitch_scroll_segment(segment: List[np.ndarray]) -> np.ndarray: if len(segment) == 1: return segment[0] min_h = min(f.shape[0] for f in segment) panorama = segment[0][:min_h, :] for i in range(1, len(segment)): curr = segment[i][:min_h, :] scroll_px, conf = _detect_scroll_offset(segment[i-1][:min_h, :], curr, min_confidence=0.1) 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]) return panorama def _merge_scroll_candidates(candidates: List[np.ndarray], min_scroll: int = 5, min_segment_len: int = 2) -> List[np.ndarray]: if len(candidates) <= 1: return candidates result = [] current_segment = [candidates[0]] prev_s_px = 0 prev_conf = 1.0 for i in range(1, len(candidates)): prev_frame = candidates[i-1] 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) if not is_cut: current_segment.append(curr_frame) else: if len(current_segment) >= min_segment_len: result.append(_stitch_scroll_segment(current_segment)) else: result.extend(current_segment) current_segment = [curr_frame] prev_s_px = s_px prev_conf = conf if len(current_segment) >= min_segment_len: result.append(_stitch_scroll_segment(current_segment)) else: result.extend(current_segment) return result def merge_panoramas_list(panoramas): if not panoramas: return [] merged_list = [] current_master = panoramas[0].copy() for i in range(1, len(panoramas)): next_pano = panoramas[i].copy() # 매마디가 똑같이 생긴 반주 구간(예: 코러스)이 있을 때, 검색 범위가 너무 넓거나 # 비교 기준(head)이 너무 짧으면, OpenCV가 과거의 똑같은 반주에 현재 씬을 겹쳐버림(마디 누락/점프 발생). # 이를 막기 위해 비교 기준은 넓게(800), 검색 과거 이력은 짧게(1500=최대 편집 되감기 길이) 제한. 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) if max_val > 0.60: 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 if next_start_idx < next_pano.shape[1]: append_part = next_pano[:, next_start_idx:] if append_part.shape[1] > 0: current_master = np.hstack([current_master, append_part]) matched = True if not matched: merged_list.append(current_master) current_master = next_pano merged_list.append(current_master) return merged_list def extract_unique_scroll(frames: List[np.ndarray], threshold: float = SIMILARITY_THRESHOLD) -> List[np.ndarray]: print(f"[4/5] 스크롤형 Tab 추출 중 (threshold={threshold})...") strip_tops, strip_bottoms = [], [] for frame in frames: strip = _find_white_tab_strip(frame) if strip: strip_tops.append(strip[0]) strip_bottoms.append(strip[1]) if not strip_tops: return [] median_top = int(np.median(strip_tops)) median_bottom = int(np.median(strip_bottoms)) candidates, all_compared = [], [] for frame in frames: h = frame.shape[0] tab_crop = frame[max(0, median_top):min(h, median_bottom), :] if not _has_tab_content(tab_crop): continue compare_img = cv2.resize(tab_crop, (480, 120), interpolation=cv2.INTER_AREA) is_dup = False for ref in all_compared: if compare_frames(compare_img, ref) >= threshold: is_dup = True break if not is_dup: candidates.append(tab_crop) all_compared.append(compare_img) stitched = _merge_scroll_candidates(candidates) merged_panoramas = merge_panoramas_list(stitched) chunk_width = candidates[0].shape[1] if candidates else 1280 final_chunks = [] global_measure_counter = 1 current_row = None for pano in merged_panoramas: gray_pano = _extract_print_channel(pano) bar_coords = _detect_measure_bars(gray_pano) if not bar_coords: w = pano.shape[1] start_x = 0 while start_x < w: chunk = pano[:, start_x:min(w, start_x + chunk_width)] if chunk.shape[1] < chunk_width: pad = np.full((chunk.shape[0], chunk_width - chunk.shape[1], 3), 255, dtype=np.uint8) chunk = np.hstack([chunk, pad]) gray_c = _extract_print_channel(chunk) final_chunks.append(cv2.cvtColor(gray_c, cv2.COLOR_GRAY2BGR)) start_x += chunk_width continue coords = [0] + bar_coords + [pano.shape[1]] coords = sorted(list(set(coords))) for i in range(len(coords) - 1): x_start = coords[i] x_end = coords[i+1] if x_end - x_start < 50: continue measure_img = pano[:, x_start:x_end] 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: if current_row.shape[1] + bgr_m.shape[1] > chunk_width: pad_w = chunk_width - current_row.shape[1] if pad_w > 0: pad_img = np.full((current_row.shape[0], pad_w, 3), 255, dtype=np.uint8) current_row = np.hstack([current_row, pad_img]) final_chunks.append(current_row) current_row = bgr_m else: current_row = np.hstack([current_row, bgr_m]) if current_row is not None: pad_w = chunk_width - current_row.shape[1] if pad_w > 0: pad_img = np.full((current_row.shape[0], pad_w, 3), 255, dtype=np.uint8) current_row = np.hstack([current_row, pad_img]) final_chunks.append(current_row) return final_chunks def extract_unique_overlay(frames: List[np.ndarray], threshold: float = OVERLAY_SIMILARITY_THRESHOLD) -> List[np.ndarray]: """오버레이형: Tab 오버레이 박스 추출 + 전체 히스토리 중복 제거""" print("[4/5] 오버레이형 Tab 추출 중...") unique = [] all_normalized = [] for frame in frames: bbox = _detect_tab_overlay(frame) if bbox is None: continue x, y, w, h = bbox if h < 40 or w < 100: continue pad = 10 x = max(0, x - pad) y = max(0, y - pad) w = min(frame.shape[1] - x, w + 2 * pad) h = min(frame.shape[0] - y, h + 2 * pad) crop = frame[y:y + h, x:x + w] # 밝기 필터 if np.mean(cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)) < 120: continue # 정규화 normalized = cv2.resize(crop, (480, 180), interpolation=cv2.INTER_AREA) canvas = np.full((180, 480, 3), 255, dtype=np.uint8) canvas[:normalized.shape[0], :normalized.shape[1]] = normalized # 전체 히스토리 비교 is_dup = False for ref in all_normalized: if compare_frames(canvas, ref) >= threshold: is_dup = True break if not is_dup: unique.append(crop) all_normalized.append(canvas) # ── Phase 2: 마디번호 기반 최종 중복 제거 (OCR) ── if unique: unique = _dedup_by_measure_number(unique) print(f" → 최종: {len(unique)}개 고유 Tab 오버레이") return unique # ─── Step 5: A4 PDF 생성 ───────────────────────────────────────────────── def generate_pdf(frames: List[np.ndarray], output_path: Path, debug_dir: Optional[Path] = None) -> None: """Tab 프레임들을 A4 페이지에 여러 행으로 배치""" print("[5/5] A4 PDF 생성 중...") if not frames: print(" ⚠ 프레임 없음!") return page_w = int(PDF_PAGE_WIDTH_MM / 25.4 * PDF_DPI) page_h = int(PDF_PAGE_HEIGHT_MM / 25.4 * PDF_DPI) margin = int(PDF_MARGIN_MM / 25.4 * PDF_DPI) gap = int(TAB_GAP_MM / 25.4 * PDF_DPI) content_w = page_w - 2 * margin resized = [] for i, frame in enumerate(frames): rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) img = Image.fromarray(rgb) if debug_dir: img.save(debug_dir / f"frame_{i:04d}.png") scale = content_w / img.width img_r = img.resize((content_w, int(img.height * scale)), Image.LANCZOS) resized.append(img_r) pages = [] cur_y = margin page = Image.new('RGB', (page_w, page_h), (255, 255, 255)) for img in resized: if cur_y + img.height > page_h - margin: pages.append(page) page = Image.new('RGB', (page_w, page_h), (255, 255, 255)) cur_y = margin page.paste(img, (margin, cur_y)) cur_y += img.height + gap if cur_y > margin + gap: pages.append(page) if not pages: return pages[0].save(str(output_path), save_all=True, append_images=pages[1:], resolution=PDF_DPI) print(f" → PDF: {len(resized)} Tab → {len(pages)} 페이지, {output_path.stat().st_size // 1024} KB") def generate_long_image(frames: List[np.ndarray], output_path: Path) -> None: """Tab을 하나의 긴 이미지로""" if not frames: return max_w = max(f.shape[1] for f in frames) imgs = [] for f in frames: if f.shape[1] != max_w: scale = max_w / f.shape[1] f = cv2.resize(f, (max_w, int(f.shape[0] * scale))) imgs.append(f) concat = np.vstack(imgs) Image.fromarray(cv2.cvtColor(concat, cv2.COLOR_BGR2RGB)).save(str(output_path)) print(f" → 롱 이미지: {max_w}x{concat.shape[0]}") # ─── Main ───────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser(description="YouTube TAB → A4 PDF") parser.add_argument("url", help="YouTube URL") parser.add_argument("-o", "--output", help="출력 PDF 경로") parser.add_argument("--fps", type=float, default=DEFAULT_FPS) parser.add_argument("--similarity", type=float, default=None) parser.add_argument("--pattern", choices=["auto", "scroll", "overlay"], default="auto") parser.add_argument("--debug", action="store_true") args = parser.parse_args() output_dir = Path("output") output_dir.mkdir(exist_ok=True) debug_dir = None if args.debug: debug_dir = output_dir / "debug_frames" debug_dir.mkdir(exist_ok=True) video_path, safe_title = download_video(args.url, output_dir) frames = extract_frames(video_path, fps=args.fps) if not frames: print("❌ 프레임 추출 실패") sys.exit(1) pattern = detect_pattern(frames) if args.pattern == "auto" else args.pattern if pattern == "scroll": sim = args.similarity if args.similarity else SIMILARITY_THRESHOLD unique = extract_unique_scroll(frames, threshold=sim) else: sim = args.similarity if args.similarity else OVERLAY_SIMILARITY_THRESHOLD unique = extract_unique_overlay(frames, threshold=sim) if not unique: print("❌ 고유 Tab 프레임 없음. --similarity를 낮추거나 --pattern을 수동 지정하세요.") sys.exit(1) pdf_path = Path(args.output) if args.output else output_dir / f"{safe_title}.pdf" generate_pdf(unique, pdf_path, debug_dir=debug_dir) generate_long_image(unique, pdf_path.with_suffix(".png")) print(f"\n✅ 완료! PDF: {pdf_path}") if __name__ == "__main__": main()