265 lines
14 KiB
Python
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 |