- [BUG1] _merge_scroll_candidates: 씬전환 가속도 조건 제거 (9→1 세그먼트) - [BUG2] merge_panoramas_list: 매칭 임계치 0.60→0.50 (파노라마 3→1 병합) - [BUG3] _detect_measure_bars: 마디선 최소간격 100px 필터 추가 (17px 오탐 제거) - remove: _stamp_measure_number 호출 제거 (AI 임의 [1][2][3] 스탬프 삭제) - add: sim_stitch.py, simulate_ocr_pipeline.py, verify_fixes.py (진단/검증 스크립트)
316 lines
13 KiB
Python
316 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
OCR-First 파이프라인 시뮬레이션 스크립트
|
|
--------------------------------------------
|
|
실제 캐시된 프레임 이미지(temp_frames/f_XXXX.png)를 읽어서
|
|
새 파이프라인의 각 단계를 시뮬레이션하고 결과를 검증합니다.
|
|
|
|
단계:
|
|
[A] HSV Tab Strip 추출 (기존 로직 재사용)
|
|
[B] 각 프레임에서 마디번호 OCR
|
|
[C] 마디번호 기반 그룹핑 + 최고선명도 프레임 선택
|
|
[D] 마디번호 순서 정렬
|
|
[E] 파노라마 이어붙이기 (단순 hstack)
|
|
[F] 결과 리포트 출력
|
|
|
|
실행:
|
|
C:\\ProgramData\\miniforge3\\envs\\score\\python.exe simulate_ocr_pipeline.py
|
|
"""
|
|
|
|
import sys, os
|
|
from pathlib import Path
|
|
import cv2
|
|
import numpy as np
|
|
from collections import defaultdict
|
|
|
|
if sys.platform == "win32":
|
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
|
|
FRAME_DIR = Path("output/temp_frames")
|
|
OUT_DIR = Path("output/sim_result")
|
|
OUT_DIR.mkdir(exist_ok=True)
|
|
|
|
# ─── [A] HSV Tab Strip 검출 (기존 코드와 동일) ─────────────────────────────
|
|
|
|
def _find_white_tab_strip(frame, min_strip_ratio=0.10):
|
|
h, w = frame.shape[:2]
|
|
margin_x = int(w * 0.1)
|
|
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]
|
|
pure_white = (roi_v > 180) & (roi_s < 40)
|
|
bright_pastel= (roi_v > 200) & (roi_s < 100)
|
|
tab_mask = pure_white | bright_pastel
|
|
row_tab_ratio = np.mean(tab_mask, axis=1)
|
|
bright_mask = row_tab_ratio > 0.5
|
|
max_gap = int(h * 0.02)
|
|
regions, start, gap_count = [], None, 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:
|
|
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 - gap_count) - start
|
|
if length >= h * min_strip_ratio:
|
|
regions.append((start, h - gap_count))
|
|
if not regions: return None
|
|
best = max(regions, key=lambda r: r[1] - r[0])
|
|
pad = int(h * 0.03)
|
|
return (max(0, best[0] - pad), min(h, best[1] + pad))
|
|
|
|
def _trim_tab_crop(crop, margin_px=6):
|
|
"""Tab 크롭에서 상단/하단 여백 + 하단 기타리스트 영상 제거"""
|
|
h, w = crop.shape[:2]
|
|
if h < 15 or w < 50: return crop
|
|
hsv = cv2.cvtColor(crop, cv2.COLOR_BGR2HSV)
|
|
_, s_ch, v_ch = cv2.split(hsv)
|
|
white_mask = ((v_ch > 180) & (s_ch < 40)) | ((v_ch > 200) & (s_ch < 100))
|
|
row_white = np.mean(white_mask, axis=1)
|
|
tab_rows = (row_white > 0.30) & (row_white < 0.97)
|
|
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
|
|
row_dark = np.mean(gray < 180, axis=1)
|
|
content_rows = row_dark > 0.002
|
|
valid_rows = tab_rows | content_rows
|
|
|
|
# 상단: 첫 유효 행 기준 -120px
|
|
top = 0
|
|
for i in range(h):
|
|
if valid_rows[i] and row_white[i] > 0.20:
|
|
top = max(0, i - 120)
|
|
break
|
|
|
|
# 하단: 마지막 유효 행 (흰색비율 > 0.20 조건 유지, 여유 +margin_px)
|
|
bottom = h
|
|
for i in range(h - 1, -1, -1):
|
|
if valid_rows[i] and row_white[i] > 0.20:
|
|
bottom = min(h, i + margin_px)
|
|
break
|
|
|
|
if bottom - top < 15: return crop
|
|
return crop[top:bottom, :]
|
|
|
|
|
|
# ─── [B] OCR 마디번호 읽기 ─────────────────────────────────────────────────
|
|
|
|
_ocr_reader = None
|
|
|
|
def _get_ocr():
|
|
global _ocr_reader
|
|
if _ocr_reader is None:
|
|
import easyocr
|
|
print(" → EasyOCR 로딩 중...")
|
|
_ocr_reader = easyocr.Reader(['en'])
|
|
return _ocr_reader
|
|
|
|
def _ocr_measure_number(crop):
|
|
"""Tab 크롭 이미지에서 마디번호(상단 숫자)를 읽어 int 또는 None 반환"""
|
|
if crop is None or crop.size == 0: return None
|
|
h, w = crop.shape[:2]
|
|
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) if len(crop.shape) == 3 else crop
|
|
_, thresh = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV)
|
|
row_sums = np.sum(thresh, axis=1) / 255
|
|
staff_rows = np.where(row_sums > w * 0.5)[0]
|
|
|
|
if len(staff_rows) > 0:
|
|
first_line_y = staff_rows[0]
|
|
y0 = max(0, first_line_y - 50)
|
|
y1 = max(10, first_line_y - 2)
|
|
# 좌측 10% 영역 (마디번호가 각 행 맨 왼쪽에 인쇄됨)
|
|
roi = gray[y0:y1, :int(w * 0.10)]
|
|
else:
|
|
roi = gray[:int(h * 0.25), :int(w * 0.10)]
|
|
|
|
if roi.size == 0: return None
|
|
|
|
# 3x 업스케일 + 이진화 → OCR 인식률 향상
|
|
up = cv2.resize(roi, (0, 0), fx=3.0, fy=3.0, interpolation=cv2.INTER_CUBIC)
|
|
_, up_bin = cv2.threshold(up, 150, 255, cv2.THRESH_BINARY_INV)
|
|
|
|
reader = _get_ocr()
|
|
results = reader.readtext(up_bin, allowlist='0123456789')
|
|
for (_, text, conf) in results:
|
|
if conf > 0.3 and text.isdigit() and 1 <= len(text) <= 3:
|
|
return int(text)
|
|
return None
|
|
|
|
|
|
# ─── [C] 선명도 기준 최고 프레임 선택 ────────────────────────────────────
|
|
|
|
def _pick_best(frames):
|
|
if len(frames) == 1: return frames[0]
|
|
def sharpness(f):
|
|
g = cv2.cvtColor(f, cv2.COLOR_BGR2GRAY) if len(f.shape)==3 else f
|
|
return cv2.Laplacian(g, cv2.CV_64F).var()
|
|
return max(frames, key=sharpness)
|
|
|
|
|
|
# ─── Main 시뮬레이션 ────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
# 프레임 로드 (f_0025 ~ f_0149 순서 정렬)
|
|
paths = sorted(FRAME_DIR.glob("f_0*.png"))
|
|
if not paths:
|
|
print("❌ 프레임 파일 없음:", FRAME_DIR)
|
|
sys.exit(1)
|
|
|
|
print(f"[SIM] {len(paths)}개 프레임 로드 — {FRAME_DIR}")
|
|
|
|
# ── 스트립 위치 중앙값 계산 ─────────────────────────────────────────
|
|
strip_tops, strip_bottoms = [], []
|
|
for p in paths[:30]: # 첫 30장으로 샘플링
|
|
f = cv2.imread(str(p))
|
|
if f is None: continue
|
|
s = _find_white_tab_strip(f)
|
|
if s: strip_tops.append(s[0]); strip_bottoms.append(s[1])
|
|
|
|
if not strip_tops:
|
|
print("❌ Tab 스트립 감지 실패")
|
|
sys.exit(1)
|
|
|
|
med_top = int(np.median(strip_tops))
|
|
med_bottom = int(np.median(strip_bottoms))
|
|
print(f"[SIM-A] 스트립 Y범위 중앙값: {med_top} ~ {med_bottom}px")
|
|
|
|
# ── [B+C] 각 프레임 OCR + 그룹핑 ───────────────────────────────────
|
|
measure_groups = defaultdict(list) # {measure_num: [crop, ...]}
|
|
no_ocr_frames = [] # OCR 실패 프레임
|
|
ocr_log = []
|
|
|
|
total = len(paths)
|
|
for idx, p in enumerate(paths):
|
|
f = cv2.imread(str(p))
|
|
if f is None: continue
|
|
h = f.shape[0]
|
|
crop = f[max(0, med_top):min(h, med_bottom), :]
|
|
crop = _trim_tab_crop(crop)
|
|
|
|
num = _ocr_measure_number(crop)
|
|
status = f"[{num:3d}]" if num is not None else "[ ? ]"
|
|
print(f" 프레임 {idx+1:3d}/{total} ({p.name}) → {status}")
|
|
ocr_log.append((p.name, num))
|
|
|
|
if num is not None:
|
|
measure_groups[num].append(crop)
|
|
else:
|
|
no_ocr_frames.append((idx, crop))
|
|
|
|
print(f"\n[SIM-B] OCR 결과:")
|
|
print(f" 마디번호 감지 성공: {len(measure_groups)}개 고유 마디")
|
|
print(f" OCR 실패 프레임: {len(no_ocr_frames)}개")
|
|
|
|
if not measure_groups:
|
|
print("❌ 마디번호 하나도 감지 못함 → OCR 방식 불가")
|
|
sys.exit(1)
|
|
|
|
# 마디번호 분포 출력
|
|
nums = sorted(measure_groups.keys())
|
|
print(f" 감지된 마디번호 범위: {nums[0]} ~ {nums[-1]}")
|
|
print(f" 감지된 마디번호: {nums}")
|
|
|
|
# 연속성 체크: 빠진 마디번호
|
|
expected = set(range(nums[0], nums[-1]+1))
|
|
missing = expected - set(nums)
|
|
if missing:
|
|
print(f" ⚠ 누락된 마디번호: {sorted(missing)} ({len(missing)}개) — OCR 실패로 인한 것")
|
|
else:
|
|
print(f" ✅ 마디번호 연속성 완전 (누락 없음)")
|
|
|
|
# 중복 프레임 수
|
|
total_dup = sum(len(v)-1 for v in measure_groups.values() if len(v) > 1)
|
|
print(f" 제거될 중복 프레임 수: {total_dup}개")
|
|
|
|
# ── [C] 최고선명도 프레임 선택 ───────────────────────────────────────
|
|
print(f"\n[SIM-C] 각 마디별 최고선명도 프레임 선택...")
|
|
best_frames = {}
|
|
for num in sorted(measure_groups.keys()):
|
|
best_frames[num] = _pick_best(measure_groups[num])
|
|
|
|
# ── [D] 정렬 + [E] 파노라마 조립 시뮬레이션 ─────────────────────────
|
|
print(f"\n[SIM-D/E] 마디번호 순 정렬 + 파노라마 조립...")
|
|
|
|
sorted_measures = sorted(best_frames.keys())
|
|
|
|
# 결과 이미지 저장: 마디별 크롭 저장 (검증용)
|
|
frame_widths = []
|
|
frame_heights = []
|
|
for i, num in enumerate(sorted_measures[:10]): # 처음 10개만 저장
|
|
img = best_frames[num]
|
|
save_path = OUT_DIR / f"sim_measure_{num:03d}.png"
|
|
cv2.imwrite(str(save_path), img)
|
|
frame_widths.append(img.shape[1])
|
|
frame_heights.append(img.shape[0])
|
|
|
|
# 전체 파노라마 너비 계산
|
|
chunk_w = sorted(frame_widths)[len(frame_widths)//2] if frame_widths else 1280 # 중앙값
|
|
|
|
# 파노라마 조립: 연속된 마디 이어붙이기
|
|
all_frames_sorted = [best_frames[n] for n in sorted_measures]
|
|
target_h = int(np.median([f.shape[0] for f in all_frames_sorted]))
|
|
|
|
print(f" 기준 높이: {target_h}px, 기준 폭: {chunk_w}px")
|
|
|
|
# 행 단위 조립 (chunk_width 초과 시 새 행)
|
|
rows = []
|
|
current_row_imgs = []
|
|
current_w = 0
|
|
for img in all_frames_sorted:
|
|
# 높이 정규화
|
|
if img.shape[0] != target_h:
|
|
img = cv2.resize(img, (int(img.shape[1] * target_h / img.shape[0]), target_h))
|
|
if current_w + img.shape[1] > chunk_w and current_row_imgs:
|
|
row = np.hstack(current_row_imgs)
|
|
rows.append(row)
|
|
current_row_imgs = [img]
|
|
current_w = img.shape[1]
|
|
else:
|
|
current_row_imgs.append(img)
|
|
current_w += img.shape[1]
|
|
if current_row_imgs:
|
|
rows.append(np.hstack(current_row_imgs))
|
|
|
|
print(f" → 총 {len(rows)}개 행 생성 (각 행 폭 ≤ {chunk_w}px)")
|
|
|
|
# 결과 이미지 저장
|
|
for i, row in enumerate(rows):
|
|
save_path = OUT_DIR / f"sim_row_{i:03d}.png"
|
|
cv2.imwrite(str(save_path), row)
|
|
|
|
# ── 최종 리포트 ─────────────────────────────────────────────────────
|
|
print(f"\n{'='*60}")
|
|
print("[SIM 결과 리포트]")
|
|
print(f" 입력 프레임: {len(paths)}개")
|
|
print(f" OCR 성공: {len(sorted_measures)}개 고유 마디")
|
|
print(f" OCR 실패: {len(no_ocr_frames)}개 ({len(no_ocr_frames)/len(paths)*100:.1f}%)")
|
|
print(f" 제거된 중복 프레임: {total_dup}개")
|
|
print(f" 누락 마디번호: {sorted(missing) if missing else '없음'}")
|
|
print(f" 출력 행 수: {len(rows)}개")
|
|
print(f" 결과 저장: {OUT_DIR}")
|
|
print(f"{'='*60}")
|
|
|
|
# ── 핵심 판정 ────────────────────────────────────────────────────────
|
|
ocr_rate = len(sorted_measures) / len(paths) * 100
|
|
print(f"\n[판정]")
|
|
if ocr_rate >= 40:
|
|
print(f" ✅ OCR-First 방식 유효 (인식률 {ocr_rate:.1f}%)")
|
|
print(f" → 누락 마디는 인접 마디 프레임으로 보완 가능")
|
|
elif ocr_rate >= 20:
|
|
print(f" ⚠ OCR 인식률 낮음 ({ocr_rate:.1f}%) → 파라미터 조정 필요")
|
|
print(f" → 업스케일 배율 증가, 신뢰도 임계치 하향 검토")
|
|
else:
|
|
print(f" ❌ OCR 인식률 너무 낮음 ({ocr_rate:.1f}%) → 이 영상에서 OCR 방식 불가")
|
|
print(f" → 마디번호가 없는 영상이거나, 크롭 영역 조정 필요")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|