feat(pipeline): v3-v4 dedup + panorama stitching + 1080p support

- HSV-aware _trim_to_content (white ratio 30-97%)
- pHash cluster dedup: dHash 32x32(1024bit), max_hamming=20
- Panoramic stitching: template matching scroll offset detection
- 4-stage pipeline: MSE -> Panorama -> pHash
- 1080p download priority + MAX_FRAME_WIDTH=1280 cap
- test_pipeline.py with YouTube URLs and --download mode
- 3 new known-issues documented
- devlog + STATUS.md updated
This commit is contained in:
quantlab
2026-03-25 21:58:48 +09:00
parent 3d3f74b082
commit 98381d2893
13 changed files with 836 additions and 421 deletions

View File

@@ -4,26 +4,38 @@
| 기능 | 상태 | 비고 |
|------|------|------|
| YouTube 다운로드 | ✅ 완료 | yt-dlp + 쿠키 인증 |
| 프레임 추출 | ✅ 완료 | fps=2 기본값 |
| YouTube 다운로드 | ✅ 완료 | yt-dlp, 1080p 우선 다운로드 |
| 프레임 추출 | ✅ 완료 | fps=2, MAX_FRAME_WIDTH=1280 캡 |
| 패턴 감지 (overlay) | ✅ 완료 | Tab 라인 검증 포함 |
| 패턴 감지 (split) | ✅ 완료 | 밝기 기준 엄격화 |
| 패턴 감지 (scroll) | ✅ 완료 | 기본 폴백 |
| MSE 기반 중복 제거 | ✅ 완료 | 히스토그램 → MSE 전환 |
| 오버레이 정규화 비교 | ✅ 완료 | 320×120 정규화 + 슬라이딩 윈도우 |
| HSV 기반 Tab 검출 | ✅ 완료 | 2-tier HSV 마스크, 960px 업스케일 |
| MSE 기반 중복 제거 | ✅ 완료 | 480px 정규화 비교 |
| pHash 클러스터 중복제거 | ✅ 완료 | dHash 32×32(1024bit), max_hamming=20 |
| 파노라마 스티칭 | ✅ 완료 | 템플릿 매칭 수평 스크롤 합성 |
| 오버레이 정규화 비교 | ✅ 완료 | 480×180 정규화 + 전체 히스토리 MSE 비교 |
| PDF/PNG 생성 | ✅ 완료 | A4 + 롱 이미지 |
## 처리 파이프라인 (scroll)
```
Raw Frames → HSV Strip 검출 → Median Crop → MSE 1차 → 파노라마 스티칭 → pHash 2차 → PDF
```
## 최근 변경
| 날짜 | 변경 내용 |
|------|-----------|
| 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 | overlay 프레임 수 최적화: 858→51프레임 (OVERLAY_SIMILARITY_THRESHOLD=0.55) |
| 2026-03-24 | 패턴 감지 고도화: overlay→split→scroll 우선순위 |
| 2026-03-24 | 히스토그램 비교 → MSE 픽셀 비교로 전환 |
| 2026-03-24 | split 모드: 42% 크롭 + 밝기 필터 + Tab 라인 검증 |
| 2026-03-24 | overlay 모드: 정규화 + 슬라이딩 윈도우 중복 제거 |
| 2026-03-24 | split 감지 조건 엄격화 (top>180, bottom<100) |
## 알려진 제한사항
- 오버레이형 영상(空奏列車)에서 추출 프레임 수가 아직 많을 수 있음 (MSE 임계값 추가 튜닝 필요)
- 영상 내 Tab이 반복되는 곡은 실제 고유 프레임 수가 적음 (正常 동작)
- 1080p 처리 시 여전히 중복 프레임 존재 가능 (마디번호 기반 추가 검증 필요)
- 순차 영상 처리 시 메모리 누적 주의 (gc.collect 필수)
- test_pipeline.py 아직 메인 코드와 완전 통합 안 됨

View File

