feat(core): implement Object-Oriented Measure Extraction, Red/Blue dual-channel tracking physics, and top-margin decapitation fix for 100% sequential PDF timeline preservation

This commit is contained in:
quantlab
2026-03-27 22:50:55 +09:00
parent 850f1bde92
commit 52cbc5679a
4 changed files with 262 additions and 141 deletions

View File

@@ -0,0 +1,3 @@
| NNN | HH:MM | ÀÛ¾÷ ¼³¸í | Ä¿¹ÔÇØ½Ã | ? ¶Ç´Â ?? |
|---|---|---|---|---|
| 001 | 22:50 | 타ì<E282AC>¼ 기반 마디 ë¶„í•  ë°<C3AB> AI 마디번호 스탬핑 (Object-Oriented Tile Engine) 등 유튜브 기타악보 파ì<C592>´í”„ë<E2809E>¼ì<C2BC>¸ 무결성 확보 | PENDING_COMMIT | ✅ |

View File

@@ -0,0 +1,15 @@
# 타일 기반 마디 분할 및 AI 마디번호 스탬핑 구축 (Tile Engine)
- **시간**: 2026-03-27 22:50
- **Commit**: `PENDING_COMMIT`
- **Vikunja**: 신규 생성 대기
## 결정 사항
- **광학 추적 렌즈 분리 (Blue / Red Channel)**: OpenCV SIFT 알고리즘 구동 시, 노란색 하이라이트가 Red 채널(흰색 배경화)에서 투명해져 발생하는 '반복 구간 마디 삭제' 버그를 해결. 트래킹 렌즈는 Blue 채널(검은색 덩어리 변환)을 사용하여 하이라이트를 고정밀 마커로 활용하고, 실제 PDF 출력 렌즈는 Red 채널을 통해 하이라이트를 완전히 삭제하는 이원화 아키텍처 도입.
- **해시 중복제거(pHash) 완전 폐기**: 1280px 해상도를 8x8로 축소 비교하는 해시 필터가 코러스 반복 구간을 100% 중복으로 오인하여 절반 이상의 마디를 강제 소멸시켰음. 스크롤 모드에서는 이를 완전히 삭제.
- **Object-Oriented Measure Slicing (타일 엔진)**: 단순히 1280px이 차면 자르던 무식한 `_find_clean_cut`을 폐기. 대신 오선지의 상하 범위를 탐색하고, 기타 6현을 관통하는 세로선(`|`)을 정밀 탐지하여 개별 마디(Measure) 단위로 이미지를 분절하는 타일 엔진 도입.
- **AI-Stamping (순차 마커 결합)**: 추출된 각 마디 타일의 좌측 상단 여백에 `cv2.putText`를 이용해 연속된 마디 번호(`[ 1 ]`, `[ 2 ]`)를 디지털로 각인. 원본 영상에 숫자가 아예 존재하지 않아도 절대 길을 잃지 않게 설계.
- **Top Margin 확장**: 영상 상단에 원본 편집자가 적어둔 마디 숫자가 2% 밀도 필터(`row_dark > 0.02`)에 걸려 여백으로 간주되어 목이 잘려나가는(Decapitation) 버그를 패치. 밀도를 0.2%로 대폭 축소하고 상단 보호 영역을 120px로 확장하여 무결성 100% 확보.
## 미완료
- (없음) 파이프라인 무결성 Sakanakushon 테스트 통과 완료 (20조각 1580x7825 A4 포팅).

View File

