""" core/preprocess.py 이미지 전처리 계층. 역할: - 파일 유효성 검사 (경로, 확장자, 읽기 가능 여부) - OpenCV 기반 이미지 품질 개선 (Audiveris OMR 정확도 향상 목적) 전처리 파이프라인 (apply_preprocessing): 1. 이미지 로드 및 검증 (cv2.imread) 2. Grayscale 변환 3. 가벼운 노이즈 제거 (GaussianBlur 3x3) 4. 이진화 (Otsu 또는 Adaptive Threshold) 5. [선택적] Deskew — 기울기 보정 (실험적, deskew_enabled=True 시) 주의: - apply_preprocessing은 opencv-python 필요 (pip install opencv-python) - opencv 미설치 시 PreprocessError 발생 → pipeline에서 fallback 처리 - mock 모드에서는 pipeline이 이 함수를 호출하지 않음 한계: - deskew는 ±10도 범위에서만 동작. 과도하게 기울어진 이미지는 보정 불가 - 손글씨, 그림자, 저해상도 이미지는 이진화 후 오히려 품질 저하 가능 - 연필 필기 악보는 Adaptive Threshold가 더 적합할 수 있음 """ from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: from .models import ConvertOptions SUPPORTED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".tiff", ".tif", ".bmp", ".pdf"} class PreprocessError(Exception): """전처리 단계 오류.""" pass # --------------------------------------------------------------------------- # 파일 검증 (opencv 불필요, 항상 동작) # --------------------------------------------------------------------------- def validate_image_path(input_path: str) -> Path: """ 입력 파일 경로를 검증하고 Path 객체를 반환. Raises: PreprocessError: 파일이 없거나 지원되지 않는 형식인 경우 """ path = Path(input_path) if not path.exists(): raise PreprocessError(f"파일을 찾을 수 없습니다: {input_path}") if not path.is_file(): raise PreprocessError(f"파일이 아닙니다: {input_path}") ext = path.suffix.lower() if ext not in SUPPORTED_EXTENSIONS: raise PreprocessError( f"지원하지 않는 파일 형식: {ext}. " f"지원 형식: {', '.join(sorted(SUPPORTED_EXTENSIONS))}" ) return path def get_file_info(path: Path) -> dict: """파일 기본 정보를 반환.""" stat = path.stat() return { "filename": path.name, "extension": path.suffix.lower(), "size_bytes": stat.st_size, "absolute_path": str(path.resolve()), } def preprocess_image(input_path: str) -> dict: """ 파일 유효성 검사만 수행. 전처리 없이 원본 경로를 반환. pipeline에서 먼저 호출하여 파일을 검증한 뒤, Audiveris 모드라면 apply_preprocessing()을 추가 호출한다. """ path = validate_image_path(input_path) info = get_file_info(path) info["preprocessed_path"] = str(path.resolve()) info["preprocessing_applied"] = [] return info # --------------------------------------------------------------------------- # OpenCV 전처리 (opencv-python 필요) # --------------------------------------------------------------------------- def apply_preprocessing( input_path: str, output_path: str, options: ConvertOptions, debug_dir: str = "", ) -> dict: """ OpenCV 기반 이미지 전처리를 적용하고 결과를 output_path에 저장. Args: input_path: 원본 이미지 경로 output_path: 전처리 결과 저장 경로 (.png 권장) options: ConvertOptions (binarize_method, deskew_enabled 사용) Returns: dict: 전처리 결과 정보 - applied: True - steps: 적용된 단계 목록 - input_size: (w, h) - output_path: 결과 파일 경로 Raises: PreprocessError: opencv 미설치, 이미지 읽기 실패, 저장 실패 시 """ try: import cv2 import numpy as np except ImportError: raise PreprocessError( "opencv-python이 설치되지 않았습니다.\n" "설치 명령: pip install opencv-python" ) img = cv2.imread(input_path, cv2.IMREAD_COLOR) if img is None: raise PreprocessError( f"이미지를 읽을 수 없습니다: {input_path}\n" f"파일이 손상되었거나 지원하지 않는 형식일 수 있습니다." ) h, w = img.shape[:2] applied: list[str] = [] def _save_step(name: str, img_data) -> None: if debug_dir: cv2.imwrite(str(Path(debug_dir) / name), img_data) # 1. Grayscale gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) applied.append("grayscale") _save_step("step_01_grayscale.png", gray) # 2. 노이즈 제거 (가벼운 Gaussian blur, 선택적) if options.blur_enabled: denoised = cv2.GaussianBlur(gray, (3, 3), 0) applied.append("gaussian_blur_3x3") _save_step("step_02_gaussian_blur.png", denoised) else: denoised = gray # 3. 이진화 (선택적 — 기본 off, Audiveris 자체 이진화 신뢰) processed = denoised if options.binarize_enabled: processed = _binarize(denoised, options.binarize_method) applied.append(f"binarize:{options.binarize_method}") _save_step(f"step_03_binarized_{options.binarize_method}.png", processed) # 4. Deskew (선택적) if options.deskew_enabled: processed, angle = _deskew(processed) if abs(angle) > 0.01: applied.append(f"deskew:{angle:.2f}deg") _save_step(f"step_04_deskew_{angle:.2f}deg.png", processed) else: applied.append("deskew:skipped(angle<0.5)") # 저장 ok = cv2.imwrite(output_path, processed) if not ok: raise PreprocessError( f"전처리 이미지 저장 실패: {output_path}\n" f"출력 디렉토리가 존재하는지 확인하세요." ) return { "applied": True, "steps": applied, "input_path": input_path, "output_path": output_path, "input_size": (w, h), } def _binarize(gray_img, method: str): """ Grayscale 이미지를 이진화. method: "otsu" : 전역 Otsu 임계값. 명암 대비가 분명한 스캔 악보에 적합. "adaptive" : 지역 Adaptive Threshold. 조명 불균일 / 연필 필기 악보에 적합. """ try: import cv2 except ImportError: raise PreprocessError("opencv-python이 필요합니다.") if method == "adaptive": return cv2.adaptiveThreshold( gray_img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, blockSize=15, C=8, ) # default: otsu _, binary = cv2.threshold( gray_img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU, ) return binary def _deskew(binary_img): """ 이진화된 이미지의 기울기를 보정. Returns: (corrected_img, angle_degrees) 한계: - ±10도 이내의 기울기만 보정. 범위 초과 시 원본 반환. - 악보 전체가 기울어진 경우에만 유효. 개별 보표 기울기는 미처리. - 어두운 픽셀이 너무 적으면 각도 추정 불가 → 원본 반환. """ try: import cv2 import numpy as np except ImportError: return binary_img, 0.0 # 어두운 픽셀 좌표 추출 (이진화된 이미지: 악보 선/음표 = 0) dark_coords = np.column_stack(np.where(binary_img < 128)) if len(dark_coords) < 200: return binary_img, 0.0 # (row, col) → (x=col, y=row) 변환 후 minAreaRect points = dark_coords[:, ::-1].astype(np.float32) rect = cv2.minAreaRect(points) angle = rect[2] # range: (-90, 0] # (-90, -45] → 세로 방향 박스 → +90 보정 if angle < -45: angle = 90.0 + angle # 이제 angle ∈ (-45, 45) # 너무 크면 보정 불가 (옆으로 찍힌 이미지 등) if abs(angle) > 10.0: return binary_img, 0.0 # 너무 작으면 의미 없음 if abs(angle) < 0.5: return binary_img, 0.0 h, w = binary_img.shape center = (w // 2, h // 2) M = cv2.getRotationMatrix2D(center, angle, 1.0) corrected = cv2.warpAffine( binary_img, M, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE, ) return corrected, angle