fix(cv): resolve infinite page duplication bug caused by playback cursor

This commit is contained in:
2026-03-29 21:23:18 +09:00
parent ac0c098259
commit 3377b5f68d
23 changed files with 779 additions and 465 deletions

View File

@@ -211,43 +211,35 @@ def extract_frames(video_path: Path, fps: float = DEFAULT_FPS) -> List[np.ndarra
# ─── 핵심: 흰색 배경 Tab 영역 검출 ───────────────────────────────────────
def _find_white_tab_strip(frame: np.ndarray, min_strip_ratio: float = 0.10) -> Optional[Tuple[int, int]]:
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)를 반환.
전략: HSV 색공간에서 밝고(V>180) + 무채색(S<40)인 행을 찾아
연속된 흰색 영역이 일정 비율 이상인 영역을 Tab 영역으로 판정.
grayscale 단독보다 노란 하이라이트, 컬러 배경을 정확히 배제.
mode="largest": 가장 큰 하나의 스트립만 반환 (연속 스크롤용)
mode="union": 최상단 스트립부터 최하단 스트립까지 전체를 포괄하여 반환 (오버레이용 다중 줄 보존)
"""
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
bright_mask = row_tab_ratio > 0.5
# 연속된 흰색 행 영역 찾기 (검은색 탭 라인 및 음표로 인한 끊김 허용)
max_gap = int(h * 0.02) # 약 2% (720p 기준 14px)까지의 흰색 끊김은 같은 영역으로 간주
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
if start is None: start = i
gap_count = 0
else:
if start is not None:
@@ -262,18 +254,20 @@ def _find_white_tab_strip(frame: np.ndarray, min_strip_ratio: float = 0.10) -> O
if length >= h * min_strip_ratio:
regions.append((start, h - gap_count))
if not regions:
return None
if not regions: return None
# 가장 넓은 흰색 스트립 반환
best = max(regions, key=lambda r: r[1] - r[0])
# 추가 패딩: 상단은 반복선 브래킷(┌─ 1.) 보존을 위해 크게 잡음
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)
@@ -381,50 +375,61 @@ def _detect_tab_overlay(frame: np.ndarray) -> Optional[Tuple[int, int, int, int]
return best
def detect_pattern(frames: List[np.ndarray], sample_count: int = 20) -> str:
"""영상 패턴 감지: scroll (우선) vs overlay"""
print("[3/5] 영상 패턴 분석 중...")
def detect_pattern(frames: List[np.ndarray], sample_count: int = 15) -> str:
print("[3/5] 영상 패턴 정밀 분석 중 (Motion Tracking)...")
if len(frames) < 30: return "scroll"
if len(frames) < sample_count:
sample_count = len(frames)
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
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 = 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 프레임 추출 ─────────────────────────────────────────
@@ -502,9 +507,21 @@ def _extract_print_channel(frame: np.ndarray) -> np.ndarray:
return frame[:, :, 2]
def _extract_tracking_channel(frame: np.ndarray) -> np.ndarray:
"""트래킹 전용 채널 (Blue 채널): 노란색을 거대한 검은색 마커로 만들어 반복적인 마디점프 시각적 오류를 영구차단"""
if len(frame.shape) != 3: return frame
return frame[:, :, 0]
"""트래킹 전용 채널: 유색 커서(빨강, 노랑 등) 및 배경 노이즈를 완벽히 투명화하고, 오직 순수한 검은색 음표와 오선지만을 마스킹하여 추출"""
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)을 추정합니다."""
@@ -619,16 +636,79 @@ def _merge_scroll_candidates(candidates: List[np.ndarray], min_scroll: int = 5,
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()
# 매마디가 똑같이 생긴 반주 구간(예: 코러스)이 있을 때, 검색 범위가 너무 넓거나
# 비교 기준(head)이 너무 짧으면, OpenCV가 과거의 똑같은 반주에 현재 씬을 겹쳐버림(마디 누락/점프 발생).
# 이를 막기 위해 비교 기준은 넓게(800), 검색 과거 이력은 짧게(1500=최대 편집 되감기 길이) 제한.
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]
@@ -641,7 +721,6 @@ def merge_panoramas_list(panoramas):
res = cv2.matchTemplate(s_gray, h_gray, cv2.TM_CCOEFF_NORMED)
_, max_val, _, max_loc = cv2.minMaxLoc(res)
# [BUG2 FIX] 매칭 임계치 0.60 → 0.50 (반복 코러스 구간에서 0.56~0.59 스코어로 분리되던 버그)
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
@@ -650,29 +729,77 @@ def merge_panoramas_list(panoramas):
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]:
"""
Deprecated parameters kept for signature compatibility.
Uses the new Object-Oriented Hybrid State Machine (ScoreExtractor)
and robust TemporalTracker to guarantee pure monotonic structural extraction.
"""
from video_cv_tracker import TemporalTracker
from score_extractor import ScoreExtractor
print("[Pipeline] Isolating static structures via TemporalTracker")
# Tracker handles Temporal Median to isolate sheet music overlays
tracker = TemporalTracker(diff_threshold=0.05)
# Dynamically find the pristine white tablature strip bounding box to isolate it from background noise
tab_bounds = None
for f in frames[::30]:
bounds = _find_white_tab_strip(f)
@@ -685,81 +812,113 @@ def extract_unique_scroll(frames: List[np.ndarray], scan_dist: int = 4) -> List[
print(f" -> Found precise sheet music bounds: Y={top} to Y={bottom}")
else:
top, bottom = 0, frames[0].shape[0]
print(f" -> Bounding box not found, fallback to full frame: Y={top} to Y={bottom}")
for frame in frames:
# Tightly constrain the region of interest to the sheet music.
# This completely hides the guitarist's hands and guarantees pure static tracking.
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.")
# State Machine extraction
extractor = ScoreExtractor()
extractor.process_pages(unique_pages)
tiled_rows = extractor.tile_to_a4(chunk_width=1800)
print(" -> 점프 컷 및 도돌이표 처리 중...")
panoramas = merge_panoramas_list(unique_pages)
# Wait, the thresholding already produced a 255 White Background with 0 Black Text!
# No need to invert!
final_a4_chunks = []
for row in tiled_rows:
final_a4_chunks.append(row)
return final_a4_chunks
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]:
"""오버레이형: Tab 오버레이 박스 추출 + 전체 히스토리 중복 제거"""
print("[4/5] 오버레이형 Tab 추출 중...")
"""오버레이형: 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 = []
all_normalized = []
for frame in frames:
bbox = _detect_tab_overlay(frame)
if bbox is None:
for crop in pages:
if np.mean(cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)) < 80:
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:
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)
all_normalized.append(canvas)
# ── Phase 2: 마디번호 기반 최종 중복 제거 (OCR) ──
if unique:
unique = _dedup_by_measure_number(unique)
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(unique)}고유 Tab 오버레이")
return unique
print(f" → 최종: {len(trimmed_unique)}정제된 오버레이 페이지 추출 성공")
return trimmed_unique
# ─── Step 5: A4 PDF 생성 ─────────────────────────────────────────────────