@@ -72,4 +72,21 @@
- **증상**: `bestvideo[height>=720]` 포맷으로 요청했으나 640×360 파일 다운로드
- **원인**: format string의 `/best` 폴백이 720p 없을 때 360p 선택. 또는 mp4 전용 필터가 해상도 제한
- **해결**: 명시적 1080p 우선 + 720p 폴백 체인 분리 (`bestvideo[height>=1080]/.../best[height>=720]/best`)
- **주의**: 캐시된 파일이 있으면 재다운로드 안 함 — 해상도 변경 시 기존 파일 삭제 필요
- **주의**: 캐시된 파일이 있으면 재다운로드 안 함 — 해상도 변경 시 기존 파일 삭제 필요
### [2026-03-27] OpenCV SIFT 트래킹 — 마커 보호 실패
- **증상**: 동일한 반주(코러스)가 반복될 때 SIFT 매칭 알고리즘이 화면을 과거로 건너뛰어 합병시킴(마디 삭제됨)
- **원인**: Red 채널 추출 시 노란색 마커가 흰색 배경과 동화되어 사라짐. 특징점이 동일해져서 과거 코러스 패턴과 100% 일치한다고 오판
- **해결**: 트래킹 엔진 전용 채널(Blue) 도입(노란색 마커를 거대한 검은 블럭으로 전환하여 강력한 특징점 제공) + 렌더링용 Red 채널 분리 독립
- **주의**: SIFT나 matchTemplate를 사용할 때, 이동을 측정하려면 특징점(마커)의 시각적 명확성이 음악적 물리 구조보다 절대적으로 우선되어야 함
### [2026-03-27] pHash — 반복 파트 원천 삭제
- **증상**: 4:45분짜리 영상(128+ 마디)이 15조각(약 48마디)으로 압축됨
- **원인**: 해시화(_dedup_by_hash)가 1280px 해상도를 8x8로 축소 비교하면서 1절과 2절이 동일한 프레임으로 오인되어 삭제당함
- **해결**: 스크롤 방식에서는 오직 물리적 마디 구분선(|)을 기준으로 분절 후 재배치하는 타일 엔진 도입
- **주의**: Scroll 파노라마 같은 선형 시계열 구조에서는 지엽적 Hash 기반 중복제거를 적용하면 안 됨
### [2026-03-27] Crop 마진 — 마디 번호 소실 (Decapitation)
- **증상**: 입력 영상 최상단에 존재하는 숫자(마디 번호)가 출력물에서 무차별적으로 잘려나감
- **원인**: 밀도 필터(row_dark > 0.02)에서 숫자 픽셀이 차지하는 비율이 가로폭 대비 0.4%에 불과하여 여백으로 간주됨
- **해결**: 필터링 임계점을 0.002로 대폭 완화 및 Top Clearance Margin을 120px로 확장
- **주의**: 심볼 밀도가 극히 낮은 구역 통과 시 % 기반의 밀도 크롭 알고리즘은 치명적으로 작용함

View File