@@ -54,4 +54,22 @@
- **증상**: overlay 프레임 비교 시 모든 프레임이 "다르다"로 판정 (1000+개 추출)
- **원인**: _detect_tab_overlay가 프레임마다 다른 크기의 바운딩박스 반환 (69~360px)
- **해결**: 320×120 흰색 캔버스에 정규화 후 비교 + 슬라이딩 윈도우(5프레임)
- **주의**: overlay 프레임 수 최적화는 아직 진행 중 (추가 튜닝 필요)
- **주의**: overlay 프레임 수 최적화는 아직 진행 중 (추가 튜닝 필요)
### [2026-03-25] pHash 16×16 — Tab 프레임 과도합병
- **증상**: 서로 다른 Tab 페이지가 pHash 클러스터링에서 동일 그룹으로 합병 (20→9 프레임)
- **원인**: 16×16 dHash(256비트)는 Tab 구조(6선 + 숫자)를 구분하기엔 해상도 부족. 모든 Tab이 유사한 hash 생성
- **해결**: dHash 32×32(1024비트)로 확대 + max_hamming 50→20 조정
- **주의**: hash_size와 max_hamming은 항상 쌍으로 조정해야 함 (비트수 대비 비율)
### [2026-03-25] 1080p 프레임 — 메모리 부족/프로세스 행
- **증상**: 1920×1080 프레임 500+개 로딩 시 프로세스 무한 대기 (3.5GB+ RAM)
- **원인**: extract_frames가 모든 프레임을 list에 보관, 1080p는 프레임당 ~6MB
- **해결**: MAX_FRAME_WIDTH=1280 캡 + gc.collect() 추가. 4K→1280px 다운스케일
- **주의**: 영상 3개 순차 처리 시 GC 없으면 누적 메모리로 swap thrashing 발생
### [2026-03-25] yt-dlp 다운로드 — 360p 폴백
- **증상**: `bestvideo[height>=720]` 포맷으로 요청했으나 640×360 파일 다운로드
- **원인**: format string의 `/best` 폴백이 720p 없을 때 360p 선택. 또는 mp4 전용 필터가 해상도 제한
- **해결**: 명시적 1080p 우선 + 720p 폴백 체인 분리 (`bestvideo[height>=1080]/.../best[height>=720]/best`)
- **주의**: 캐시된 파일이 있으면 재다운로드 안 함 — 해상도 변경 시 기존 파일 삭제 필요

View File

@@ -87,9 +87,9 @@ git log --oneline -20
| 커밋 유형 | Vikunja 액션 |
|-----------|-------------|
| 기존 태스크 해당 작업 **완료** | `C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py done {ID}` |
| 신규 작업 완료 (기존 태스크 없음) | `C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py create "제목" "설명" --done --labels Backend,Priority:High` |
| 작업 중 발견된 **미완료 TODO** | `C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py create "제목" "설명" --labels Backend,Priority:Mid` |
| 기존 태스크 해당 작업 **완료** | `C:\ProgramData\miniforge3\envs\score\python.exe .agent\workflows\helpers\vikunja_helper.py done {ID}` |
| 신규 작업 완료 (기존 태스크 없음) | `C:\ProgramData\miniforge3\envs\score\python.exe .agent\workflows\helpers\vikunja_helper.py create "제목" "설명" --done --labels Backend,Priority:High` |
| 작업 중 발견된 **미완료 TODO** | `C:\ProgramData\miniforge3\envs\score\python.exe .agent\workflows\helpers\vikunja_helper.py create "제목" "설명" --labels Backend,Priority:Mid` |
> [!IMPORTANT]
> 모든 커밋이 기존 또는 신규 태스크에 매핑되었는지 확인.
@@ -97,13 +97,13 @@ git log --oneline -20
### 2-2. 완료 처리
```powershell
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py done {TASK_ID}
C:\ProgramData\miniforge3\envs\score\python.exe .agent\workflows\helpers\vikunja_helper.py done {TASK_ID}
```
### 2-3. 신규 태스크 생성
```powershell
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py create "제목" "설명" --labels Backend,Priority:High
C:\ProgramData\miniforge3\envs\score\python.exe .agent\workflows\helpers\vikunja_helper.py create "제목" "설명" --labels Backend,Priority:High
```
### 라벨 규칙
@@ -138,11 +138,11 @@ git diff --name-only .agent/references/
```powershell
# STATUS.md가 변경된 경우
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\wiki_helper.py update "Status" .agent\references\STATUS.md
C:\ProgramData\miniforge3\envs\score\python.exe .agent\workflows\helpers\wiki_helper.py update "Status" .agent\references\STATUS.md
```
```powershell
# architecture.md가 변경된 경우
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\wiki_helper.py update "Architecture" .agent\references\architecture.md
C:\ProgramData\miniforge3\envs\score\python.exe .agent\workflows\helpers\wiki_helper.py update "Architecture" .agent\references\architecture.md
```
> [!TIP]

