파이썬(Python), PIL/Pillow로 고급 이미지 썸네일 생성기 구현하기

728x90
  1. 고급 썸네일 생성 기능:
    • 다양한 이미지 효과 적용 (그레이스케일, 세피아, 블러, 엣지 강화 등)
    • 워터마크 추가 기능
    • EXIF 메타데이터 처리 (이미지 회전 정보 적용)
    • 품질 및 포맷 옵션 제어
    • 알파 채널이 있는 이미지 처리
  2. 병렬 처리:
    • concurrent.futures 모듈을 사용한 멀티스레딩 처리
    • 대량의 이미지를 빠르게 처리 가능
  3. 컨택트 시트(모아보기) 생성:
    • 여러 썸네일을 하나의 이미지로 모아서 보기 쉽게 정렬
    • 사용자 지정 제목, 열 수, 배경색 등 설정 가능
  4. 명령줄 인터페이스:
    • argparse 모듈을 사용한 유연한 명령줄 인터페이스
    • 다양한 옵션을 통해 사용자가 원하는 설정 가능
  5. 로깅 시스템:
    • 파일 및 콘솔에 로그 기록
    • 처리 정보, 경고, 오류 등 세분화된 로깅
  6. 견고한 오류 처리:
    • 개별 이미지 처리 실패 시 전체 프로세스 중단되지 않음
    • 상세한 오류 메시지 기록

이러한 기능을 통해 단순한 썸네일 생성을 넘어 다양한 이미지 처리 작업을 효율적으로 수행 가능

 

다음과 같은 용도로 사용 가능

  • 웹사이트 갤러리용 썸네일 일괄 생성
  • 사진 모음 미리보기 생성
  • 이미지 카탈로그 제작
  • 이미지 아카이브 정리 및 관리

명령줄 인터페이스를 통해 사용자는 필요에 따라 다양한 옵션을 지정할 수 있으며,

특히 대량의 이미지를 처리할 때 병렬 처리를 통해 시간을 절약

 

 

from PIL import Image, ImageFilter, ImageEnhance, ImageDraw, ImageFont
import os
import mimetypes
import argparse
from datetime import datetime
import concurrent.futures
import logging

# 로깅 설정
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    handlers=[logging.FileHandler("thumbnail_generator.log"), logging.StreamHandler()]
)
logger = logging.getLogger(__name__)

def create_thumbnail(image_path, output_dir="thumbnails", size=(128, 128), 
                     format=None, quality=85, prefix="thumb_", 
                     watermark=None, effects=None):
    """
    이미지 파일을 읽어 다양한 효과와 옵션으로 썸네일을 생성하는 함수
    
    Args:
        image_path (str): 이미지 파일 경로
        output_dir (str): 출력 디렉토리 (기본값: "thumbnails")
        size (tuple): 썸네일 크기 (기본값: 128x128)
        format (str): 출력 이미지 형식 (기본값: 원본과 동일)
        quality (int): JPEG 품질 (1-100, 기본값: 85)
        prefix (str): 출력 파일명 접두사 (기본값: "thumb_")
        watermark (str): 워터마크 텍스트 (기본값: None)
        effects (list): 적용할 효과 목록 (기본값: None)
    
    Returns:
        str: 생성된 썸네일 경로
    """
    try:
        # 이미지 파일 열기
        img = Image.open(image_path)
        original_format = img.format
        
        # 이미지 정보 출력
        logger.info(f"원본 이미지: {image_path} ({img.width}x{img.height}, {original_format})")
        
        # EXIF 회전 정보가 있으면 적용
        if hasattr(img, '_getexif') and img._getexif():
            from PIL import ExifTags
            exif = {ExifTags.TAGS[k]: v for k, v in img._getexif().items() if k in ExifTags.TAGS}
            if 'Orientation' in exif:
                if exif['Orientation'] == 3:
                    img = img.rotate(180, expand=True)
                elif exif['Orientation'] == 6:
                    img = img.rotate(270, expand=True)
                elif exif['Orientation'] == 8:
                    img = img.rotate(90, expand=True)
        
        # 알파 채널이 있으면 배경 추가
        if img.mode == 'RGBA':
            background = Image.new('RGB', img.size, (255, 255, 255))
            background.paste(img, mask=img.split()[3])  # 알파 채널을 마스크로 사용
            img = background
        
        # 썸네일 생성 (비율 유지)
        img.thumbnail(size, Image.Resampling.LANCZOS)
        
        # 효과 적용
        if effects:
            if 'grayscale' in effects:
                img = img.convert('L').convert('RGB')
            if 'sepia' in effects:
                sepia_palette = []
                r, g, b = (255, 240, 192)
                for i in range(255):
                    sepia_palette.extend((r*i//255, g*i//255, b*i//255))
                img = img.convert('L')
                img.putpalette(sepia_palette)
                img = img.convert('RGB')
            if 'blur' in effects:
                img = img.filter(ImageFilter.GaussianBlur(radius=1))
            if 'edge_enhance' in effects:
                img = img.filter(ImageFilter.EDGE_ENHANCE)
            if 'sharpen' in effects:
                img = img.filter(ImageFilter.SHARPEN)
            if 'contrast' in effects:
                enhancer = ImageEnhance.Contrast(img)
                img = enhancer.enhance(1.5)
            if 'brightness' in effects:
                enhancer = ImageEnhance.Brightness(img)
                img = enhancer.enhance(1.2)
        
        # 워터마크 추가
        if watermark:
            draw = ImageDraw.Draw(img)
            try:
                font = ImageFont.truetype("arial.ttf", 12)
            except IOError:
                font = ImageFont.load_default()
            
            text_width, text_height = draw.textsize(watermark, font=font)
            position = (img.width - text_width - 10, img.height - text_height - 10)
            
            # 텍스트 그림자 효과
            draw.text((position[0]+1, position[1]+1), watermark, font=font, fill=(0, 0, 0, 128))
            draw.text(position, watermark, font=font, fill=(255, 255, 255, 255))
        
        # 출력 디렉토리 확인
        if not os.path.exists(output_dir):
            os.makedirs(output_dir)
        
        # 저장할 파일 이름 및 경로 생성
        basename = os.path.basename(image_path)
        name, ext = os.path.splitext(basename)
        
        # 출력 형식 결정
        if format:
            output_ext = f".{format.lower()}"
        else:
            output_ext = ext
        
        output_filename = f"{prefix}{name}{output_ext}"
        output_path = os.path.join(output_dir, output_filename)
        
        # 썸네일 저장
        save_options = {}
        if output_ext.lower() in ['.jpg', '.jpeg']:
            save_options['quality'] = quality
            save_options['optimize'] = True
        elif output_ext.lower() == '.png':
            save_options['optimize'] = True
        
        img.save(output_path, **save_options)
        logger.info(f"썸네일 저장 성공: {output_path} ({img.width}x{img.height})")
        
        return output_path
    
    except Exception as e:
        logger.error(f"썸네일 처리 오류 ({image_path}): {str(e)}")
        return None

def process_directory(input_dir, output_dir="thumbnails", size=(128, 128), 
                      format=None, quality=85, prefix="thumb_", 
                      recursive=False, watermark=None, effects=None, max_workers=4):
    """
    디렉토리 내의 모든 이미지를 처리하여 썸네일 생성
    
    Args:
        input_dir (str): 입력 디렉토리
        output_dir (str): 출력 디렉토리
        size (tuple): 썸네일 크기
        format (str): 출력 형식
        quality (int): JPEG 품질
        prefix (str): 출력 파일명 접두사
        recursive (bool): 하위 디렉토리 처리 여부
        watermark (str): 워터마크 텍스트
        effects (list): 적용할 효과 목록
        max_workers (int): 최대 병렬 처리 워커 수
    
    Returns:
        int: 처리된 이미지 수
    """
    if not os.path.exists(input_dir):
        logger.error(f"입력 디렉토리가 존재하지 않습니다: {input_dir}")
        return 0
    
    if not os.path.exists(output_dir):
        os.makedirs(output_dir)
    
    # 이미지 파일 수집
    image_files = []
    
    if recursive:
        for root, dirs, files in os.walk(input_dir):
            for file in files:
                file_path = os.path.join(root, file)
                if is_image_file(file_path):
                    image_files.append(file_path)
    else:
        for file in os.listdir(input_dir):
            file_path = os.path.join(input_dir, file)
            if os.path.isfile(file_path) and is_image_file(file_path):
                image_files.append(file_path)
    
    logger.info(f"총 {len(image_files)}개 이미지 파일 발견")
    
    # 병렬 처리
    successful = 0
    with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
        futures = {}
        for image_path in image_files:
            future = executor.submit(
                create_thumbnail, 
                image_path, 
                output_dir, 
                size, 
                format, 
                quality, 
                prefix,
                watermark,
                effects
            )
            futures[future] = image_path
        
        for future in concurrent.futures.as_completed(futures):
            image_path = futures[future]
            try:
                result = future.result()
                if result:
                    successful += 1
            except Exception as e:
                logger.error(f"이미지 처리 실패 ({image_path}): {str(e)}")
    
    logger.info(f"총 {successful}/{len(image_files)} 이미지 처리 완료")
    return successful

def is_image_file(file_path):
    """파일이 이미지인지 확인"""
    mime_type = mimetypes.guess_type(file_path)[0]
    return mime_type and mime_type.startswith('image')

def create_contact_sheet(image_dir, output_path, columns=5, size=(150, 150), 
                        padding=10, background_color=(255, 255, 255), title=None):
    """
    여러 이미지로 구성된 컨택트 시트(모아보기) 생성
    
    Args:
        image_dir (str): 이미지 디렉토리
        output_path (str): 출력 파일 경로
        columns (int): 열 수
        size (tuple): 각 이미지 크기
        padding (int): 이미지 간 패딩
        background_color (tuple): 배경색 (RGB)
        title (str): 상단 제목
    """
    try:
        # 이미지 파일 목록 가져오기
        image_files = []
        for file in os.listdir(image_dir):
            file_path = os.path.join(image_dir, file)
            if os.path.isfile(file_path) and is_image_file(file_path):
                image_files.append(file_path)
        
        if not image_files:
            logger.error(f"이미지 파일이 없습니다: {image_dir}")
            return False
        
        # 썸네일 생성
        thumbnails = []
        for img_path in image_files:
            try:
                img = Image.open(img_path)
                img.thumbnail(size, Image.Resampling.LANCZOS)
                
                # 모든 이미지를 동일한 크기로 맞추기 (빈 공간은 배경색으로 채움)
                thumb = Image.new('RGB', size, background_color)
                offset = ((size[0] - img.width) // 2, (size[1] - img.height) // 2)
                thumb.paste(img, offset)
                thumbnails.append(thumb)
            except Exception as e:
                logger.warning(f"이미지 처리 실패 ({img_path}): {str(e)}")
        
        # 행 수 계산
        rows = (len(thumbnails) + columns - 1) // columns
        
        # 제목 공간 추가
        title_height = 40 if title else 0
        
        # 컨택트 시트 생성
        sheet_width = columns * (size[0] + padding) + padding
        sheet_height = rows * (size[1] + padding) + padding + title_height
        contact_sheet = Image.new('RGB', (sheet_width, sheet_height), background_color)
        
        # 제목 추가
        if title:
            draw = ImageDraw.Draw(contact_sheet)
            try:
                font = ImageFont.truetype("arial.ttf", 20)
            except IOError:
                font = ImageFont.load_default()
            
            text_width, text_height = draw.textsize(title, font=font)
            position = ((sheet_width - text_width) // 2, padding)
            draw.text(position, title, font=font, fill=(0, 0, 0))
        
        # 썸네일 배치
        for idx, thumb in enumerate(thumbnails):
            row = idx // columns
            col = idx % columns
            x = padding + col * (size[0] + padding)
            y = padding + row * (size[1] + padding) + title_height
            contact_sheet.paste(thumb, (x, y))
        
        # 저장
        contact_sheet.save(output_path, quality=90, optimize=True)
        logger.info(f"컨택트 시트 생성 완료: {output_path}")
        return True
    
    except Exception as e:
        logger.error(f"컨택트 시트 생성 오류: {str(e)}")
        return False

def main():
    """메인 함수: 명령줄 인자를 처리하고 썸네일 생성 작업 수행"""
    parser = argparse.ArgumentParser(description='이미지 썸네일 생성기')
    parser.add_argument('input', help='입력 이미지 또는 디렉토리')
    parser.add_argument('--output', '-o', default='thumbnails', help='출력 디렉토리')
    parser.add_argument('--size', '-s', default='128x128', help='썸네일 크기 (예: 128x128)')
    parser.add_argument('--format', '-f', help='출력 형식 (jpg, png 등)')
    parser.add_argument('--quality', '-q', type=int, default=85, help='JPEG 품질 (1-100)')
    parser.add_argument('--prefix', '-p', default='thumb_', help='출력 파일명 접두사')
    parser.add_argument('--recursive', '-r', action='store_true', help='하위 디렉토리 처리')
    parser.add_argument('--watermark', '-w', help='워터마크 텍스트')
    parser.add_argument('--effects', '-e', nargs='+', choices=[
        'grayscale', 'sepia', 'blur', 'edge_enhance', 'sharpen', 'contrast', 'brightness'
    ], help='적용할 효과')
    parser.add_argument('--contact-sheet', '-c', help='컨택트 시트 생성 경로')
    parser.add_argument('--columns', type=int, default=5, help='컨택트 시트 열 수')
    
    args = parser.parse_args()
    
    # 크기 파싱
    try:
        width, height = map(int, args.size.split('x'))
        size = (width, height)
    except:
        logger.error(f"잘못된 크기 형식: {args.size} (예: 128x128)")
        return
    
    # 처리 시작
    start_time = datetime.now()
    logger.info(f"처리 시작: {start_time}")
    
    if os.path.isfile(args.input) and is_image_file(args.input):
        # 단일 이미지 처리
        create_thumbnail(
            args.input, 
            args.output, 
            size, 
            args.format, 
            args.quality, 
            args.prefix,
            args.watermark,
            args.effects
        )
    elif os.path.isdir(args.input):
        # 디렉토리 처리
        process_directory(
            args.input, 
            args.output, 
            size, 
            args.format, 
            args.quality, 
            args.prefix,
            args.recursive,
            args.watermark,
            args.effects
        )
        
        # 컨택트 시트 생성
        if args.contact_sheet:
            create_contact_sheet(
                args.output,
                args.contact_sheet,
                args.columns,
                size,
                title=f"썸네일 생성: {datetime.now().strftime('%Y-%m-%d %H:%M')}"
            )
    else:
        logger.error(f"유효하지 않은 입력: {args.input}")
    
    end_time = datetime.now()
    elapsed = (end_time - start_time).total_seconds()
    logger.info(f"처리 완료: {end_time} (소요 시간: {elapsed:.2f}초)")

if __name__ == "__main__":
    # mimetypes 초기화
    mimetypes.init()
    main()
728x90