Files
guitar_score/youtube_tab_to_pdf.py
CD 3d3f74b082 feat(pipeline): YouTube Tab → PDF 자동 추출 파이프라인 초기 구현
- 5단계 파이프라인: 다운로드 → 프레임 추출 → 패턴 감지 → 중복 제거 → PDF 생성
- 3가지 패턴 지원: overlay, split, scroll
- MSE 기반 픽셀 비교 프레임 중복 제거
- split 모드: 42% 크롭 + 밝기 필터 + Tab 라인 검증
- overlay 모드: 320x120 정규화 + 슬라이딩 윈도우 비교
- 프로젝트 문서 초기 작성 (architecture, tech-stack, STATUS, known-issues)
2026-03-24 23:29:25 +09:00

628 lines
23 KiB
Python

#!/usr/bin/env python3
"""
YouTube Tab → PDF 캡처 파이프라인
YouTube 기타 TAB 영상에서 Tab 프레임을 추출하여 깔끔한 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
import tempfile
from pathlib import Path
from typing import List, Tuple, Optional
import cv2
import numpy as np
from PIL import Image
# Windows 콘솔 인코딩 강제 UTF-8
if sys.platform == "win32":
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
# ─── Configuration ───────────────────────────────────────────────────────
DEFAULT_FPS = 2 # 프레임 추출 빈도 (초당 N프레임)
DEFAULT_CROP_RATIO = 0.55 # 상단 크롭 비율 (스크롤형)
SIMILARITY_THRESHOLD = 0.95 # 프레임 유사도 임계값 (SSIM 대신 히스토그램 비교)
OVERLAY_MIN_AREA_RATIO = 0.05 # 오버레이 박스 최소 면적 비율
OVERLAY_MAX_AREA_RATIO = 0.6 # 오버레이 박스 최대 면적 비율
MIN_TAB_LINES = 4 # Tab 악보 최소 수평 라인 수 (6줄 중 4줄 이상)
SPLIT_TOP_RATIO = 0.42 # 분할 화면 상단 영역 비율 (핸드캠 제외)
PDF_DPI = 150
PDF_PAGE_WIDTH_MM = 210 # A4
# ─── Step 1: Download ────────────────────────────────────────────────────
def _find_yt_dlp() -> str:
"""yt-dlp 실행 파일 경로 찾기"""
yt_dlp = shutil.which("yt-dlp")
if yt_dlp:
return yt_dlp
# pip user-installed path (Windows)
for pyver in ["Python312", "Python311", "Python310"]:
user_scripts = Path(os.environ.get("APPDATA", "")) / "Python" / pyver / "Scripts"
yt_dlp_path = user_scripts / "yt-dlp.exe"
if yt_dlp_path.exists():
return str(yt_dlp_path)
# conda env Scripts
conda_path = Path(sys.executable).parent / "Scripts" / "yt-dlp.exe"
if conda_path.exists():
return str(conda_path)
raise RuntimeError("yt-dlp를 찾을 수 없습니다. pip install yt-dlp를 실행하세요.")
def download_video(url: str, output_dir: Path) -> Tuple[Path, str]:
"""yt-dlp로 YouTube 영상 다운로드. 반환: (파일 경로, 제목)"""
print("[1/5] 영상 다운로드 중...")
yt_dlp = _find_yt_dlp()
# 제목 추출 (encoding 안전 처리)
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
subprocess.run(
[yt_dlp,
"-f", "best[height<=720][ext=mp4]/best[ext=mp4]/best",
"-o", str(video_path), url],
encoding="utf-8", errors="replace",
check=True
)
print(f" → 다운로드 완료: {video_path.name}")
return video_path, safe_title
# ─── Step 2: Frame Extraction ────────────────────────────────────────────
def extract_frames(video_path: Path, fps: float = DEFAULT_FPS) -> List[np.ndarray]:
"""OpenCV VideoCapture로 프레임 추출"""
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_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
frame_interval = max(1, int(video_fps / fps))
frames = []
frame_idx = 0
while True:
ret, frame = cap.read()
if not ret:
break
if frame_idx % frame_interval == 0:
frames.append(frame)
frame_idx += 1
cap.release()
print(f"{len(frames)}개 프레임 추출 (전체 {total_frames}프레임, 원본 {video_fps:.1f}fps)")
return frames
# ─── Step 3: Pattern Detection ───────────────────────────────────────────
def _has_tab_lines(region: np.ndarray, min_lines: int = MIN_TAB_LINES) -> bool:
"""영역 내에 Tab 악보 수평 라인(기타 6줄)이 있는지 확인"""
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 < 20 or w < 50:
return False
# 이진화 (밝은 배경 + 어두운 라인)
_, binary = cv2.threshold(gray, 180, 255, cv2.THRESH_BINARY_INV)
# 수평 라인 강조: 가로 커널 모폴로지
horiz_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (max(w // 4, 30), 1))
horiz = cv2.morphologyEx(binary, cv2.MORPH_OPEN, horiz_kernel)
# HoughLinesP로 수평 라인 검출
lines = cv2.HoughLinesP(horiz, 1, np.pi / 180, threshold=50,
minLineLength=w // 3, maxLineGap=20)
if lines is None:
return False
# 거의 수평인 라인만 필터 (각도 < 5도)
horizontal_ys = []
for line in lines:
x1, y1, x2, y2 = line[0]
if abs(y2 - y1) < max(5, abs(x2 - x1) * 0.087): # ~5도
horizontal_ys.append((y1 + y2) / 2)
if len(horizontal_ys) < min_lines:
return False
# Y좌표 클러스터링: 가까운 라인을 하나로 묶기 (6줄 그룹 검출)
horizontal_ys.sort()
clusters = []
for y in horizontal_ys:
if not clusters or y - clusters[-1] > h * 0.02: # 2% 거리 이상이면 새 클러스터
clusters.append(y)
return len(clusters) >= min_lines
def _detect_white_region(frame: np.ndarray) -> Optional[Tuple[int, int, int, int]]:
"""흰색 사각형 영역 검출 (Tab 여부 무관). 반환: (x, y, w, h) or None"""
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
if (OVERLAY_MIN_AREA_RATIO < ratio < OVERLAY_MAX_AREA_RATIO
and cw > ch * 0.5
and area > best_area):
best = (x, y, cw, ch)
best_area = area
return best
def _detect_tab_overlay(frame: np.ndarray) -> Optional[Tuple[int, int, int, int]]:
"""Tab 악보가 포함된 흰색 오버레이 박스 검출. 반환: (x, y, w, h) or None"""
bbox = _detect_white_region(frame)
if bbox is None:
return None
x, y, w, h = bbox
region = frame[y:y + h, x:x + w]
# Tab 수평 라인이 있는 경우에만 반환
if _has_tab_lines(region, min_lines=3):
return bbox
return None
def _detect_split_screen(frames: List[np.ndarray], sample_count: int = 10) -> bool:
"""분할 화면 감지: 상단이 밝은 Tab 용지, 하단이 어두운 핸드캠인지 확인
엄격한 기준:
- 상단 평균 밝기 > 180 (Tab 용지는 거의 흰색)
- 하단 평균 밝기 < 100 (핸드캠은 일반적으로 어두움)
- 밝기 차이 > 80
- 상단에 Tab 수평 라인이 4개 이상 존재
"""
DETECT_SPLIT = 0.5 # 감지용 분할 비율
if len(frames) < sample_count:
sample_count = len(frames)
indices = np.linspace(0, len(frames) - 1, sample_count, dtype=int)
split_count = 0
for idx in indices:
frame = frames[idx]
fh, fw = frame.shape[:2]
top_half = frame[0:int(fh * DETECT_SPLIT), :]
bottom_half = frame[int(fh * DETECT_SPLIT):, :]
top_brightness = np.mean(cv2.cvtColor(top_half, cv2.COLOR_BGR2GRAY))
bottom_brightness = np.mean(cv2.cvtColor(bottom_half, cv2.COLOR_BGR2GRAY))
# 엄격한 밝기 기준: Tab 용지(>180) + 어두운 핸드캠(<100) + 큰 차이(>80)
if (top_brightness > 180 and bottom_brightness < 100
and top_brightness - bottom_brightness > 80
and _has_tab_lines(top_half, min_lines=4)):
split_count += 1
ratio = split_count / sample_count
return ratio > 0.3
def detect_pattern(frames: List[np.ndarray], sample_count: int = 20) -> str:
"""영상 패턴 감지: 'scroll', 'overlay', 또는 'split'
감지 순서:
1. overlay — Tab 오버레이 박스가 가장 구체적이므로 최우선
2. split — 상단 Tab 용지 + 하단 핸드캠 = 엄격한 밝기 기준
3. scroll — 기본 (상단 크롭)
"""
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 라인이 있는 흰 박스 (가장 구체적)
overlay_count = 0
for frame in sample_frames:
if _detect_tab_overlay(frame) is not None:
overlay_count += 1
overlay_ratio = overlay_count / sample_count
if overlay_ratio > 0.3:
print(f" → 패턴: overlay (Tab 오버레이 감지율: {overlay_ratio:.0%})")
return "overlay"
# 2) 분할 화면(split) 검출 — 상단 Tab 용지 + 하단 핸드캠
if _detect_split_screen(frames, sample_count):
print(" → 패턴: split (상단 Tab + 하단 핸드캠)")
return "split"
# 3) 기본: 스크롤형
print(f" → 패턴: scroll (오버레이 감지율: {overlay_ratio:.0%})")
return "scroll"
# ─── Step 4: Extract Unique Tab Frames ────────────────────────────────────
def compare_frames(frame1: np.ndarray, frame2: np.ndarray) -> float:
"""두 프레임의 유사도 비교 (0~1, 1=동일).
픽셀 수준 정규화 상호상관(NCC) 사용 — 히스토그램 방식보다
Tab 내용 변화(프렛 번호, 마디 위치 등)를 정확히 감지.
"""
# 그레이스케일 변환
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 = 320
if g1.shape[1] > target_w:
scale = target_w / g1.shape[1]
new_size = (target_w, int(g1.shape[0] * scale))
g1 = cv2.resize(g1, new_size)
g2 = cv2.resize(g2, new_size)
# 정규화 상호상관 (NCC): 픽셀 수준 비교
# MSE 기반: 0=동일, 높을수록 다름 → 유사도로 변환
g1_f = g1.astype(np.float32) / 255.0
g2_f = g2.astype(np.float32) / 255.0
mse = np.mean((g1_f - g2_f) ** 2)
# MSE → 유사도 변환 (0~1, 1=동일)
# factor 8: MSE 0.005→sim 0.96, MSE 0.06→sim 0.52, MSE 0.13+→sim 0.0
similarity = 1.0 - min(mse * 8.0, 1.0)
return max(0.0, similarity)
def extract_unique_scroll(frames: List[np.ndarray],
crop_ratio: float = DEFAULT_CROP_RATIO,
threshold: float = SIMILARITY_THRESHOLD) -> List[np.ndarray]:
"""스크롤형: 상단 크롭 후 중복 제거"""
print("[4/5] 스크롤형 Tab 프레임 추출 중...")
unique = []
prev_crop = None
for i, frame in enumerate(frames):
h, w = frame.shape[:2]
crop = frame[0:int(h * crop_ratio), :]
if prev_crop is None:
unique.append(crop)
prev_crop = crop
continue
sim = compare_frames(crop, prev_crop)
if sim < threshold:
unique.append(crop)
prev_crop = crop
print(f"{len(unique)}개 고유 프레임 선별 (임계값: {threshold})")
return unique
def _normalize_overlay(crop: np.ndarray, target_w: int = 320,
target_h: int = 120) -> np.ndarray:
"""오버레이 크롭을 고정 크기 흰색 캔버스 위에 배치 (비교 정규화용)"""
h, w = crop.shape[:2]
scale = min(target_w / w, target_h / h)
new_w = int(w * scale)
new_h = int(h * scale)
resized = cv2.resize(crop, (new_w, new_h))
# 흰색 캔버스에 중앙 배치
canvas = np.full((target_h, target_w, 3), 255, dtype=np.uint8)
offset_x = (target_w - new_w) // 2
offset_y = (target_h - new_h) // 2
canvas[offset_y:offset_y + new_h, offset_x:offset_x + new_w] = resized
return canvas
def extract_unique_overlay(frames: List[np.ndarray],
threshold: float = SIMILARITY_THRESHOLD) -> List[np.ndarray]:
"""오버레이형: Tab 라인이 있는 흰 박스 영역 검출 후 중복 제거
슬라이딩 윈도우 비교: 각 프레임을 최근 N개 고유 프레임과 비교하여
점진적 변화 누적(drift)에 의한 중복을 방지.
"""
print("[4/5] 오버레이형 Tab 프레임 추출 중...")
WINDOW_SIZE = 5 # 최근 5개 고유 프레임과 비교
MIN_CROP_H = 40 # 최소 크롭 높이 (너무 작은 검출 제외)
MIN_CROP_W = 100 # 최소 크롭 폭
unique = []
recent_normalized = [] # 최근 고유 프레임 정규화 결과
for i, frame in enumerate(frames):
bbox = _detect_tab_overlay(frame)
if bbox is None:
continue
x, y, w, h = bbox
# 최소 크기 필터
if h < MIN_CROP_H or w < MIN_CROP_W:
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)
overlay_crop = frame[y:y + h, x:x + w]
normalized = _normalize_overlay(overlay_crop)
# 최근 N개 고유 프레임과 비교 — 하나라도 유사하면 건너뛰기
is_duplicate = False
for ref_norm in recent_normalized:
sim = compare_frames(normalized, ref_norm)
if sim >= threshold:
is_duplicate = True
break
if not is_duplicate:
unique.append(overlay_crop)
recent_normalized.append(normalized)
# 윈도우 크기 유지
if len(recent_normalized) > WINDOW_SIZE:
recent_normalized.pop(0)
print(f"{len(unique)}개 고유 오버레이 프레임 선별")
return unique
def extract_unique_split(frames: List[np.ndarray],
crop_ratio: float = SPLIT_TOP_RATIO,
threshold: float = 0.95) -> List[np.ndarray]:
"""분할 화면형: 상단 Tab 영역 크롭 후 중복 제거
MSE 기반 비교에서 동일 프레임은 sim>0.999, 커서만 이동 시 ~0.995.
실제 Tab 전환 시 sim 0.60~0.91. threshold=0.95가 적절한 균형점.
"""
print(f"[4/5] 분할 화면형 Tab 프레임 추출 중 (crop={crop_ratio:.0%}, sim={threshold})...")
unique = []
prev_crop = None
for i, frame in enumerate(frames):
h, w = frame.shape[:2]
crop = frame[0:int(h * crop_ratio), :]
# 밝기 필터: 어두운 프레임(인트로/아웃트로) 제외
gray_crop = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
mean_brightness = np.mean(gray_crop)
if mean_brightness < 120: # 어두운 프레임 건너뛰기
continue
# Tab 라인이 있는 프레임만 선별
if not _has_tab_lines(crop, min_lines=3):
continue
if prev_crop is None:
unique.append(crop)
prev_crop = crop
continue
sim = compare_frames(crop, prev_crop)
if sim < threshold:
unique.append(crop)
prev_crop = crop
print(f"{len(unique)}개 고유 분할화면 프레임 선별")
return unique
# ─── Step 5: Generate PDF ─────────────────────────────────────────────────
def generate_pdf(frames: List[np.ndarray], output_path: Path,
debug_dir: Optional[Path] = None) -> None:
"""고유 프레임들을 하나의 PDF로 합성"""
print("[5/5] PDF 생성 중...")
if not frames:
print(" ⚠ 추출된 프레임이 없습니다!")
return
pil_images = []
for i, frame in enumerate(frames):
# BGR → RGB
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
img = Image.fromarray(rgb)
# 디버그 모드: 개별 이미지 저장
if debug_dir:
img.save(debug_dir / f"frame_{i:04d}.png")
pil_images.append(img)
# PDF 생성: 첫 이미지에 나머지를 append
# 각 프레임을 PDF 페이지로 변환 (원본 크기 유지)
pdf_pages = []
for img in pil_images:
# RGB → PDF 호환 (RGBA 미지원이므로 RGB로)
if img.mode != 'RGB':
img = img.convert('RGB')
pdf_pages.append(img)
if pdf_pages:
first_page = pdf_pages[0]
rest_pages = pdf_pages[1:] if len(pdf_pages) > 1 else []
first_page.save(
str(output_path),
save_all=True,
append_images=rest_pages,
resolution=PDF_DPI,
)
print(f" → PDF 생성 완료: {output_path}")
print(f" {len(pdf_pages)} 페이지, 파일 크기: {output_path.stat().st_size / 1024:.0f} KB")
# ─── Also generate single long PNG ────────────────────────────────────────
def generate_long_image(frames: List[np.ndarray], output_path: Path) -> None:
"""모든 프레임을 하나의 긴 이미지로 이어붙이기"""
if not frames:
return
# 가장 넓은 프레임에 맞춰 통일
max_width = max(f.shape[1] for f in frames)
resized = []
for f in frames:
if f.shape[1] != max_width:
scale = max_width / f.shape[1]
new_h = int(f.shape[0] * scale)
f = cv2.resize(f, (max_width, new_h))
resized.append(f)
concat = np.vstack(resized)
rgb = cv2.cvtColor(concat, cv2.COLOR_BGR2RGB)
img = Image.fromarray(rgb)
img.save(str(output_path))
print(f" → 롱 이미지 생성: {output_path} ({img.width}x{img.height})")
# ─── Main Pipeline ────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="YouTube 기타 TAB 영상 → PDF 캡처",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
예시:
python youtube_tab_to_pdf.py "https://youtu.be/90BWvJY6KbE"
python youtube_tab_to_pdf.py "https://youtu.be/Ri9g4lwnrJQ" -o my_tab.pdf --debug
python youtube_tab_to_pdf.py "https://youtu.be/VIDEO" --pattern overlay --crop-ratio 0.6
""",
)
parser.add_argument("url", help="YouTube 영상 URL")
parser.add_argument("-o", "--output", help="출력 PDF 파일 경로")
parser.add_argument("--crop-ratio", type=float, default=DEFAULT_CROP_RATIO,
help=f"Tab 영역 크롭 비율 (기본: {DEFAULT_CROP_RATIO})")
parser.add_argument("--fps", type=float, default=DEFAULT_FPS,
help=f"프레임 추출 빈도 (기본: {DEFAULT_FPS})")
parser.add_argument("--similarity", type=float, default=SIMILARITY_THRESHOLD,
help=f"프레임 유사도 임계값 (기본: {SIMILARITY_THRESHOLD})")
parser.add_argument("--pattern", choices=["auto", "scroll", "overlay", "split"],
default="auto", help="영상 패턴 (기본: auto)")
parser.add_argument("--debug", action="store_true", help="중간 이미지 저장")
args = parser.parse_args()
# 출력 디렉토리 설정
output_dir = Path("output")
output_dir.mkdir(exist_ok=True)
# Debug 디렉토리
debug_dir = None
if args.debug:
debug_dir = output_dir / "debug_frames"
debug_dir.mkdir(exist_ok=True)
# ── Step 1: Download ──
video_path, safe_title = download_video(args.url, output_dir)
# ── Step 2: Extract Frames ──
frames = extract_frames(video_path, fps=args.fps)
if not frames:
print("❌ 프레임을 추출할 수 없습니다.")
sys.exit(1)
# ── Step 3: Detect Pattern ──
if args.pattern == "auto":
pattern = detect_pattern(frames)
else:
pattern = args.pattern
print(f"[3/5] 패턴 수동 지정: {pattern}")
# ── Step 4: Extract Unique Frames ──
if pattern == "scroll":
unique_frames = extract_unique_scroll(
frames, crop_ratio=args.crop_ratio, threshold=args.similarity
)
elif pattern == "split":
# split 모드: 자체 최적값 사용 (crop=42%, sim=0.98)
# CLI에서 명시 지정 시에만 override
split_kwargs = {}
if args.crop_ratio != DEFAULT_CROP_RATIO: # 사용자가 직접 지정한 경우
split_kwargs['crop_ratio'] = args.crop_ratio
if args.similarity != SIMILARITY_THRESHOLD:
split_kwargs['threshold'] = args.similarity
unique_frames = extract_unique_split(frames, **split_kwargs)
else:
unique_frames = extract_unique_overlay(
frames, threshold=args.similarity
)
if not unique_frames:
print("❌ 고유 프레임을 찾을 수 없습니다. --similarity 값을 낮추거나 --pattern을 수동 지정해보세요.")
sys.exit(1)
# ── Step 5: Generate Output ──
if args.output:
pdf_path = Path(args.output)
else:
pdf_path = output_dir / f"{safe_title}.pdf"
generate_pdf(unique_frames, pdf_path, debug_dir=debug_dir)
# 보너스: 긴 이미지도 생성
long_img_path = pdf_path.with_suffix(".png")
generate_long_image(unique_frames, long_img_path)
print(f"\n✅ 완료!")
print(f" PDF: {pdf_path}")
print(f" PNG: {long_img_path}")
if debug_dir:
debug_count = len(list(debug_dir.glob("*.png")))
print(f" Debug: {debug_dir} ({debug_count}개 이미지)")
if __name__ == "__main__":
main()