728x90
- 고급 썸네일 생성 기능:
- 다양한 이미지 효과 적용 (그레이스케일, 세피아, 블러, 엣지 강화 등)
- 워터마크 추가 기능
- EXIF 메타데이터 처리 (이미지 회전 정보 적용)
- 품질 및 포맷 옵션 제어
- 알파 채널이 있는 이미지 처리
- 병렬 처리:
- concurrent.futures 모듈을 사용한 멀티스레딩 처리
- 대량의 이미지를 빠르게 처리 가능
- 컨택트 시트(모아보기) 생성:
- 여러 썸네일을 하나의 이미지로 모아서 보기 쉽게 정렬
- 사용자 지정 제목, 열 수, 배경색 등 설정 가능
- 명령줄 인터페이스:
- argparse 모듈을 사용한 유연한 명령줄 인터페이스
- 다양한 옵션을 통해 사용자가 원하는 설정 가능
- 로깅 시스템:
- 파일 및 콘솔에 로그 기록
- 처리 정보, 경고, 오류 등 세분화된 로깅
- 견고한 오류 처리:
- 개별 이미지 처리 실패 시 전체 프로세스 중단되지 않음
- 상세한 오류 메시지 기록
이러한 기능을 통해 단순한 썸네일 생성을 넘어 다양한 이미지 처리 작업을 효율적으로 수행 가능
다음과 같은 용도로 사용 가능
- 웹사이트 갤러리용 썸네일 일괄 생성
- 사진 모음 미리보기 생성
- 이미지 카탈로그 제작
- 이미지 아카이브 정리 및 관리
명령줄 인터페이스를 통해 사용자는 필요에 따라 다양한 옵션을 지정할 수 있으며,
특히 대량의 이미지를 처리할 때 병렬 처리를 통해 시간을 절약
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
'프로그래밍 > 파이썬' 카테고리의 다른 글
파이썬(Python), filter() 함수를 활용한 간결하고 효율적인 데이터 필터링 기법 (0) | 2025.03.15 |
---|---|
파이썬(Python), 클로저와 람다를 활용한 함수형 데이터 검증 시스템 구현하기 (0) | 2025.03.15 |
파이썬(Python), 랜덤 기능 총정리와 실용적인 활용 예제 (0) | 2025.03.15 |
파이썬(Python), 정규식으로 제어 문자와 불필요한 공백 제거하기 (0) | 2025.03.15 |
파이썬(python) dataclass 사용/활용 예제 (0) | 2025.03.15 |