#!/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] # 마디 번호는 극한의 좌측 상단 (높이 상위 25%, 너비 좌측 8%)에 위치 crop = frame[:int(h * 0.25), :int(w * 0.08)] gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) if len(crop.shape) == 3 else crop results = reader.readtext(gray, 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) # 콘텐츠 존재 확인 (어두운 픽셀 > 1%) 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.02 # 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 - margin_px) 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 _detect_scroll_offset(frame_a: np.ndarray, frame_b: np.ndarray, template_ratio: float = 0.6, min_confidence: float = 0.75) -> Tuple[int, float]: """두 프레임 사이의 수평 스크롤 오프셋 검출. frame_a의 오른쪽 template_ratio 영역을 frame_b에서 탐색. Returns: (scroll_px, confidence). scroll_px > 0 = 왼쪽으로 스크롤됨.""" ga = cv2.cvtColor(frame_a, cv2.COLOR_BGR2GRAY) if len(frame_a.shape) == 3 else frame_a gb = cv2.cvtColor(frame_b, cv2.COLOR_BGR2GRAY) if len(frame_b.shape) == 3 else frame_b # 높이 맞추기 if ga.shape[0] != gb.shape[0]: target_h = min(ga.shape[0], gb.shape[0]) ga = ga[:target_h, :] gb = gb[:target_h, :] h, w = ga.shape template_w = int(w * template_ratio) if template_w < 20 or template_w >= w: return (0, 0.0) 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 _stitch_scroll_segment(segment: List[np.ndarray]) -> np.ndarray: """스크롤 연속 프레임을 하나의 파노라마로 합성. template matching으로 겹치는 영역을 제거하고 새 영역만 이어붙임.""" 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) if scroll_px > 0 and conf > 0.7: # 새로운 영역(오른쪽 scroll_px 픽셀)만 추가 new_strip = curr[:, curr.shape[1] - scroll_px:] panorama = np.hstack([panorama, new_strip]) else: # 스크롤 실패 → 전체 프레임 추가 (safe fallback) 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 # 연속 프레임 간 스크롤 오프셋 측정 offsets = [] for i in range(len(candidates) - 1): scroll_px, conf = _detect_scroll_offset(candidates[i], candidates[i+1]) offsets.append((scroll_px, conf)) # 스크롤 연속 구간(run) 분리 result = [] segment_start = 0 i = 0 while i < len(candidates): # 다음 프레임과 스크롤 연결인지 확인 if i < len(offsets) and offsets[i][0] >= min_scroll and offsets[i][1] > 0.7: # 스크롤 시작: 연속 구간 탐색 seg_end = i + 1 while seg_end < len(offsets) and offsets[seg_end][0] >= min_scroll and offsets[seg_end][1] > 0.7: seg_end += 1 seg_end += 1 # 마지막 프레임 포함 segment = candidates[i:seg_end] if len(segment) >= min_segment_len: # 파노라마 합성 panorama = _stitch_scroll_segment(segment) result.append(panorama) else: result.extend(segment) i = seg_end else: result.append(candidates[i]) i += 1 return result def extract_unique_scroll(frames: List[np.ndarray], threshold: float = SIMILARITY_THRESHOLD) -> List[np.ndarray]: """스크롤형: 업스케일 + HSV + median voting + 트림 + MSE → 파노라마 → pHash""" print(f"[4/5] 스크롤형 Tab 추출 중 (threshold={threshold})...") # ── Phase 1: 전체 프레임의 strip 위치 수집 (median voting) ── strip_tops = [] strip_bottoms = [] for frame in frames: orig_h, orig_w = frame.shape[:2] if orig_w < DETECT_WIDTH: scale = DETECT_WIDTH / orig_w upscaled = cv2.resize(frame, (DETECT_WIDTH, int(orig_h * scale)), interpolation=cv2.INTER_LANCZOS4) else: upscaled = frame scale = 1.0 strip = _find_white_tab_strip(upscaled) if strip is not None: up_top, up_bottom = strip strip_tops.append(int(up_top / scale)) strip_bottoms.append(int(up_bottom / scale)) if not strip_tops: print(" → 흰색 스트립 미감지") return [] median_top = int(np.median(strip_tops)) median_bottom = int(np.median(strip_bottoms)) print(f" → 크롭 영역: y={median_top}~{median_bottom} " f"(median of {len(strip_tops)} strips)") # ── Phase 2: 크롭 + 트림 + MSE 1차 필터 ── candidates = [] all_compared = [] for frame in frames: h = frame.shape[0] top = max(0, median_top) bottom = min(h, median_bottom) tab_crop = frame[top:bottom, :] if not _has_tab_content(tab_crop): continue # 🚨 _trim_to_content를 각 프레임별로 적용하면 음표 높낮이에 따라 프레임 높이가 들쭉날쭉해짐. # 이후 스크롤 합성(stitch)에서 min_h로 잘리면서 악보가 다 잘려나가는(Crop) 치명적 원인이 됨! # tab_crop = _trim_to_content(tab_crop) 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) print(f" → MSE 1차: {len(candidates)}개 후보") # ── Phase 2.5: 파노라마 스티칭 (스크롤 겹침 제거) ── stitched = _merge_scroll_candidates(candidates) if len(stitched) != len(candidates): print(f" → 파노라마: {len(candidates)}개 → {len(stitched)}개 (스크롤 합성)") # ── Phase 3: pHash 2차 클러스터 중복 제거 ── unique = _dedup_by_hash(stitched, max_hamming=20) print(f" → pHash 2차: {len(unique)}개 고유 Tab 프레임") # ── Phase 4: 마디번호 기반 최종 중복 제거 (OCR) ── unique = _dedup_by_measure_number(unique) return unique 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()