Files
guitar_score/youtube_tab_to_pdf.py

825 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()