Files
guitar_score/youtube_tab_to_pdf.py

911 lines
34 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]
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()