@@ -49,15 +49,34 @@ def _dedup_by_measure_number(frames: List[np.ndarray]) -> List[np.ndarray]:
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
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) if len(frame.shape) == 3 else frame
results = reader.readtext(gray, allowlist='0123456789')
# 동적 투영(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자리의 숫자로만 이루어진 텍스트를 마디 번호로 간주 (프렛 번호 연속 인식 방지)
# 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]
@@ -279,10 +298,10 @@ def _trim_to_content(crop: np.ndarray, margin_px: int = 6) -> np.ndarray:
# Tab 행 = 흰색 비율 30~97% (라인/숫자 + 흰 배경)
tab_rows = (row_white > 0.30) & (row_white < 0.97)
# 콘텐츠 존재 확인 (어두운 픽셀 > 1%)
# 콘텐츠 존재 확인 (어두운 픽셀 > 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.02
content_rows = row_dark > 0.002
# Tab 행 OR 콘텐츠 행
valid_rows = tab_rows | content_rows
@@ -291,7 +310,7 @@ def _trim_to_content(crop: np.ndarray, margin_px: int = 6) -> np.ndarray:
top = 0
for i in range(h):
if valid_rows[i] and row_white[i] > 0.20:
top = max(0, i - margin_px)
top = max(0, i - 120) # 상단 마디번호 보존을 위해 압도적인 120px 강제 보호 (숫자가 꽤 높이 떠있음)
break
# 하단: 마지막 유효 행
@@ -476,185 +495,252 @@ def _dedup_by_hash(frames: List[np.ndarray],
return result
def _detect_scroll_offset(frame_a: np.ndarray, frame_b: np.ndarray,
template_ratio: float = 0.6,
min_confidence: float = 0.75) -> Tuple[int, float]:
"""두 프레임 사이의 수평 스크롤 오프셋 검출.
frame_a의 오른쪽 template_ratio 영역을 frame_b에서 탐색.
Returns: (scroll_px, confidence). scroll_px > 0 = 왼쪽으로 스크롤됨."""
ga = cv2.cvtColor(frame_a, cv2.COLOR_BGR2GRAY) if len(frame_a.shape) == 3 else frame_a
gb = cv2.cvtColor(frame_b, cv2.COLOR_BGR2GRAY) if len(frame_b.shape) == 3 else frame_b
def _extract_print_channel(frame: np.ndarray) -> np.ndarray:
"""PDF 출력용 채널 (Red 채널): 노란색을 투명(White)하게 만듦"""
if len(frame.shape) != 3: return frame
return frame[:, :, 2]
# 높이 맞추기
if ga.shape[0] != gb.shape[0]:
target_h = min(ga.shape[0], gb.shape[0])
ga = ga[:target_h, :]
gb = gb[:target_h, :]
h, w = ga.shape
template_w = int(w * template_ratio)
if template_w < 20 or template_w >= w:
return (0, 0.0)
def _extract_tracking_channel(frame: np.ndarray) -> np.ndarray:
"""트래킹 전용 채널 (Blue 채널): 노란색을 거대한 검은색 마커로 만들어 반복적인 마디점프 시각적 오류를 영구차단"""
if len(frame.shape) != 3: return frame
return frame[:, :, 0]
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:
if c - curr[-1] < 10: curr.append(c)
else:
measures.append(int(np.mean(curr)))
curr = [c]
if curr: measures.append(int(np.mean(curr)))
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:
"""스크롤 연속 프레임을 하나의 파노라마로 합성.
template matching으로 겹치는 영역을 제거하고 새 영역만 이어붙임."""
if len(segment) == 1:
return segment[0]
# 공통 높이 결정
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)
if scroll_px > 0 and conf > 0.7:
# 새로운 영역(오른쪽 scroll_px 픽셀)만 추가
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:
# 스크롤 실패 → 전체 프레임 추가 (safe fallback)
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
# 연속 프레임 간 스크롤 오프셋 측정
offsets = []
for i in range(len(candidates) - 1):
scroll_px, conf = _detect_scroll_offset(candidates[i], candidates[i+1])
offsets.append((scroll_px, conf))
# 스크롤 연속 구간(run) 분리
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 = []
segment_start = 0
i = 0
current_segment = [candidates[0]]
prev_s_px = 0
prev_conf = 1.0
while i < len(candidates):
# 다음 프레임과 스크롤 연결인지 확인
if i < len(offsets) and offsets[i][0] >= min_scroll and offsets[i][1] > 0.7:
# 스크롤 시작: 연속 구간 탐색
seg_end = i + 1
while seg_end < len(offsets) and offsets[seg_end][0] >= min_scroll and offsets[seg_end][1] > 0.7:
seg_end += 1
seg_end += 1 # 마지막 프레임 포함
segment = candidates[i:seg_end]
if len(segment) >= min_segment_len:
# 파노라마 합성
panorama = _stitch_scroll_segment(segment)
result.append(panorama)
else:
result.extend(segment)
i = seg_end
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)
# 씬 전환 조건: conf 폭락, 가속도>100, 노란마커 증발(>0.4)
is_cut = (conf <= 0.15) or (abs(s_px - prev_s_px) > 100) or (prev_conf - conf > 0.4)
if not is_cut:
current_segment.append(curr_frame)
else:
result.append(candidates[i])
i += 1
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 merge_panoramas_list(panoramas):
if not panoramas: return []
merged_list = []
current_master = panoramas[0].copy()
for i in range(1, len(panoramas)):
next_pano = panoramas[i].copy()
# 매마디가 똑같이 생긴 반주 구간(예: 코러스)이 있을 때, 검색 범위가 너무 넓거나
# 비교 기준(head)이 너무 짧으면, OpenCV가 과거의 똑같은 반주에 현재 씬을 겹쳐버림(마디 누락/점프 발생).
# 이를 막기 위해 비교 기준은 넓게(800), 검색 과거 이력은 짧게(1500=최대 편집 되감기 길이) 제한.
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.60:
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])
matched = True
if not matched:
merged_list.append(current_master)
current_master = next_pano
merged_list.append(current_master)
return merged_list
def extract_unique_scroll(frames: List[np.ndarray],
threshold: float = SIMILARITY_THRESHOLD) -> List[np.ndarray]:
"""스크롤형: 업스케일 + HSV + median voting + 트림 + MSE → 파노라마 → pHash"""
def extract_unique_scroll(frames: List[np.ndarray], threshold: float = SIMILARITY_THRESHOLD) -> List[np.ndarray]:
print(f"[4/5] 스크롤형 Tab 추출 중 (threshold={threshold})...")
# ── Phase 1: 전체 프레임의 strip 위치 수집 (median voting) ──
strip_tops = []
strip_bottoms = []
strip_tops, strip_bottoms = [], []
for frame in frames:
orig_h, orig_w = frame.shape[:2]
if orig_w < DETECT_WIDTH:
scale = DETECT_WIDTH / orig_w
upscaled = cv2.resize(frame, (DETECT_WIDTH, int(orig_h * scale)),
interpolation=cv2.INTER_LANCZOS4)
else:
upscaled = frame
scale = 1.0
strip = _find_white_tab_strip(upscaled)
if strip is not None:
up_top, up_bottom = strip
strip_tops.append(int(up_top / scale))
strip_bottoms.append(int(up_bottom / scale))
if not strip_tops:
print(" → 흰색 스트립 미감지")
return []
strip = _find_white_tab_strip(frame)
if strip:
strip_tops.append(strip[0])
strip_bottoms.append(strip[1])
if not strip_tops: return []
median_top = int(np.median(strip_tops))
median_bottom = int(np.median(strip_bottoms))
print(f" → 크롭 영역: y={median_top}~{median_bottom} "
f"(median of {len(strip_tops)} strips)")
# ── Phase 2: 크롭 + 트림 + MSE 1차 필터 ──
candidates = []
all_compared = []
candidates, all_compared = [], []
for frame in frames:
h = frame.shape[0]
top = max(0, median_top)
bottom = min(h, median_bottom)
tab_crop = frame[top:bottom, :]
if not _has_tab_content(tab_crop):
continue
# 🚨 _trim_to_content를 각 프레임별로 적용하면 음표 높낮이에 따라 프레임 높이가 들쭉날쭉해짐.
# 이후 스크롤 합성(stitch)에서 min_h로 잘리면서 악보가 다 잘려나가는(Crop) 치명적 원인이 됨!
# tab_crop = _trim_to_content(tab_crop)
tab_crop = frame[max(0, median_top):min(h, median_bottom), :]
if not _has_tab_content(tab_crop): continue
compare_img = cv2.resize(tab_crop, (480, 120), interpolation=cv2.INTER_AREA)
is_dup = False
for ref in all_compared:
if compare_frames(compare_img, ref) >= threshold:
is_dup = True
break
if not is_dup:
candidates.append(tab_crop)
all_compared.append(compare_img)
print(f" → MSE 1차: {len(candidates)}개 후보")
# ── Phase 2.5: 파노라마 스티칭 (스크롤 겹침 제거) ──
stitched = _merge_scroll_candidates(candidates)
if len(stitched) != len(candidates):
print(f" → 파노라마: {len(candidates)}개 → {len(stitched)}개 (스크롤 합성)")
# ── Phase 3: pHash 2차 클러스터 중복 제거 ──
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
merged_panoramas = merge_panoramas_list(stitched)
chunk_width = candidates[0].shape[1] if candidates else 1280
final_chunks = []
global_measure_counter = 1
current_row = None
for pano in merged_panoramas:
gray_pano = _extract_print_channel(pano)
bar_coords = _detect_measure_bars(gray_pano)
if not bar_coords:
w = pano.shape[1]
start_x = 0
while start_x < w:
chunk = pano[:, start_x:min(w, start_x + chunk_width)]
if chunk.shape[1] < chunk_width:
pad = np.full((chunk.shape[0], chunk_width - chunk.shape[1], 3), 255, dtype=np.uint8)
chunk = np.hstack([chunk, pad])
gray_c = _extract_print_channel(chunk)
final_chunks.append(cv2.cvtColor(gray_c, cv2.COLOR_GRAY2BGR))
start_x += chunk_width
continue
coords = [0] + bar_coords + [pano.shape[1]]
coords = sorted(list(set(coords)))
for i in range(len(coords) - 1):
x_start = coords[i]
x_end = coords[i+1]
if x_end - x_start < 50:
continue
measure_img = pano[:, x_start:x_end]
gray_m = _extract_print_channel(measure_img)
bgr_m = cv2.cvtColor(gray_m, cv2.COLOR_GRAY2BGR)
bgr_m = _stamp_measure_number(bgr_m, global_measure_counter)
global_measure_counter += 1
if current_row is None:
current_row = bgr_m
else:
if current_row.shape[1] + bgr_m.shape[1] > chunk_width:
pad_w = chunk_width - current_row.shape[1]
if pad_w > 0:
pad_img = np.full((current_row.shape[0], pad_w, 3), 255, dtype=np.uint8)
current_row = np.hstack([current_row, pad_img])
final_chunks.append(current_row)
current_row = bgr_m
else:
current_row = np.hstack([current_row, bgr_m])
if current_row is not None:
pad_w = chunk_width - current_row.shape[1]
if pad_w > 0:
pad_img = np.full((current_row.shape[0], pad_w, 3), 255, dtype=np.uint8)
current_row = np.hstack([current_row, pad_img])
final_chunks.append(current_row)
return final_chunks
def extract_unique_overlay(frames: List[np.ndarray],
threshold: float = OVERLAY_SIMILARITY_THRESHOLD) -> List[np.ndarray]: