Files
guitar_score/youtube_tab_to_pdf.py

1051 lines
41 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
from video_cv_tracker import TemporalTracker
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
# 영상 추출 처리(CV)만 필요하므로, ffmpeg 병합이 불필요한 video-only 고화질 포맷(720p)을 직접 요청하여 360p 강등을 방지
subprocess.run(
[yt_dlp,
"-f", "bestvideo[ext=mp4]",
"-S", "res:720",
"-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, mode: str = "largest") -> Optional[Tuple[int, int]]:
"""프레임에서 흰색 배경의 Tab 스트립 영역의 Y범위(top, bottom)를 반환.
mode="largest": 가장 큰 하나의 스트립만 반환 (연속 스크롤용)
mode="union": 최상단 스트립부터 최하단 스트립까지 전체를 포괄하여 반환 (오버레이용 다중 줄 보존)
"""
h, w = frame.shape[:2]
margin_x = int(w * 0.1)
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]
pure_white = (roi_v > 180) & (roi_s < 40)
bright_pastel = (roi_v > 200) & (roi_s < 100)
tab_mask = pure_white | bright_pastel
row_tab_ratio = np.mean(tab_mask, axis=1)
bright_mask = row_tab_ratio > 0.5
max_gap = int(h * 0.02)
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
pad_top = int(h * 0.15)
pad_bottom = int(h * 0.03)
if mode == "union":
top = max(0, min(r[0] for r in regions) - pad_top)
bottom = min(h, max(r[1] for r in regions) + pad_bottom)
return (top, bottom)
# largest
best = max(regions, key=lambda r: r[1] - r[0])
top = max(0, best[0] - pad_top)
bottom = min(h, best[1] + pad_bottom)
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 = 15) -> str:
print("[3/5] 영상 패턴 정밀 분석 중 (Motion Tracking)...")
if len(frames) < 30: return "scroll"
scroll_votes = 0
overlay_votes = 0
tab_bounds = None
for f in frames[::30]:
bounds = _find_white_tab_strip(f, mode="largest")
if bounds:
tab_bounds = bounds
break
if tab_bounds:
top, bottom = tab_bounds
else:
top, bottom = int(frames[0].shape[0]*0.2), int(frames[0].shape[0]*0.8) # Default
step = max(1, len(frames) // sample_count)
for i in range(2, len(frames)-1, step):
f1 = frames[i]
f2 = frames[i+1]
h, w = f1.shape[:2]
# 악보 영역 내부에서 높이의 중앙부분(잡음이 적은 곳)만 사용
crop_h = bottom - top
safe_top = int(top + crop_h * 0.2)
safe_bottom = int(top + crop_h * 0.8)
crop1 = f1[safe_top:safe_bottom, :]
crop2 = f2[safe_top:safe_bottom, :]
g1 = _extract_tracking_channel(crop1)
g2 = _extract_tracking_channel(crop2)
template_w = int(w * 0.5)
template = g1[:, w - template_w:]
res = cv2.matchTemplate(g2, template, cv2.TM_CCOEFF_NORMED)
_, max_val, _, max_loc = cv2.minMaxLoc(res)
scroll_px = (w - template_w) - max_loc[0]
# 강한 매칭이면서 스크롤이 없으면 정지된 페이지(overlay)
if max_val > 0.90 and scroll_px <= 1:
overlay_votes += 1
# 의미있는 매칭이면서 확연한 스크롤이 보이면 연속 스크롤(scroll)
elif max_val > 0.10 and scroll_px > 1:
scroll_votes += 1
else:
overlay_votes += 1
pattern = "scroll" if scroll_votes > overlay_votes else "overlay"
print(f" → 판단 패턴: {pattern} (Scroll:{scroll_votes}, Overlay/Static:{overlay_votes})")
return pattern
# ─── 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:
"""트래킹 전용 채널: 유색 커서(빨강, 노랑 등) 및 배경 노이즈를 완벽히 투명화하고, 오직 순수한 검은색 음표와 오선지만을 마스킹하여 추출"""
if len(frame.shape) != 3:
return frame
# B, G, R 모두 120 미만인 어두운 픽셀(순수 블랙 및 진회색)만 True로 마스킹
# 빨간색(0, 0, 255)이나 노란색(0, 255, 255)은 R이나 G가 255이므로 완벽하게 걸러짐
black_mask = (frame[:,:,0] < 120) & (frame[:,:,1] < 120) & (frame[:,:,2] < 120)
# 흰 배경 위에 검은 음표만 그리기 (바이너리 이미지와 동일한 효과)
img = np.full_like(frame[:,:,0], 255)
img[black_mask] = 0
# OpenCV matchTemplate은 밝기 기준 매칭을 하므로, 이미지 전체를 반전시킬 필요 없이
# 이대로 넘기면 흰 바탕의 검은색 패턴 매칭이 정확히 일어남
return img
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:
# [BUG3 FIX] 클러스터 허용폭 10→30px (마디선은 보통 2~5px 폭 클러스터)
if c - curr[-1] < 30: curr.append(c)
else:
measures.append(int(np.mean(curr)))
curr = [c]
if curr: measures.append(int(np.mean(curr)))
# [BUG3 FIX] 100px 미만 간격 마디선 제거 (음표 기둥 오탐 방지)
measures = [x for i, x in enumerate(measures)
if i == 0 or x - measures[i-1] >= 100]
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)
# [BUG1 FIX] 씬 전환 조건: conf 기반만 사용
# abs(s_px - prev_s_px) > 100 제거 — 스크롤 가속도를 씬전환으로 오탐하던 원인
is_cut = (conf <= 0.15) 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 _is_rewind_duplicate(query_bgr: np.ndarray, history_pano: np.ndarray) -> bool:
if history_pano is None: return False
h_gray = _extract_tracking_channel(history_pano)
qw = min(800, query_bgr.shape[1])
q_gray = _extract_tracking_channel(query_bgr[:, :qw])
if h_gray.shape[0] != q_gray.shape[0] or h_gray.shape[1] < q_gray.shape[1]: return False
res = cv2.matchTemplate(h_gray, q_gray, cv2.TM_CCOEFF_NORMED)
_, max_val, _, max_loc = cv2.minMaxLoc(res)
if max_val < 0.85: return False
match_x = max_loc[0]
# HEURISTIC 1: Is it just consecutive stitching from the immediate past?
# If it matched the very end of history (< 2500 pixels from the end), it's just normal scroll overlap!
if history_pano.shape[1] - match_x < 2500:
return False
# HEURISTIC 2: It matched deep in the past! It might be a rewind, OR it might be an identical Chorus.
# We must check the measure number to differentiate identical Chorus vs exact D.S. al Coda rewind.
qw_img = query_bgr[:, :qw]
gray_for_staff = cv2.cvtColor(qw_img, cv2.COLOR_BGR2GRAY) if len(qw_img.shape) == 3 else qw_img
_, bin_inv = cv2.threshold(gray_for_staff, 200, 255, cv2.THRESH_BINARY_INV)
row_sums = np.sum(bin_inv, axis=1) / 255.0
staff_rows = np.where(row_sums > qw * 0.4)[0]
if len(staff_rows) < 2: return False
staff_top = staff_rows[0]
box_y1 = max(0, staff_top - 60)
box_y2 = staff_top + 10
box_x2 = min(250, query_bgr.shape[1], history_pano.shape[1] - match_x)
q_num = query_bgr[box_y1:box_y2, 0:box_x2]
h_num = history_pano[box_y1:box_y2, match_x:match_x+box_x2]
if q_num.shape != h_num.shape or q_num.size == 0: return False
diff = cv2.absdiff(cv2.cvtColor(q_num, cv2.COLOR_BGR2GRAY), cv2.cvtColor(h_num, cv2.COLOR_BGR2GRAY))
mse = np.mean(diff ** 2)
if mse < 300.0:
return True
return False
def merge_panoramas_list(panoramas):
if not panoramas: return []
merged_list = []
current_master = panoramas[0].copy()
history_pano = current_master.copy()
rewind_state = False
for i in range(1, len(panoramas)):
next_pano = panoramas[i].copy()
if _is_rewind_duplicate(next_pano, history_pano):
print(" [Rewind Filter] D.S. al Coda or Backward Jump detected. Dropping redundant chronological playback.")
rewind_state = True
continue
if rewind_state:
print(" [Rewind Filter] Returning from rewind jump! Searching for novelty.")
merged_list.append(current_master)
current_master = next_pano
if current_master.shape[0] == history_pano.shape[0]:
history_pano = np.hstack([history_pano, next_pano])
rewind_state = False
continue
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.50:
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])
if current_master.shape[0] == history_pano.shape[0]:
history_pano = np.hstack([history_pano, append_part])
matched = True
if not matched:
merged_list.append(current_master)
current_master = next_pano
if current_master.shape[0] == history_pano.shape[0]:
history_pano = np.hstack([history_pano, next_pano])
merged_list.append(current_master)
return merged_list
def _find_all_measure_bars_standalone(img_bgr: np.ndarray, max_width: int) -> List[int]:
cw = min(img_bgr.shape[1], max_width)
img_gray = cv2.cvtColor(img_bgr[:, :cw], cv2.COLOR_BGR2GRAY) if len(img_bgr.shape) == 3 else img_bgr
_, bin_inv = cv2.threshold(img_gray, 200, 255, cv2.THRESH_BINARY_INV)
row_sums = np.sum(bin_inv, axis=1) / 255.0
staff_rows = np.where(row_sums > cw * 0.4)[0]
if len(staff_rows) >= 6:
staff_y_top, staff_y_bottom = staff_rows[0], staff_rows[-1]
else:
staff_y_top, staff_y_bottom = int(img_bgr.shape[0] * 0.3), int(img_bgr.shape[0] * 0.8)
expected_h = max(10, staff_y_bottom - staff_y_top + 1)
staff_region = bin_inv[staff_y_top:staff_y_bottom+1, :]
col_sums = np.sum(staff_region, axis=0) / 255.0
bar_xs = np.where(col_sums >= expected_h * 0.6)[0]
grouped_bars = []
if len(bar_xs) > 0:
c = [bar_xs[0]]
for x in bar_xs[1:]:
if x - c[-1] <= 15: c.append(x)
else:
grouped_bars.append(int(np.mean(c)))
c = [x]
grouped_bars.append(int(np.mean(c)))
unique_bars = []
for p in grouped_bars:
if not unique_bars or p - unique_bars[-1] >= 50:
unique_bars.append(p)
return unique_bars
def tile_panoramas_to_a4(panoramas: List[np.ndarray], chunk_width: int=1800) -> List[np.ndarray]:
if not panoramas: return []
panorama = np.hstack(panoramas) if len(panoramas) > 1 else panoramas[0]
rows = []
x_curr = 0
total_w = panorama.shape[1]
while x_curr < total_w:
remaining_w = total_w - x_curr
if remaining_w <= chunk_width:
r = panorama[:, x_curr:]
if r.shape[1] > 50:
r_padded = cv2.copyMakeBorder(r, 0, 0, 0, chunk_width - r.shape[1], cv2.BORDER_CONSTANT, value=[255,255,255])
rows.append(r_padded)
break
slice_bgr = panorama[:, x_curr : min(x_curr + chunk_width + 100, total_w)]
bars = _find_all_measure_bars_standalone(slice_bgr, slice_bgr.shape[1])
valid_bars = [b for b in bars if 50 < b < chunk_width - 15]
cut_offset = (valid_bars[-1] - 10) if valid_bars else chunk_width
r = panorama[:, x_curr : x_curr + cut_offset]
r_padded = cv2.copyMakeBorder(r, 0, 0, 0, chunk_width - r.shape[1], cv2.BORDER_CONSTANT, value=[255,255,255])
rows.append(r_padded)
x_curr += cut_offset
return rows
def extract_unique_scroll(frames: List[np.ndarray], scan_dist: int = 4) -> List[np.ndarray]:
from video_cv_tracker import TemporalTracker
print("[Pipeline] Isolating static structures via TemporalTracker")
tracker = TemporalTracker(diff_threshold=0.05)
tab_bounds = None
for f in frames[::30]:
bounds = _find_white_tab_strip(f)
if bounds:
tab_bounds = bounds
break
if tab_bounds:
top, bottom = tab_bounds
print(f" -> Found precise sheet music bounds: Y={top} to Y={bottom}")
else:
top, bottom = 0, frames[0].shape[0]
for frame in frames:
roi = frame[top:bottom, :]
tracker.process_frame(roi)
unique_pages = tracker.get_unique_pages()
print(f"[Pipeline] Reduced down to {len(unique_pages)} static structural median pages.")
print(" -> 점프 컷 및 도돌이표 처리 중...")
panoramas = merge_panoramas_list(unique_pages)
print(" -> A4 타일링 포맷팅 중...")
return tile_panoramas_to_a4(panoramas, chunk_width=1800)
def extract_unique_overlay(frames: List[np.ndarray],
threshold: float = OVERLAY_SIMILARITY_THRESHOLD) -> List[np.ndarray]:
"""오버레이형: TemporalTracker 기반의 고해상도 페이지(단일 스트립 크롭) 추출 및 정밀 픽셀 중복 필터"""
from video_cv_tracker import TemporalTracker
print("[4/5] 정지형(Overlay) Tab 트래킹 및 고해상도 추출 중...")
tab_bounds = None
for f in frames[::30]:
bounds = _find_white_tab_strip(f, mode="largest")
if bounds:
tab_bounds = bounds
break
if tab_bounds:
top, bottom = tab_bounds
print(f" -> Found precise sheet music bounds: Y={top} to Y={bottom}")
else:
top, bottom = int(frames[0].shape[0]*0.2), int(frames[0].shape[0]*0.8)
# BGR2GRAY 대신 True Black 채널을 사용하므로, 붉은색 재생커서 이동을 완벽히 무시합니다.
# 따라서 악보가 물리적으로 넘어갈 때 발생하는 픽셀 변화(음표 교체)만 감지하게 되므로, 임계값을 0.03(3%)으로 극도로 낮춰 정밀도를 높입니다.
tracker = TemporalTracker(diff_threshold=0.03)
for frame in frames:
if top is not None and bottom is not None:
roi = frame[top:bottom, :]
else:
roi = frame
roi_tracking = _extract_tracking_channel(roi)
# 상하단 20%는 악보 밖이므로(예: 연주자 머리카락, 천장 등) 변화 감지에서 완전히 배제
h_r = roi_tracking.shape[0]
s_top = int(h_r * 0.20)
s_bot = int(h_r * 0.80)
roi_tracking[:s_top, :] = 255
roi_tracking[s_bot:, :] = 255
tracker.process_frame(roi, tracking_channel=roi_tracking)
pages = tracker.get_unique_pages()
print(f"[Tracker] {len(pages)}개의 최초 구분 페이지 추출됨. 전역 중복 페이지 병합 심사 중...")
unique = []
for crop in pages:
if np.mean(cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)) < 80:
continue
is_dup = False
crop_gray = _extract_tracking_channel(crop)
h_c, w_c = crop_gray.shape
crop_gray[:int(h_c * 0.20), :] = 255
crop_gray[int(h_c * 0.80):, :] = 255
for past_crop in unique:
past_gray = _extract_tracking_channel(past_crop)
past_gray[:int(h_c * 0.20), :] = 255
past_gray[int(h_c * 0.80):, :] = 255
# 약간의 위치 이동(+/- 10픽셀)을 탐색하기 위해 템플릿 사이즈를 줄임
template = crop_gray[10:h_c-10, 10:w_c-10]
res = cv2.matchTemplate(past_gray, template, cv2.TM_CCOEFF_NORMED)
_, max_val, _, _ = cv2.minMaxLoc(res)
# 90% 이상의 강한 상관계수를 가지면 인간의 눈에는 완벽히 똑같은 악보(도돌이표)임.
if max_val > 0.90:
is_dup = True
break
if not is_dup:
unique.append(crop)
print(f" → 임시: {len(unique)}개 고유 오버레이 페이지 추출 성공. 상하단 여백 및 제목 정리 중...")
trimmed_unique = []
for crop in unique:
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
_, thresh = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV)
row_sums = np.sum(thresh, axis=1) / 255.0
# 폭의 40% 이상 차지하는 검은 오선지 영역만 찾음 (정적 제목 등 배제)
h_c, w_c = crop.shape[:2]
staff_rows = np.where(row_sums > w_c * 0.4)[0]
if len(staff_rows) > 0:
# 상단 여백 60px (코드, 기호 등), 하단 여백 30px
top_y = max(0, staff_rows[0] - 60)
bottom_y = min(h_c, staff_rows[-1] + 30)
trimmed_unique.append(crop[top_y:bottom_y, :])
else:
trimmed_unique.append(crop)
print(f" → 최종: {len(trimmed_unique)}개 정제된 오버레이 페이지 추출 성공")
return trimmed_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_HEIGHT_MM / 25.4 * PDF_DPI) # Landscape width
page_h = int(PDF_PAGE_WIDTH_MM / 25.4 * PDF_DPI) # Landscape height
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(chunks: List[np.ndarray], output_path: str):
if not chunks:
return
print(f"DEBUG: First chunk shape = {chunks[0].shape}, dtype = {chunks[0].dtype}")
# Calculate exact total height required
total_h = sum(chunk.shape[0] for chunk in chunks)
max_w = max(chunk.shape[1] for chunk in chunks)
# Ensure correct channel dimensions for the canvas to prevent squishing!
if len(chunks[0].shape) == 3:
canvas = np.full((total_h, max_w, 3), 255, dtype=np.uint8)
else:
canvas = np.full((total_h, max_w), 255, dtype=np.uint8)
y_offset = 0
for chunk in chunks:
h, w = chunk.shape[:2]
if len(chunk.shape) == 3 and len(canvas.shape) == 2:
canvas[y_offset:y_offset+h, :w] = cv2.cvtColor(chunk, cv2.COLOR_BGR2GRAY)
elif len(chunk.shape) == 2 and len(canvas.shape) == 3:
canvas[y_offset:y_offset+h, :w] = cv2.cvtColor(chunk, cv2.COLOR_GRAY2BGR)
else:
canvas[y_offset:y_offset+h, :w] = chunk
y_offset += h
cv2.imwrite(str(output_path), canvas)
# ─── 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()