diff --git a/.agent/references/STATUS.md b/.agent/references/STATUS.md index 847dd45..bde6f0e 100644 --- a/.agent/references/STATUS.md +++ b/.agent/references/STATUS.md @@ -14,22 +14,25 @@ | pHash 클러스터 중복제거 | ✅ 완료 | dHash 32×32(1024bit), max_hamming=20 | | 파노라마 스티칭 | ✅ 완료 | 템플릿 매칭 수평 스크롤 합성 | | 오버레이 정규화 비교 | ✅ 완료 | 480×180 정규화 + 전체 히스토리 MSE 비교 | +| OCR 기반 마디번호 중복 제거 | ✅ 완료 | easyocr 기반 상단 숫자 판독 보조 | | PDF/PNG 생성 | ✅ 완료 | A4 + 롱 이미지 | ## 처리 파이프라인 (scroll) ``` -Raw Frames → HSV Strip 검출 → Median Crop → MSE 1차 → 파노라마 스티칭 → pHash 2차 → PDF +Raw Frames → HSV Strip 검출 → Median Crop → MSE 1차 → 파노라마 스티칭 → pHash 2차 → OCR 3차 → PDF ``` ## 최근 변경 | 날짜 | 변경 내용 | |------|-----------| +| 2026-03-25 | 마디번호 기반 중복 검색 기능(OCR) 파이프라인 적용 | +| 2026-03-25 | 1080p 에러 방지용 720p 폴백(다운스케일링 부하 원천 차단) 도입 | | 2026-03-25 | 1080p 우선 다운로드 + MAX_FRAME_WIDTH=1280 캡 (OOM 방지) | | 2026-03-25 | dHash 32×32 + max_hamming=20으로 pHash 정밀도 향상 | | 2026-03-25 | 파노라마 스티칭: 템플릿 매칭 스크롤 오프셋 검출 + 연속 프레임 합성 | -| 2026-03-25 | HSV 트림: 흰색비율 30~97% 기반 정밀 크롭 | +| 2026-03-25 | HSV 트림: 연속된 흰색 행 영역 찾기 (검은색 끊김 허용) 개선 | | 2026-03-25 | overlay 프레임 수 최적화: 858→51프레임 (OVERLAY_SIMILARITY_THRESHOLD=0.55) | | 2026-03-24 | 패턴 감지 고도화: overlay→split→scroll 우선순위 | | 2026-03-24 | 히스토그램 비교 → MSE 픽셀 비교로 전환 | diff --git a/docs/devlog/2026-03-25.md b/docs/devlog/2026-03-25.md index da69312..61da7e1 100644 --- a/docs/devlog/2026-03-25.md +++ b/docs/devlog/2026-03-25.md @@ -2,6 +2,7 @@ | # | 시간 | 작업 설명 | 커밋 | 상태 | |---|------|-----------|------|------| -| 1 | 00:00~01:00 | HSV 트림 + pHash 클러스터 중복 제거 (v3 고도화) | `pending` | ✅ | -| 2 | 01:00~01:30 | 파노라마 스티칭: 템플릿 매칭 스크롤 오프셋 + 연속 프레임 합성 | `pending` | ✅ | -| 3 | 12:00~21:50 | 1080p 다운로드 + dHash 32×32 + OOM 방지 (MAX_FRAME_WIDTH=1280) | `pending` | 🔧 | +| 1 | 00:00~01:00 | HSV 트림 + pHash 클러스터 중복 제거 (v3 고도화) | `98381d2` | ✅ | +| 2 | 01:00~01:30 | 파노라마 스티칭: 템플릿 매칭 스크롤 오프셋 + 연속 프레임 합성 | `98381d2` | ✅ | +| 3 | 12:00~21:50 | 1080p 다운로드 + dHash 32×32 + OOM 방지 (MAX_FRAME_WIDTH=1280) | `98381d2` | 🔧 | +| 4 | 21:50~23:30 | 마디번호 기반 중심 중복 제어 (OCR) 구현 | `e25f38e` | ✅ | diff --git a/test_pipeline.py b/test_pipeline.py index a3df904..eb18697 100644 --- a/test_pipeline.py +++ b/test_pipeline.py @@ -82,9 +82,8 @@ def main(): output_dir = Path("output") mp4_files = sorted(output_dir.glob("*.mp4")) - if not mp4_files: - print("output/ 폴더에 mp4 파일이 없습니다!") + print("테스트할 영상(mp4)이 output 폴더에 없습니다.") print(" → python test_pipeline.py --download 로 영상 다운로드") sys.exit(1) diff --git a/youtube_tab_to_pdf.py b/youtube_tab_to_pdf.py index 1703c34..91a7a47 100644 --- a/youtube_tab_to_pdf.py +++ b/youtube_tab_to_pdf.py @@ -19,8 +19,63 @@ 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") @@ -82,12 +137,11 @@ def download_video(url: str, output_dir: Path) -> Tuple[Path, str]: print(f" → 이미 다운로드됨: {video_path.name}") return video_path, safe_title - # 1080p 우선, 720p 폴백, 최종 best + # 720p 우선 (다운스케일링 부하 원천 차단) subprocess.run( [yt_dlp, - "-f", "bestvideo[height>=1080][ext=mp4]+bestaudio[ext=m4a]/" - "bestvideo[height>=720][ext=mp4]+bestaudio[ext=m4a]/" - "best[height>=720]/best", + "-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 @@ -127,6 +181,8 @@ def extract_frames(video_path: Path, fps: float = DEFAULT_FPS) -> List[np.ndarra 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() @@ -164,23 +220,28 @@ def _find_white_tab_strip(frame: np.ndarray, min_strip_ratio: float = 0.10) -> O 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: - length = i - start - if length >= h * min_strip_ratio: - regions.append((start, i)) - start = 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 - start + length = (h - gap_count) - start if length >= h * min_strip_ratio: - regions.append((start, h)) + regions.append((start, h - gap_count)) if not regions: return None @@ -562,7 +623,9 @@ def extract_unique_scroll(frames: List[np.ndarray], if not _has_tab_content(tab_crop): continue - tab_crop = _trim_to_content(tab_crop) + # 🚨 _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) @@ -584,8 +647,12 @@ def extract_unique_scroll(frames: List[np.ndarray], print(f" → 파노라마: {len(candidates)}개 → {len(stitched)}개 (스크롤 합성)") # ── Phase 3: pHash 2차 클러스터 중복 제거 ── - unique = _dedup_by_hash(stitched, max_hamming=50) + 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 @@ -634,7 +701,11 @@ def extract_unique_overlay(frames: List[np.ndarray], unique.append(crop) all_normalized.append(canvas) - print(f" → {len(unique)}개 고유 Tab 오버레이") + # ── Phase 2: 마디번호 기반 최종 중복 제거 (OCR) ── + if unique: + unique = _dedup_by_measure_number(unique) + + print(f" → 최종: {len(unique)}개 고유 Tab 오버레이") return unique