fix(cv): resolve infinite page duplication bug caused by playback cursor
This commit is contained in:
@@ -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 생성 ─────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user