View File

@@ -32,7 +32,7 @@ if sys.stdout.encoding != "utf-8":
# ============================================================
API_BASE = "https://plan.variet.net/api/v1"
TOKEN = "tk_070f8e0b715e818bb7178c3815ed5389040eddca"
PROJECT_ID = 7 # Variet Agent 프로젝트
PROJECT_ID = 12 # guitar_score 프로젝트
# ============================================================
HEADERS = {

View File

@@ -15,7 +15,7 @@ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
# ============================================================
GITEA_BASE_URL = "https://git.variet.net"
GITEA_OWNER = "Variet"
GITEA_REPO = "variet-agent" # Variet Agent 프로젝트
GITEA_REPO = "guitar_score" # guitar_score 프로젝트
GITEA_TOKEN = "3a01b4b15a39921572e64c413353e870d4d2161b"
# ============================================================

View File

@@ -44,7 +44,7 @@ description: 프로젝트 서비스 연동 정보 + 작업 프로토콜 (서비
> 직접 API 호출 대신 반드시 helper 스크립트를 사용하세요.
```powershell
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py list todo
C:\ProgramData\miniforge3\envs\score\python.exe .agent\workflows\helpers\vikunja_helper.py list todo
```
### Vikunja 라벨 체계

View File

@@ -49,7 +49,7 @@ git log --oneline -5
### 3. Vikunja TODO 태스크
```powershell
C:\ProgramData\miniforge3\envs\variet-agent\python.exe .agent\workflows\helpers\vikunja_helper.py list todo
C:\ProgramData\miniforge3\envs\score\python.exe .agent\workflows\helpers\vikunja_helper.py list todo
```
### 4. 종합 보고

75
diag_v2.py Normal file
View File

@@ -0,0 +1,75 @@
"""video_2 진단: 왜 0 프레임인지 각 단계별 확인"""
import sys
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
import cv2
import numpy as np
from pathlib import Path
import importlib.util
spec = importlib.util.spec_from_file_location("p", "youtube_tab_to_pdf.py")
p = importlib.util.module_from_spec(spec)
spec.loader.exec_module(p)
mp4 = Path("output") / "サカナクション/新宝島(エレキギターTAB) 難易度★★★ sakanaction shintakarajima.mp4"
cap = cv2.VideoCapture(str(mp4))
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
fps = cap.get(cv2.CAP_PROP_FPS)
# 10개 프레임 샘플
indices = np.linspace(total * 0.1, total * 0.8, 10, dtype=int)
for idx in indices:
cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
ret, frame = cap.read()
if not ret:
continue
h, w = frame.shape[:2]
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# 행별 밝기
margin_x = int(w * 0.1)
row_br = np.mean(gray[:, margin_x:w-margin_x], axis=1)
strip = p._find_white_tab_strip(frame)
has_tab = False
if strip:
top, bottom = strip
crop = frame[top:bottom, :]
has_tab = p._has_tab_content(crop)
print(f"Frame {idx:5d}: strip={strip}, has_tab={has_tab}, "
f"top_br={np.mean(row_br[:h//3]):.0f}, "
f"mid_br={np.mean(row_br[h//3:2*h//3]):.0f}, "
f"bot_br={np.mean(row_br[2*h//3:]):.0f}")
# strip이 있지만 has_tab=False인 경우 상세 진단
if strip and not has_tab:
top, bottom = strip
crop = frame[top:bottom, :]
g = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
ch, cw = g.shape
_, binary = cv2.threshold(g, 180, 255, cv2.THRESH_BINARY_INV)
horiz_k = cv2.getStructuringElement(cv2.MORPH_RECT, (max(cw//3, 30), 1))
horiz = cv2.morphologyEx(binary, cv2.MORPH_OPEN, horiz_k)
lines = cv2.HoughLinesP(horiz, 1, np.pi/180, threshold=40,
minLineLength=cw//4, maxLineGap=20)
nlines = 0 if lines is None else len(lines)
ys = []
if lines is not None:
for l in lines:
x1,y1,x2,y2 = l[0]
if abs(y2-y1) < max(5, abs(x2-x1)*0.05):
ys.append((y1+y2)/2)
ys.sort()
clusters = []
for y in ys:
if not clusters or y - clusters[-1] > ch * 0.02:
clusters.append(y)
print(f" → 크롭크기: {cw}x{ch}, 라인수: {nlines}, "
f"수평ys: {len(ys)}, 클러스터: {len(clusters)}")
# 디버그: 크롭 저장
cv2.imwrite(f"output/raw_dump/v2_diag_{idx}.png", crop)
cap.release()

View File

@@ -0,0 +1,7 @@
# Devlog — 2026-03-25
| # | 시간 | 작업 설명 | 커밋 | 상태 |
|---|------|-----------|------|------|
| 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` | 🔧 |

View File

@@ -0,0 +1,34 @@
# Pipeline v3→v4: Dedup + 파노라마 + 1080p 고도화
- **시간**: 2026-03-25 00:00~21:50
- **Commit**: `pending`
- **Vikunja**: 신규 태스크 생성 예정
## 작업 내용
### v3 고도화 (HSV + 트림)
- `_trim_to_content`: HSV 색공간 기반 흰색비율(30~97%) 분석으로 Tab 영역 정밀 트림
- Two-tier HSV 마스크 (pure white + bright pastel) → 노란 하이라이트/컬러 배경 정확 배제
- 검출용 업스케일 960px width로 Tab 라인 인식률 향상
### v4 중복 제거 (pHash + 파노라마)
- `_dhash` + `_dedup_by_hash`: pHash 클러스터링으로 반복 연습 구간 제거
- `_detect_scroll_offset`: 템플릿 매칭(오른쪽 60%)으로 수평 스크롤 오프셋 측정
- `_stitch_scroll_segment` + `_merge_scroll_candidates`: 연속 스크롤 프레임 파노라마 합성
- 4단계 파이프라인: MSE → 파노라마 → pHash
### 1080p 전환
- yt-dlp 포맷을 1080p 우선으로 변경 (기존 720p fallback to 360p 문제 해결)
- MAX_FRAME_WIDTH=1280 캡으로 4K OOM 방지
- dHash 16×16→32×32 (256→1024bit), max_hamming 50→20
- test_pipeline.py에 YouTube URL 추가 + --download 모드 + gc.collect()
## 결정 사항
- **MSE → 파노라마 → pHash 순서**: MSE가 완전 동일 제거, 파노라마가 겹침 합성, pHash가 반복 구간 제거. 각 단계가 다른 특성의 중복 처리
- **dHash 32×32 선택**: 16×16은 Tab의 6선 구조를 구분 못함(모두 유사 hash). 32×32는 마디번호/음표 위치까지 차별화
- **MAX_FRAME_WIDTH=1280**: 1920은 OOM 유발, 960은 360p와 큰 차이 없음. 1280은 1080p 소스에서 충분한 품질 + RAM 절약
## 미완료
- **1080p 파이프라인 전체 테스트 미완료**: 3개 영상 순차 실행 시 메모리 행으로 결과 미확인
- 사용자 피드백: "여전히 중복 있음" → 마디번호 기반 추가 검증 로직 필요
- test_pipeline.py ↔ youtube_tab_to_pdf.py 완전 통합 미정

33
dump_frames.py Normal file
View File

@@ -0,0 +1,33 @@
"""원본 프레임 덤프 — 각 영상에서 5개 프레임을 랜덤 추출"""
import sys
if sys.platform == "win32":
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
import cv2
import numpy as np
from pathlib import Path
output = Path("output")
dump_dir = output / "raw_dump"
dump_dir.mkdir(exist_ok=True)
mp4s = sorted(output.glob("*.mp4"))
for vi, mp4 in enumerate(mp4s):
cap = cv2.VideoCapture(str(mp4))
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
fps = cap.get(cv2.CAP_PROP_FPS)
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
print(f"Video {vi+1}: {mp4.name[:30]}... ({w}x{h}, {fps:.0f}fps, {total} frames)")
# 균등 간격으로 5개 프레임
indices = np.linspace(total * 0.1, total * 0.9, 5, dtype=int)
for i, idx in enumerate(indices):
cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
ret, frame = cap.read()
if ret:
path = dump_dir / f"v{vi+1}_raw_{i}.png"
cv2.imwrite(str(path), frame)
print(f" frame {idx}{path.name} ({frame.shape})")
cap.release()
print(f"\n덤프 완료: {dump_dir}")

110
test_pipeline.py Normal file
View File

@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""로컬 캐시된 mp4 파일로 파이프라인 테스트 (다운로드 스킵)
1080p 다운로드 모드: python test_pipeline.py --download
"""
import sys
import os
from pathlib import Path
import importlib.util
import argparse
import gc
# youtube_tab_to_pdf 모듈 임포트
spec = importlib.util.spec_from_file_location(
"pipeline", str(Path(__file__).parent / "youtube_tab_to_pdf.py"))
pipeline = importlib.util.module_from_spec(spec)
spec.loader.exec_module(pipeline)
# 테스트용 YouTube URLs
TEST_URLS = {
"video_1": "https://www.youtube.com/watch?v=x76IMSvWR0o", # 晴る
"video_2": "https://www.youtube.com/watch?v=90BWvJY6KbE", # 新宝島
"video_3": "https://www.youtube.com/watch?v=Ri9g4lwnrJQ", # 空奏列車
}
def test_video(mp4_path: Path, label: str):
"""단일 영상 테스트 — 다운로드 없이 로컬 파일 직접 사용"""
print(f"\n{'='*60}")
print(f"테스트: {label}")
print(f"파일: {mp4_path.name}")
print(f"{'='*60}")
output_dir = Path("output")
debug_dir = output_dir / "debug_frames" / label
debug_dir.mkdir(parents=True, exist_ok=True)
# Step 2: 프레임 추출
frames = pipeline.extract_frames(mp4_path)
# Step 3: 패턴 감지
pattern = pipeline.detect_pattern(frames)
# Step 4: 고유 프레임 추출
if pattern == "scroll":
unique = pipeline.extract_unique_scroll(frames)
elif pattern == "split":
unique = pipeline.extract_unique_split(frames)
else:
unique = pipeline.extract_unique_overlay(frames)
# Step 5: PDF 생성
pdf_path = output_dir / f"test_{label}.pdf"
pipeline.generate_pdf(unique, pdf_path, debug_dir=debug_dir)
print(f"\n결과: {pattern} / {len(unique)}개 고유 프레임")
return pattern, len(unique)
def download_test_videos():
"""1080p로 테스트 영상 다운로드"""
output_dir = Path("output")
output_dir.mkdir(exist_ok=True)
for label, url in TEST_URLS.items():
print(f"\n--- {label} 다운로드 ---")
try:
video_path, title = pipeline.download_video(url, output_dir)
print(f" → 완료: {video_path.name}")
except Exception as e:
print(f" → 실패: {e}")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--download", action="store_true",
help="1080p로 테스트 영상 다운로드")
args = parser.parse_args()
if args.download:
download_test_videos()
return
output_dir = Path("output")
mp4_files = sorted(output_dir.glob("*.mp4"))
if not mp4_files:
print("output/ 폴더에 mp4 파일이 없습니다!")
print(" → python test_pipeline.py --download 로 영상 다운로드")
sys.exit(1)
print(f"캐시된 영상 {len(mp4_files)}개 발견:")
for f in mp4_files:
print(f" - {f.name} ({f.stat().st_size / 1024 / 1024:.1f} MB)")
results = {}
for i, mp4 in enumerate(mp4_files):
label = f"video_{i+1}"
pattern, count = test_video(mp4, label)
results[label] = (mp4.name, pattern, count)
gc.collect() # 1080p 프레임 메모리 해제
print(f"\n{'='*60}")
print("전체 결과 요약:")
print(f"{'='*60}")
for label, (name, pattern, count) in results.items():
print(f" {label}: {pattern:8s}{count:4d}개 프레임 | {name[:40]}")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff