IMG_Worker/modules/postImageManager.py

265 lines
14 KiB
Python

# import base64
# import pyperclip
# import win32clipboard
from io import BytesIO
from PIL import Image, ImageGrab, ImageFont, ImageDraw
import requests
import numpy as np
import cv2
import time
import os, sys
from datetime import datetime
import random
import io
import logging
class PostImageManager:
def __init__(self, logger, toggle_states):
self.logger = logger
self.toggle_states = toggle_states
self.font = None
# 프로그램이 위치한 경로 기준으로 폰트 경로 설정
self.base_path = self.toggle_states.get('base_dir', "")
self.font_path = self.toggle_states.get('image_font_path', os.path.join(self.base_path, "HakgyoansimDunggeunmisoTTFB.ttf"))
self.watermark_font_size = 36
# 폰트 로드
self.font_load()
def update_toggle_states(self, toggle_states1):
self.toggle_states = toggle_states1
self.base_path = self.toggle_states.get('base_dir', "")
self.font_path = self.toggle_states.get('image_font_path', os.path.join(self.base_path, "HakgyoansimDunggeunmisoTTFB.ttf"))
self.watermark_font_size = 36
def font_load(self):
# 폰트 로드
try:
self.font = ImageFont.truetype(self.font_path, self.watermark_font_size)
self.logger.log(f"폰트 로드 성공: {self.font_path}", level=logging.DEBUG)
except Exception as e:
self.logger.log(f"커스텀 폰트 로드 실패 ({self.font_path}): {e}", level=logging.WARNING)
try:
# 기본 폰트 사용
self.font = ImageFont.load_default()
self.logger.log("기본 폰트를 사용합니다.", level=logging.INFO)
except Exception as e2:
self.logger.log(f"기본 폰트 로드도 실패: {e2}", level=logging.ERROR)
# 최후의 수단으로 None 설정
self.font = None
def save_image_to_path(self, image, path):
try:
# None 체크 추가
if image is None:
self.logger.log(f"이미지가 None입니다. 저장을 건너뜁니다: {path}", level=logging.WARNING)
return None
# 타입별 처리
if isinstance(image, np.ndarray): # 이미 np.ndarray(BGR)라면
if image.size == 0: # 빈 배열 체크
self.logger.log(f"빈 배열입니다. 저장을 건너뜁니다: {path}", level=logging.WARNING)
return None
cv2.imwrite(path, image) # 바로 저장
elif isinstance(image, Image.Image): # PIL 객체
import os
file_ext = os.path.splitext(path)[1].lower()
# RGBA 이미지를 JPEG로 저장할 때 에러 방지
if image.mode == 'RGBA' and file_ext in ['.jpg', '.jpeg']:
# RGBA를 RGB로 변환 (흰색 배경 사용)
rgb_image = Image.new('RGB', image.size, (255, 255, 255))
rgb_image.paste(image, mask=image.split()[-1]) # 알파 채널을 마스크로 사용
image = rgb_image
self.logger.log(f"RGBA 이미지를 RGB로 변환하여 JPEG 저장", level=logging.INFO)
# 확장자에 따른 형식 결정 (품질 우선 순위: WebP > PNG > JPEG)
if file_ext == '.webp':
# WebP: 최고의 압축률과 품질
image.save(path, format='WebP', quality=95, method=6)
self.logger.log(f"WebP 형식으로 저장 (품질: 95, 최적 압축)", level=logging.DEBUG)
elif file_ext == '.png':
# PNG: 무손실, 투명도 지원
image.save(path, format='PNG', optimize=True)
self.logger.log(f"PNG 형식으로 저장 (무손실, 최적화됨)", level=logging.DEBUG)
elif file_ext in ['.jpg', '.jpeg']:
# JPEG: 호환성 위주 (품질 높게 설정)
image.save(path, format='JPEG', quality=95, optimize=True)
self.logger.log(f"JPEG 형식으로 저장 (품질: 95)", level=logging.DEBUG)
else:
# 기본값: PNG 형식 (가장 안전하고 품질 좋음)
image.save(path, format='PNG', optimize=True)
self.logger.log(f"기본 PNG 형식으로 저장", level=logging.DEBUG)
else:
# 예상하지 못한 타입의 경우 로그에 타입 정보 추가
actual_type = type(image).__name__
self.logger.log(f"지원하지 않는 이미지 타입: {actual_type}, 값: {image}", level=logging.ERROR)
raise TypeError(f"지원하지 않는 형식: {actual_type}")
self.logger.log(f"이미지 저장 완료 : {path}", level=logging.INFO)
return path
except Exception as e:
raise RuntimeError(f"이미지 저장 중 오류 발생: {e}")
def add_watermark(self, image_data, watermark_text="Watermark", opacity_percent=30, angle=30, font_size=36):
"""
이미지에 텍스트 워터마크를 이미지 전체에 걸쳐서 추가하는 함수
:param image_data: PIL 이미지 객체
:param watermark_text: 워터마크로 추가할 텍스트
:param opacity_percent: 워터마크의 투명도 (0~100)
:param angle: 워터마크 텍스트 회전 각도 (기본 30도)
:param font_size: 워터마크 텍스트의 폰트 크기 (기본 36)
:return: 워터마크가 추가된 이미지
"""
try:
# --- (1) 입력 형식 통일: RGB np.ndarray 로 변환 --------------------
if isinstance(image_data, np.ndarray):
img_rgb = cv2.cvtColor(image_data, cv2.COLOR_BGR2RGB)
elif isinstance(image_data, Image.Image):
image_data.load() # ⬅️ JPEG 완전 디코딩
img_rgb = np.array(image_data.convert("RGB"))
else:
raise TypeError("지원하지 않는 이미지 타입")
# --- (2) 텍스트 작업은 Pillow(RGBA) 로 수행 ------------------------
watermark_image = Image.fromarray(img_rgb).convert("RGBA")
# 폰트가 로드되지 않은 경우 원본 이미지 반환
if self.font is None:
self.logger.log("폰트가 로드되지 않아 워터마크를 추가할 수 없습니다. 원본 이미지를 반환합니다.", level=logging.WARNING)
return image_data
# 텍스트 투명도를 0~255로 변환
opacity = int(255 * (opacity_percent / 100))
# 텍스트 크기 측정 (textbbox 사용)
draw = ImageDraw.Draw(watermark_image)
bbox = draw.textbbox((0, 0), watermark_text, font=self.font)
text_width, text_height = bbox[2] - bbox[0], bbox[3] - bbox[1]
# 텍스트 크기가 0인 경우 예외 처리 (빈 문자열 등)
if text_width == 0 or text_height == 0:
self.logger.log("워터마크 텍스트 크기를 계산할 수 없어 워터마크를 건너뜁니다.", level=logging.WARNING)
return image_data
# 이미지 크기
width, height = watermark_image.size
# 지그재그 간격 설정 (0 방지를 위해 최소 1 보장)
zigzag_step = max(1, int(text_height * 2)) # Y축의 지그재그 간격
step_x = max(1, int(text_width * 3)) # X축 반복 간격도 0 방지
# 이미지 전체에 반복적으로 워터마크 텍스트 그리기 (지그재그 형태)
for y in range(0, height, zigzag_step):
for x in range(0, width, step_x): # 3배 너비 간격으로 반복
# 텍스트가 한 줄씩 지그재그 형태로 X축을 교차하여 이동
x_offset = (y // zigzag_step) % 2 * int(text_width * 1.5) # 짝수 행에서는 X축을 약간 이동
# 텍스트 레이어 생성
text_layer = Image.new("RGBA", (text_width, text_height), (255, 255, 255, 0))
text_draw = ImageDraw.Draw(text_layer)
# 텍스트 그리기
text_draw.text((0, 0), watermark_text, fill=(255, 255, 255, opacity), font=self.font)
# 텍스트 회전
rotated_text_layer = text_layer.rotate(angle, expand=1)
# 회전된 텍스트를 원본 이미지에 직접 추가
watermark_image.paste(rotated_text_layer, (x + x_offset, y), rotated_text_layer)
# --- (3) 최종 결과를 BGR ndarray 로 변환해 반환 ----------------------
result_rgb = watermark_image.convert("RGB")
result_bgr = cv2.cvtColor(np.array(result_rgb), cv2.COLOR_RGB2BGR)
return result_bgr
except Exception as e:
self.logger.log(f"워터마크 추가 중 오류 발생: {e}", level=logging.ERROR, exc_info=True)
return image_data
def download_image_from_url(self, url, max_retries=3):
"""URL에서 이미지를 다운로드하고 PIL 이미지 객체로 반환"""
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"DNT": "1", # Do Not Track 요청 헤더
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
"Cache-Control": "max-age=0"
}
retries = 0
while retries < max_retries:
try:
self.logger.log(f"이미지 URL 다운로드 중: {url}", level=logging.DEBUG)
response = requests.get(url, headers=headers, stream=True)
# 상태 코드가 200이 아니면 재시도
if response.status_code == 200:
# OpenCV로 이미지를 로드하여 변환
image = np.asarray(bytearray(response.content), dtype="uint8")
image = cv2.imdecode(image, cv2.IMREAD_COLOR)
# OpenCV에서 이미지를 PIL로 변환
if image is not None:
pil_image = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
return pil_image
else:
self.logger.log(f"이미지 파일 형식이 올바르지 않습니다. 대상 URL: {url}", level=logging.DEBUG)
return None
else:
self.logger.log(f"이미지 로딩 실패, HTTP 상태 코드: {response.status_code}. 재시도 {retries + 1}/{max_retries}", level=logging.DEBUG)
retries += 1
# await asyncio.sleep(random.randint(2, 5)) # 2~5초 대기 후 재시도
time.sleep(random.randint(2, 5))
except Exception as e:
self.logger.log(f"이미지 로딩 중 오류 발생: {e}. 재시도 {retries + 1}/{max_retries}", level=logging.DEBUG)
retries += 1
# await asyncio.sleep(random.randint(2, 5)) # 예외 발생 시 대기 후 재시도
time.sleep(random.randint(2, 5))
self.logger.log("이미지 다운로드 최대 재시도 횟수를 초과했습니다.", level=logging.DEBUG)
return None
def crop_image(self, image, is_thumb=False, crop_percentage=0.01):
"""이미지를 주어진 퍼센트만큼 크롭하는 함수"""
if is_thumb:
crop_percentage = 0.03
self.logger.log(f"썸네일 이미지 이므로 크롭 3%로 조정", level=logging.DEBUG)
width, height = image.size
left = width * crop_percentage
top = height * crop_percentage
right = width * (1 - crop_percentage)
bottom = height * (1 - crop_percentage)
cropped_image = image.crop((left, top, right, bottom))
if self.debug:
# 디버그 모드일 경우 크롭 전후 다양한 비율로 이미지 저장
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
original_image_path = os.path.join(os.getcwd(), f"original_image_{timestamp}.png")
image.save(original_image_path) # 크롭 전 이미지 저장
self.logger.log(f"크롭 전 이미지 저장됨: {original_image_path}", level=logging.DEBUG)
# 1%, 2%, 3% 크롭 이미지 저장
crop_alternatives = [0.01, 0.02, 0.03]
for crop in crop_alternatives:
left_alt = width * crop
top_alt = height * crop
right_alt = width * (1 - crop)
bottom_alt = height * (1 - crop)
cropped_alt_image = image.crop((left_alt, top_alt, right_alt, bottom_alt))
cropped_image_path = os.path.join(os.getcwd(), f"cropped_image_{int(crop*100)}_{timestamp}.png")
cropped_alt_image.save(cropped_image_path)
self.logger.log(f"{int(crop*100)}% 크롭된 이미지 저장됨: {cropped_image_path}", level=logging.DEBUG)
return cropped_image