first commit

This commit is contained in:
AGX 2025-07-17 13:24:38 +09:00
commit c18e80627f
328 changed files with 98573 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
lib/
include/
lib64/
bin/
share/
__pycache__/
*.pyc
*.pyo
*.pyd
*.pyw
*.pyz

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
FROM python:3.8-slim
# 시스템 패키지 업데이트 및 필요한 패키지 설치
RUN apt-get update && apt-get install -y \
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender-dev \
libgomp1 \
libgl1-mesa-glx \
libglib2.0-0 \
wget \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /worker
# paddlepaddle whl 파일 복사 (설치는 수동으로)
COPY paddlepaddle_gpu-2.4.2-cp38-cp38-linux_aarch64.whl .
# requirements.txt 복사 (paddlepaddle-gpu 제외된 버전)
COPY requirements_no_paddle.txt .
RUN pip install --no-cache-dir -r requirements_no_paddle.txt
# 전체 소스 복사
COPY . .
# 워커 실행 스크립트에 실행 권한 부여
RUN chmod +x worker.py
# 기본 명령어 (일단 bash로 시작해서 수동 설치 가능)
CMD ["bash"]

1187
ITServer.log Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

View File

@ -0,0 +1,764 @@
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 pywinauto
import io
import logging
class ClipboardImageManager:
def __init__(self, logger, watermark_font_size=36, debug_flag=False):
self.logger = logger
self.debug = debug_flag # 디버그 플래그를 클래스 변수로 사용
# 프로그램이 위치한 경로 기준으로 폰트 경로 설정
self.base_path = self.get_base_dir()
# 먼저 현재 모듈과 같은 디렉토리에서 폰트 파일 찾기
current_dir = os.path.dirname(os.path.abspath(__file__))
self.font_path = os.path.join(current_dir, 'HakgyoansimDunggeunmisoTTFB.ttf')
# 폰트 파일이 없으면 다른 경로들을 시도
if not os.path.exists(self.font_path):
alternative_paths = [
os.path.join(self.base_path, 'HakgyoansimDunggeunmisoTTFB.ttf'),
os.path.join(self.base_path, 'src', 'modules', 'HakgyoansimDunggeunmisoTTFB.ttf'),
os.path.join(os.path.dirname(self.base_path), 'src', 'modules', 'HakgyoansimDunggeunmisoTTFB.ttf')
]
for alt_path in alternative_paths:
if os.path.exists(alt_path):
self.font_path = alt_path
break
# 폰트 로드 (예외 처리 추가)
try:
self.font = ImageFont.truetype(self.font_path, 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
# self.debug = True
def reset_state(self):
"""클립보드 이미지 관리자의 상태를 초기화합니다."""
self.logger.log("ClipboardImageManager 상태 초기화", level=logging.DEBUG)
# 클립보드 비우기
self.clear_clipboard()
def get_base_dir(self):
"""
실행 환경에 따라 base_dir을 설정하는 메서드.
cx_Freeze로 패키징된 경우 실행 파일의 경로, 일반 Python 환경일 경우 __file__을 기준으로 설정.
"""
if getattr(sys, 'frozen', False): # 패키징된 경우
base_dir = os.path.dirname(sys.executable)
internal_dir = os.path.join(base_dir, 'lib', 'src') # lib 디렉토리 포함
if os.path.exists(internal_dir): # lib 디렉토리가 존재하면 base_dir로 설정
return internal_dir
else: # 일반 Python 실행 환경
base_dir = os.path.dirname(os.path.abspath(__file__))
debug_dir = os.path.join(base_dir, 'src') # lib 디렉토리 포함
return debug_dir
def get_clipboard_data(self):
"""클립보드의 텍스트 또는 이미지 데이터를 가져옵니다."""
self.logger.log("클립보드의 텍스트 또는 이미지 데이터를 가져옵니다", level=logging.DEBUG)
max_attempts = 5
attempt = 0
while attempt < max_attempts:
try:
# 1. 텍스트 데이터 우선 시도
clipboard_text = pyperclip.paste()
if clipboard_text:
return clipboard_text
# 2. 텍스트가 없으면 이미지 확인
self.logger.log("텍스트 데이터가 없어 이미지 데이터 확인 시도", level=logging.DEBUG)
image = ImageGrab.grabclipboard()
if isinstance(image, Image.Image): # 이미지 데이터가 있는 경우
self.logger.log("클립보드에 이미지 데이터가 확인되었습니다.", level=logging.DEBUG)
return image # PIL 이미지 객체 반환
else:
self.logger.log("클립보드에 텍스트 또는 이미지 데이터가 없습니다.", level=logging.DEBUG)
return None
except Exception as e:
attempt += 1
self.logger.log(f"클립보드 데이터를 가져오는 중 오류 발생 (시도 {attempt}/{max_attempts}): {e}", level=logging.WARNING)
if attempt < max_attempts:
time.sleep(0.5) # 0.5초 대기 후 재시도
else:
self.logger.log(f"클립보드 데이터를 가져오는 중 최대 시도 횟수 초과: {e}", level=logging.ERROR, exc_info=True)
return None
def set_image_to_clipboard(self, image):
"""이미지를 클립보드에 넣는 함수 (Windows 전용)"""
output = BytesIO()
image.save(output, "BMP")
self.logger.log(f"이미지 데이터 BMP 변환", level=logging.DEBUG)
data = output.getvalue()[14:] # BMP 헤더 제거
output.close()
self.logger.log(f"이미지 BMP 헤더 제거", level=logging.DEBUG)
# 클립보드 접근 재시도 로직
max_attempts = 5
attempt = 0
success = False
while attempt < max_attempts and not success:
try:
# 클립보드에 이미지 데이터 넣기
win32clipboard.OpenClipboard()
win32clipboard.EmptyClipboard()
win32clipboard.SetClipboardData(win32clipboard.CF_DIB, data)
win32clipboard.CloseClipboard()
success = True
self.logger.log(f"클립보드 데이터 저장 성공 (시도 {attempt+1}/{max_attempts})", level=logging.DEBUG)
except Exception as e:
attempt += 1
self.logger.log(f"클립보드 데이터 저장 실패 (시도 {attempt}/{max_attempts}): {e}", level=logging.WARNING)
if attempt < max_attempts:
time.sleep(0.5) # 0.5초 대기 후 재시도
# 클립보드가 제대로 설정되었는지 확인하는 로그
if success:
try:
time.sleep(0.1) # 아주 짧은 대기 시간
win32clipboard.OpenClipboard()
if win32clipboard.IsClipboardFormatAvailable(win32clipboard.CF_DIB):
self.logger.log("클립보드 데이터 확인 성공", level=logging.DEBUG)
else:
self.logger.log("클립보드 데이터 확인 실패", level=logging.ERROR)
win32clipboard.CloseClipboard()
except Exception as e:
self.logger.log(f"클립보드 데이터 확인 중 오류: {e}", level=logging.ERROR)
def save_image_to_path(self, image, path):
try:
if image:
# 이미지를 저장 경로에 저장
self.logger.log(f"이미지 저장 완료 : {path}", level=logging.INFO)
image.save(path, format='PNG')
return path
except Exception as e:
raise RuntimeError(f"이미지 저장 중 오류 발생: {e}")
def add_watermark(self, image, watermark_text="Watermark", opacity_percent=30, angle=30, font_size=36):
"""
이미지에 텍스트 워터마크를 이미지 전체에 걸쳐서 추가하는 함수
:param image: PIL 이미지 객체
:param watermark_text: 워터마크로 추가할 텍스트
:param opacity_percent: 워터마크의 투명도 (0~100)
:param angle: 워터마크 텍스트 회전 각도 (기본 30)
:param font_size: 워터마크 텍스트의 폰트 크기 (기본 36)
:return: 워터마크가 추가된 이미지
"""
# 폰트가 로드되지 않은 경우 원본 이미지 반환
if self.font is None:
self.logger.log("폰트가 로드되지 않아 워터마크를 추가할 수 없습니다. 원본 이미지를 반환합니다.", level=logging.WARNING)
return image
# 이미지 복사본 생성
watermark_image = image.copy()
# 폰트 설정 (안전한 폰트 로딩)
try:
# self.font가 있으면 크기만 조정해서 새 폰트 생성
if hasattr(self, 'font_path') and os.path.exists(self.font_path):
font = ImageFont.truetype(self.font_path, font_size)
else:
# 크기를 조정할 수 없으면 기존 폰트 사용
font = self.font
except Exception as e:
self.logger.log(f"폰트 크기 조정 실패: {e}. 기본 폰트를 사용합니다.", level=logging.WARNING)
font = self.font
# 텍스트 투명도를 0~255로 변환
opacity = int(255 * (opacity_percent / 100))
# 텍스트 크기 측정 (textbbox 사용)
draw = ImageDraw.Draw(watermark_image)
bbox = draw.textbbox((0, 0), watermark_text, font=font)
text_width, text_height = bbox[2] - bbox[0], bbox[3] - bbox[1]
# 이미지 크기
width, height = image.size
# 워터마크 레이어 생성
watermark_layer = Image.new("RGBA", (width, height)) # RGBA 이미지 생성
# 지그재그 간격 설정
zigzag_step = int(text_height * 2) # Y축의 지그재그 간격
# 이미지 전체에 반복적으로 워터마크 텍스트 그리기 (지그재그 형태)
for y in range(0, height, zigzag_step):
for x in range(0, width, int(text_width * 3)): # 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=font)
# 텍스트 회전
rotated_text_layer = text_layer.rotate(angle, expand=1)
# 회전된 텍스트를 워터마크 레이어에 추가
watermark_layer.paste(rotated_text_layer, (x + x_offset, y), rotated_text_layer)
# 원본 이미지와 워터마크 레이어 합성
watermark_image = Image.alpha_composite(watermark_image.convert("RGBA"), watermark_layer)
# 최종적으로 RGB 형식으로 변환 후 반환
return watermark_image.convert("RGB")
def base64_to_image(self, base64_data):
"""Base64 데이터를 이미지로 변환하는 함수"""
if base64_data.startswith('data:image'):
header, encoded = base64_data.split(',', 1)
img_data = base64.b64decode(encoded)
image = Image.open(BytesIO(img_data))
return image
else:
self.logger.log("유효하지 않은 Base64 이미지 데이터입니다.", level=logging.DEBUG)
return None
def image_to_base64(self, image):
# 이미지 Base64로 변환
buffer = io.BytesIO()
image.save(buffer, format="PNG")
base64_image = base64.b64encode(buffer.getvalue()).decode('utf-8')
return base64_image
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 process_clipboard(self, original_url, is_success_translated, toggle_states, path=None, is_thumb=False):
"""클립보드의 내용을 처리하고, 필요한 경우 이미지 변환, 크롭 또는 클립보드 비우기"""
try:
is_watermark = toggle_states.get('watermark')
self.logger.log(f"is_watermark : {is_watermark}", level=logging.DEBUG)
watermark_text = toggle_states.get('watermark_text')
self.logger.log(f"watermark_text : {watermark_text}", level=logging.DEBUG)
opacity_percent = toggle_states.get('opacity_percent')
self.logger.log(f"opacity_percent : {opacity_percent}", level=logging.DEBUG)
clipboard_data = self.get_clipboard_data()
self.logger.log("clipboard_data", level=logging.DEBUG)
self.logger.log(f"{clipboard_data}", level=logging.DEBUG)
self.logger.log(f"============================", level=logging.DEBUG)
# 1. 클립보드의 데이터가 Base64 이미지일 경우
if isinstance(clipboard_data, str) and clipboard_data.startswith('data:image'):
self.logger.log("[process_clipboard] data:image 감지 : 이미지 데이터로 변환", level=logging.INFO)
image = self.base64_to_image(clipboard_data)
if image:
width, _ = image.size
self.logger.log(f"Base64 이미지 크기: {width}px", level=logging.DEBUG)
# 가로 크기가 200픽셀 이상이면 크롭
if width >= 200:
self.logger.log("이미지 가로 크기 200픽셀 이상: 크롭 진행 중...", level=logging.DEBUG)
cropped_image = self.crop_image(image, is_thumb) # 크롭 메서드 사용
# 워터마크 추가
if is_watermark and not is_thumb: # is_thumb가 True라면 워터마크 추가를 건너뜁니다
self.logger.log("워터마크 추가 중...", level=logging.DEBUG)
cropped_watermark_image = self.add_watermark(cropped_image, watermark_text, opacity_percent) # 워터마크 추가
cropped_image = cropped_watermark_image
self.set_image_to_clipboard(cropped_image) # 클립보드에 저장
if path:
self.logger.log("이미지 저장 시도...", level=logging.DEBUG)
self.save_image_to_path(cropped_image, path)
else:
self.logger.log("이미지 가로 크기 200픽셀 이하: 클립보드 비움.", level=logging.DEBUG)
self.clear_clipboard()
else:
self.logger.log("Base64 이미지 변환 실패.", level=logging.DEBUG)
# 2. 클립보드에 이미지가 있을 경우
elif isinstance(clipboard_data, Image.Image):
self.logger.log("[process_clipboard] 클립보드 이미지 확인", level=logging.INFO)
image = clipboard_data
width, _ = image.size
self.logger.log(f"클립보드에 있는 이미지 크기: {width}px", level=logging.DEBUG)
if width >= 200:
self.logger.log("이미지 가로 크기 200픽셀 이상: 크롭 진행 중...", level=logging.DEBUG)
cropped_image = self.crop_image(image, is_thumb) # 크롭 메서드 사용
# 워터마크 추가
if is_watermark and not is_thumb: # is_thumb가 True라면 워터마크 추가를 건너뜁니다
self.logger.log("워터마크 추가 중...", level=logging.DEBUG)
cropped_watermark_image = self.add_watermark(cropped_image, watermark_text, opacity_percent) # 워터마크 추가
cropped_image = cropped_watermark_image
self.set_image_to_clipboard(cropped_image) # 클립보드에 저장
if path:
self.logger.log("이미지 저장 시도...", level=logging.DEBUG)
self.save_image_to_path(cropped_image, path)
else:
self.logger.log("이미지 가로 크기 200픽셀 이하: 클립보드 비움.", level=logging.DEBUG)
self.clear_clipboard()
# 3. 클립보드에 데이터가 없거나 html > whale-ocr 처리
elif clipboard_data == "html > whale-ocr" or clipboard_data is None or not is_success_translated:
if clipboard_data == "html > whale-ocr":
self.logger.log("[process_clipboard] html > whale-ocr 감지 : 이미지 번역 실패 확인", level=logging.INFO)
elif clipboard_data is None:
self.logger.log("[process_clipboard] 클립보드에 이미지 없음", level=logging.INFO)
elif is_success_translated is None:
self.logger.log("[process_clipboard] 번역 실패로 인한 원본이미지 다운로드", level=logging.INFO)
if original_url:
image = self.download_image_from_url(original_url)
if image:
self.logger.log("원본 이미지 다운로드 성공!", level=logging.DEBUG)
self.set_image_to_clipboard(image) # 크롭 없이 저장
if path:
self.logger.log("이미지 저장 시도...", level=logging.DEBUG)
self.save_image_to_path(image, path)
else:
self.logger.log("원본 이미지 다운로드 실패.", level=logging.DEBUG)
else:
self.logger.log("원본 이미지 URL을 찾을 수 없습니다.", level=logging.DEBUG)
except Exception as e:
self.logger.log(f"클립보드에서 이미지를 처리하는 중 오류 발생: {e}", level=logging.ERROR, exc_info=True)
def process_clipboard_to_save_path(self, original_url, is_success_translated, toggle_states, path=None, is_thumb=False):
"""클립보드의 내용을 처리하고, 필요한 경우 이미지 변환, 크롭 또는 클립보드 비우기"""
try:
is_watermark = toggle_states.get('watermark')
self.logger.log(f"is_watermark : {is_watermark}", level=logging.DEBUG)
watermark_text = toggle_states.get('watermark_text')
self.logger.log(f"watermark_text : {watermark_text}", level=logging.DEBUG)
opacity_percent = toggle_states.get('opacity_percent')
self.logger.log(f"opacity_percent : {opacity_percent}", level=logging.DEBUG)
clipboard_data = self.get_clipboard_data()
self.logger.log("clipboard_data", level=logging.DEBUG)
self.logger.log(f"{clipboard_data}", level=logging.DEBUG)
self.logger.log(f"============================", level=logging.DEBUG)
# 1. 클립보드의 데이터가 Base64 이미지일 경우
if isinstance(clipboard_data, str) and clipboard_data.startswith('data:image'):
self.logger.log("[process_clipboard] data:image 감지 : 이미지 데이터로 변환", level=logging.INFO)
image = self.base64_to_image(clipboard_data)
if image:
width, _ = image.size
self.logger.log(f"Base64 이미지 크기: {width}px", level=logging.DEBUG)
# 가로 크기가 200픽셀 이상이면 크롭
if width >= 200:
self.logger.log("이미지 가로 크기 200픽셀 이상: 크롭 진행 중...", level=logging.DEBUG)
cropped_image = self.crop_image(image, is_thumb) # 크롭 메서드 사용
# 워터마크 추가
if is_watermark and not is_thumb: # is_thumb가 True라면 워터마크 추가를 건너뜁니다
self.logger.log("워터마크 추가 중...", level=logging.DEBUG)
cropped_watermark_image = self.add_watermark(cropped_image, watermark_text, opacity_percent) # 워터마크 추가
cropped_image = cropped_watermark_image
if path:
self.logger.log("이미지 저장 시도...", level=logging.DEBUG)
return self.save_image_to_path(cropped_image, path)
else:
self.set_image_to_clipboard(cropped_image) # 클립보드에 저장
else:
self.logger.log("이미지 가로 크기 200픽셀 이하로 처리불가: 클립보드 비움.", level=logging.DEBUG)
self.clear_clipboard()
else:
self.logger.log("Base64 이미지 변환 실패.", level=logging.DEBUG)
# 2. 클립보드에 이미지가 있을 경우
elif isinstance(clipboard_data, Image.Image):
self.logger.log("[process_clipboard] 클립보드 이미지 확인", level=logging.INFO)
image = clipboard_data
width, _ = image.size
self.logger.log(f"클립보드에 있는 이미지 크기: {width}px", level=logging.DEBUG)
if width >= 200:
self.logger.log("이미지 가로 크기 200픽셀 이상: 크롭 진행 중...", level=logging.DEBUG)
cropped_image = self.crop_image(image, is_thumb) # 크롭 메서드 사용
# 워터마크 추가
if is_watermark and not is_thumb: # is_thumb가 True라면 워터마크 추가를 건너뜁니다
self.logger.log("워터마크 추가 중...", level=logging.DEBUG)
cropped_watermark_image = self.add_watermark(cropped_image, watermark_text, opacity_percent) # 워터마크 추가
cropped_image = cropped_watermark_image
if path:
self.logger.log("이미지 저장 시도...", level=logging.DEBUG)
return self.save_image_to_path(cropped_image, path)
else:
self.set_image_to_clipboard(cropped_image) # 클립보드에 저장
else:
self.logger.log("이미지 가로 크기 200픽셀 이하로 처리불가: 클립보드 비움.", level=logging.DEBUG)
self.clear_clipboard()
# 3. 클립보드에 데이터가 없거나 html > whale-ocr 처리
elif clipboard_data == "html > whale-ocr" or clipboard_data is None or not is_success_translated or clipboard_data.startswith("https://") or clipboard_data.startswith("http://"):
if clipboard_data == "html > whale-ocr":
self.logger.log("[process_clipboard] html > whale-ocr 감지 : 이미지 번역 실패 확인", level=logging.INFO)
elif clipboard_data is None:
self.logger.log("[process_clipboard] 클립보드에 이미지 없음", level=logging.INFO)
elif is_success_translated is None:
self.logger.log("[process_clipboard] 번역 실패로 인한 원본이미지 다운로드", level=logging.INFO)
elif clipboard_data.startswith("https://") or clipboard_data.startswith("http://"):
self.logger.log("[process_clipboard] 타임아웃으로 인한 번역 실패 - 원본이미지 다운로드", level=logging.INFO)
if original_url:
image = self.download_image_from_url(original_url)
if image:
self.logger.log("원본 이미지 다운로드 성공!", level=logging.DEBUG)
if path:
self.logger.log("이미지 저장 시도...", level=logging.DEBUG)
return self.save_image_to_path(image, path)
else:
self.set_image_to_clipboard(cropped_image) # 클립보드에 저장
else:
self.logger.log("원본 이미지 다운로드 실패.", level=logging.DEBUG)
else:
self.logger.log("원본 이미지 URL을 찾을 수 없습니다.", level=logging.DEBUG)
except Exception as e:
self.logger.log(f"클립보드에서 이미지를 처리하는 중 오류 발생: {e}", level=logging.ERROR, exc_info=True)
def process_clipboard_to_save_path_with_local_hosted_image(self, local_image_path, is_success_translated, toggle_states, path=None, is_thumb=False):
"""클립보드의 내용을 처리하고, 필요한 경우 이미지 변환, 크롭 또는 클립보드 비우기
Returns:
str: 처리된 이미지 파일 경로 (성공 )
str: 원본 이미지 파일 경로 (실패 )
"""
# 매개변수 유효성 검사
if not local_image_path or not os.path.exists(local_image_path):
self.logger.log(f"유효하지 않은 로컬 이미지 경로: {local_image_path}", level=logging.ERROR)
return local_image_path if local_image_path else None
if not toggle_states:
self.logger.log("toggle_states가 제공되지 않았습니다", level=logging.WARNING)
toggle_states = {}
try:
is_watermark = toggle_states.get('watermark', False)
self.logger.log(f"is_watermark : {is_watermark}", level=logging.DEBUG)
watermark_text = toggle_states.get('watermark_text', '')
self.logger.log(f"watermark_text : {watermark_text}", level=logging.DEBUG)
opacity_percent = toggle_states.get('opacity_percent', 20)
self.logger.log(f"opacity_percent : {opacity_percent}", level=logging.DEBUG)
clipboard_data = self.get_clipboard_data()
self.logger.log(f"type(clipboard_data) : {type(clipboard_data)}", level=logging.DEBUG)
self.logger.log(f"============================", level=logging.DEBUG)
# 1. 클립보드의 데이터가 Base64 이미지일 경우
if isinstance(clipboard_data, str) and clipboard_data.startswith('data:image'):
self.logger.log("[process_clipboard] data:image 감지 : 이미지 데이터로 변환", level=logging.INFO)
image = self.base64_to_image(clipboard_data)
if image:
width, _ = image.size
self.logger.log(f"Base64 이미지 크기: {width}px", level=logging.DEBUG)
# 가로 크기가 200픽셀 이상이면 크롭
if width >= 200:
self.logger.log("이미지 가로 크기 200픽셀 이상: 크롭 진행 중...", level=logging.DEBUG)
cropped_image = self.crop_image(image, is_thumb) # 크롭 메서드 사용
# 워터마크 추가
if is_watermark and not is_thumb: # is_thumb가 True라면 워터마크 추가를 건너뜁니다
self.logger.log("워터마크 추가 중...", level=logging.DEBUG)
cropped_watermark_image = self.add_watermark(cropped_image, watermark_text, opacity_percent) # 워터마크 추가
cropped_image = cropped_watermark_image
if path:
self.logger.log("이미지 저장 시도...", level=logging.DEBUG)
saved_path = self.save_image_to_path(cropped_image, path)
return saved_path if saved_path else local_image_path
else:
self.set_image_to_clipboard(cropped_image) # 클립보드에 저장
return local_image_path # path가 없으면 원본 경로 반환
else:
self.logger.log("이미지 가로 크기 200픽셀 이하로 처리불가: 클립보드 비움.", level=logging.DEBUG)
self.clear_clipboard()
return local_image_path
else:
self.logger.log("Base64 이미지 변환 실패.", level=logging.DEBUG)
return local_image_path
# 2. 클립보드에 이미지가 있을 경우
elif isinstance(clipboard_data, Image.Image):
self.logger.log("[process_clipboard] 클립보드 이미지 확인", level=logging.INFO)
image = clipboard_data
width, _ = image.size
self.logger.log(f"클립보드에 있는 이미지 크기: {width}px", level=logging.DEBUG)
if width >= 200:
self.logger.log("이미지 가로 크기 200픽셀 이상: 크롭 진행 중...", level=logging.DEBUG)
cropped_image = self.crop_image(image, is_thumb) # 크롭 메서드 사용
# 워터마크 추가
if is_watermark and not is_thumb: # is_thumb가 True라면 워터마크 추가를 건너뜁니다
self.logger.log("워터마크 추가 중...", level=logging.DEBUG)
cropped_watermark_image = self.add_watermark(cropped_image, watermark_text, opacity_percent) # 워터마크 추가
cropped_image = cropped_watermark_image
if path:
self.logger.log("이미지 저장 시도...", level=logging.DEBUG)
saved_path = self.save_image_to_path(cropped_image, path)
return saved_path if saved_path else local_image_path
else:
self.set_image_to_clipboard(cropped_image) # 클립보드에 저장
return local_image_path # path가 없으면 원본 경로 반환
else:
self.logger.log("이미지 가로 크기 200픽셀 이하로 처리불가: 클립보드 비움.", level=logging.DEBUG)
self.clear_clipboard()
return local_image_path
# 3. 클립보드에 데이터가 없거나 html > whale-ocr 처리
elif clipboard_data == "html > whale-ocr" or clipboard_data is None or not is_success_translated or clipboard_data.startswith("https://") or clipboard_data.startswith("http://"):
if clipboard_data == "html > whale-ocr":
self.logger.log("[process_clipboard] html > whale-ocr 감지 : 이미지 번역 실패 확인", level=logging.INFO)
elif clipboard_data is None:
self.logger.log("[process_clipboard] 클립보드에 이미지 없음", level=logging.INFO)
elif not is_success_translated:
self.logger.log("[process_clipboard] 번역 실패로 인한 원본이미지 사용", level=logging.INFO)
elif clipboard_data.startswith("https://") or clipboard_data.startswith("http://"):
self.logger.log("[process_clipboard] 타임아웃으로 인한 번역 실패 - 원본이미지 사용", level=logging.INFO)
return local_image_path
# 4. 기타 예상하지 못한 클립보드 데이터
else:
self.logger.log(f"[process_clipboard] 예상하지 못한 클립보드 데이터 타입: {type(clipboard_data)}", level=logging.WARNING)
return local_image_path
except Exception as e:
self.logger.log(f"클립보드에서 이미지를 처리하는 중 오류 발생: {e}", level=logging.ERROR, exc_info=True)
return local_image_path # 오류 시 원본 경로 반환
def is_clipboard_image(self):
"""클립보드에 이미지가 있는지 확인하는 함수"""
max_attempts = 5
attempt = 0
while attempt < max_attempts:
try:
win32clipboard.OpenClipboard()
is_clipboard_image_flag = win32clipboard.IsClipboardFormatAvailable(win32clipboard.CF_DIB)
win32clipboard.CloseClipboard()
if is_clipboard_image_flag:
self.logger.log("클립보드에 이미지가 존재합니다.", level=logging.DEBUG)
else:
self.logger.log("클립보드에 이미지가 없습니다.", level=logging.DEBUG)
return is_clipboard_image_flag
except Exception as e:
attempt += 1
# 클립보드가 열려있으면 닫기 시도
try:
win32clipboard.CloseClipboard()
except:
pass
self.logger.log(f"클립보드 이미지 확인 중 오류 발생 (시도 {attempt}/{max_attempts}): {e}", level=logging.WARNING)
if attempt < max_attempts:
time.sleep(0.5) # 0.5초 대기 후 재시도
else:
self.logger.log(f"클립보드 이미지 확인 중 최대 시도 횟수 초과: {e}", level=logging.ERROR, exc_info=True)
return False
def get_image_from_clipboard(self):
"""클립보드에서 이미지를 가져오는 함수"""
max_attempts = 5
attempt = 0
while attempt < max_attempts:
try:
win32clipboard.OpenClipboard()
if self.is_clipboard_image():
dib_data = win32clipboard.GetClipboardData(win32clipboard.CF_DIB)
win32clipboard.CloseClipboard()
image = Image.open(BytesIO(dib_data))
return image
else:
win32clipboard.CloseClipboard()
self.logger.log("클립보드에 이미지가 없습니다.", level=logging.DEBUG)
return None
except Exception as e:
attempt += 1
# 클립보드가 열려있으면 닫기 시도
try:
win32clipboard.CloseClipboard()
except:
pass
self.logger.log(f"클립보드에서 이미지를 가져오는 중 오류 발생 (시도 {attempt}/{max_attempts}): {e}", level=logging.WARNING)
if attempt < max_attempts:
time.sleep(0.5) # 0.5초 대기 후 재시도
else:
self.logger.log(f"클립보드에서 이미지를 가져오는 중 최대 시도 횟수 초과: {e}", level=logging.ERROR, exc_info=True)
return None
def clear_clipboard(self):
"""클립보드를 비우는 함수"""
max_attempts = 5
attempt = 0
success = False
while attempt < max_attempts and not success:
try:
# 먼저 pywinauto로 시도
try:
pywinauto.clipboard.EmptyClipboard()
success = True
except:
# pywinauto 실패 시 win32clipboard로 시도
win32clipboard.OpenClipboard()
win32clipboard.EmptyClipboard()
win32clipboard.CloseClipboard()
success = True
self.logger.log(f"클립보드가 비워졌습니다. (시도 {attempt+1}/{max_attempts})", level=logging.DEBUG)
except Exception as e:
attempt += 1
self.logger.log(f"클립보드를 비우는 중 오류 발생 (시도 {attempt}/{max_attempts}): {e}", level=logging.WARNING)
if attempt < max_attempts:
time.sleep(0.5) # 0.5초 대기 후 재시도
if not success:
self.logger.log("최대 시도 횟수를 초과하여 클립보드를 비우지 못했습니다.", level=logging.ERROR)
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

View File

@ -0,0 +1,41 @@
import logging
from openai import OpenAI
import json
import re
import logging
class GPTClient:
def __init__(self, model="gpt-4o-mini", temperature=0.2):
self.client = None
self.model = model
self.temperature = temperature
self.set_client(api_key='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')
def set_client(self, api_key):
self.client = OpenAI(api_key=api_key)
def ask(self, prompt: str) -> dict:
"""프롬프트를 이용하여 GPT 모델로부터 응답을 받습니다. 항상 JSON 형식으로 반환."""
try:
response = self.client.chat.completions.create(
model=self.model,
temperature=self.temperature,
messages=[{"role": "user", "content": prompt}]
)
# GPT 응답 내용 가져오기
content = response.choices[0].message.content.strip()
print(f'GPT 응답: {content}')
# 불필요한 포맷팅 제거 (```json```)
cleaned_content = re.sub(r"^```json|```$", "", content).strip()
# JSON 변환 시도
return json.loads(cleaned_content)
except json.JSONDecodeError as e:
print(f'JSON 디코딩 실패: {e}. 원본 응답: {content}')
return {}
except Exception as e:
print(f'GPT 통신 오류: {e}')
return {}

View File

@ -0,0 +1,346 @@
import os
import asyncio
import aiofiles
import logging
from urllib.parse import urlparse
import shutil
import sys
from concurrent.futures import ThreadPoolExecutor
import re
import cv2
import base64
import requests
import numpy as np
from modules.ocr_module import OCRModule
from modules.mask_module import MaskModule
from modules.text_rendering_module import TextRenderingModule
from modules.postImageManager import PostImageManager
from modules.lama_inpaint import inpaint_with_simple_lama
class ImageProcessor:
"""이미지 다운로드, OCR, 번역 처리를 담당하는 클래스"""
def __init__(self, logger, gpt_client, base_dir, font_path):
self.logger = logger
self.base_dir = base_dir
self.gpt_client = gpt_client
# OCR 관련
self.inpaint_sv_port = 8000
self.font_path = font_path
self.TEMP_IMAGE_DIR = os.path.join(self.base_dir, "temp_images")
os.makedirs(self.TEMP_IMAGE_DIR, exist_ok=True)
self.ocr_module = OCRModule(logger=self.logger, base_dir=self.base_dir)
self.mask_module = MaskModule(logger=self.logger, base_dir=self.base_dir)
self.text_rendering_module = TextRenderingModule(logger=self.logger, font_path=self.font_path)
self.postImageManager = PostImageManager(logger=self.logger, font_path=self.font_path)
def __del__(self):
"""소멸자에서 리소스 정리"""
self.cleanup()
def cleanup(self):
"""리소스 정리"""
try:
# 임시 폴더 삭제
if hasattr(self, 'TEMP_IMAGE_DIR') and os.path.exists(self.TEMP_IMAGE_DIR):
shutil.rmtree(self.TEMP_IMAGE_DIR)
self.logger.log(f"임시 폴더 삭제됨: {self.TEMP_IMAGE_DIR}", level=logging.INFO)
except Exception as e:
self.logger.log(f"리소스 정리 중 오류: {e}", level=logging.ERROR, exc_info=True)
def is_valid_image_path(self, path):
# http/https 또는 로컬 파일(.jpg, .png 등) 모두 허용
if re.match(r'^(http|https)://.*\\.(jpg|jpeg|png|bmp|gif|webp|tiff?)(\\?.*)?$', path, re.IGNORECASE):
return True
if os.path.isfile(path) and path.lower().endswith(('.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp', '.tif', '.tiff')):
return True
return False
async def process_single_image(self, toggle_states, unwanted_texts, local_image_path, index, file_prefix=""):
"""
단일 이미지를 처리합니다 (다운로드 -> OCR -> 인페인팅)
Args:
toggle_states: 토글 상태 딕셔너리
local_image_path (str): 처리할 이미지 경로
index (int): 이미지 인덱스
unwanted_texts: 치환할 텍스트 딕셔너리
file_prefix (str): 파일명에 추가할 접두사 (: "detail", "option")
Returns:
dict: 처리 결과를 포함한 딕셔너리
- status: 'inpainted', 'original', 'exclude', 'error' 하나
- path: 처리된 이미지 파일 경로 또는 원본 이미지 파일 경로
- error: 오류 메시지 (status가 'error' 경우에만 포함)
"""
ocr_enabled = toggle_states.get('ocr', False)
unwanted_texts = unwanted_texts
try:
ocr_results = self.ocr_module.detect_text(local_image_path)
# 3. 중국어 텍스트 없는 경우 원본 이미지 반환
if not self.ocr_module.filter_chinese_text(ocr_results):
self.logger.log(f"이미지 {index+1} 중국어 텍스트 없음, 원본 이미지 반환", level=logging.INFO)
return local_image_path
# 4. 텍스트 번역 (GPT)
translated_texts = self.gpt_translate_texts(ocr_results, self.gpt_client)
if ocr_enabled:
filtered_translated_texts = self.process_translated_texts(translated_texts, unwanted_texts, local_image_path, index)
if not filtered_translated_texts:
self.logger.log(f"이미지 {index+1} 제외됨", level=logging.INFO)
return None
else:
self.logger.log(f"이미지 {index+1} 치환됨", level=logging.INFO)
# 마스크 생성 (basic 방식만 사용)
masks = self.mask_module.create_masks(
image_path=local_image_path, ocr_results=ocr_results, mask_option="basic"
)
self.logger.log(f"마스크 생성 완료", level=logging.INFO)
# 인페인팅
# inpainted_image = self.call_inpaint_api(local_image_path, masks)
# self.logger.log(f"인페인팅 완료", level=logging.INFO)
inpainted_image = inpaint_with_simple_lama(local_image_path, masks)
self.logger.log(f"인페인팅 완료", level=logging.INFO)
# 텍스트 렌더링
text_rendered_image = self.text_rendering_module.render_text(
inpainted_image, ocr_results, filtered_translated_texts, font_path=self.font_path)
self.logger.log(f"텍스트 렌더링 완료", level=logging.INFO)
# 결과 저장
translated_img_path = await self.postProcess_and_save_image(local_image_path, text_rendered_image, index, file_prefix, toggle_states)
self.logger.log(f"이미지 {index+1} 번역 완료: {translated_img_path}", level=logging.INFO)
return translated_img_path
except Exception as e:
self.logger.log(f"이미지 {index+1} 처리 중 오류: {e}", level=logging.ERROR, exc_info=True)
return {'status': 'failed', 'path': local_image_path, 'error': str(e)}
async def postProcess_and_save_image(self, local_image_path, text_rendered_image, index, file_prefix, toggle_states):
"""로컬 서버 URL을 사용해 이미지를 번역하고 로컬에 저장합니다"""
try:
# 파일명에 접두사 포함
if file_prefix:
img_path = os.path.join(self.TEMP_IMAGE_DIR, f"translated_{file_prefix}_img_{index+1}.png")
else:
img_path = os.path.join(self.TEMP_IMAGE_DIR, f"translated_img_{index+1}.png")
watermarked_image_data = self.postImageManager.add_watermark(image_data=text_rendered_image, watermark_text=toggle_states.get("watermark_text", "워터마크"))
final_image_path = self.postImageManager.save_image_to_path(watermarked_image_data, img_path)
return final_image_path
except Exception as e:
self.logger.log(f"이미지 {index+1} 번역 처리 중 오류: {e}", level=logging.ERROR, exc_info=True)
return local_image_path
def is_valid_image_data(self, image_data: bytes) -> bool:
"""이미지 데이터가 유효한지 확인합니다"""
if not image_data or len(image_data) < 100:
return False
# 일반적인 이미지 파일 시그니처 확인
image_signatures = [
b'\xFF\xD8\xFF', # JPEG
b'\x89PNG\r\n\x1a\n', # PNG
b'GIF87a', # GIF87a
b'GIF89a', # GIF89a
b'RIFF', # WebP (RIFF 컨테이너)
]
return any(image_data.startswith(sig) for sig in image_signatures)
def call_inpaint_api(self, image, mask):
"""
인페인팅 API를 호출하여 이미지를 인페인팅합니다.
"""
try:
# 이미지 처리
if isinstance(image, str):
image_np = cv2.imread(image)
if image_np is None:
self.logger.log(f"이미지 로딩 실패: {image}", level=logging.ERROR)
return None
else:
image_np = image
# 마스크 처리
if isinstance(mask, str):
mask_np = cv2.imread(mask, cv2.IMREAD_GRAYSCALE)
if mask_np is None:
self.logger.log(f"마스크 로딩 실패: {mask}", level=logging.ERROR)
return None
else:
mask_np = mask
api_url = f"http://localhost:{self.inpaint_sv_port}/api/v1/inpaint"
_, img_encoded = cv2.imencode('.png', image_np)
_, mask_encoded = cv2.imencode('.png', mask_np)
img_b64 = base64.b64encode(img_encoded).decode('utf-8')
mask_b64 = base64.b64encode(mask_encoded).decode('utf-8')
payload = {
"image": img_b64,
"mask": mask_b64
}
response = requests.post(api_url, json=payload)
if response.status_code != 200:
self.logger.log(f"IOPaint 서버 에러: {response.text}", level=logging.ERROR)
return None
nparr = np.frombuffer(response.content, np.uint8)
result = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
return result
except Exception as e:
self.logger.log(f"인페인팅 API 호출 중 오류: {e}", level=logging.ERROR, exc_info=True)
return None
def process_translated_texts(self, translated_texts, unwanted_texts, local_image_path, index):
"""
번역된 단어 리스트(translated_texts)에서 unwanted_texts의 원본값이
앞이나 뒤에 포함되면 치환값으로 바꿉니다.
치환값이 '이미지삭제'라면 None 반환(이미지 제외)
"""
new_texts = []
for text in translated_texts:
replaced = False
for origin, replace in unwanted_texts.items():
# 앞/뒤에 원본값이 있는지 확인
if text.startswith(origin) or text.endswith(origin):
self.logger.log(f"[{text}] -> [{replace}] (치환)", level=logging.INFO)
if replace == "이미지삭제":
self.logger.log(f"이미지 {index+1} 제외됨: {local_image_path}", level=logging.INFO)
return None
# 앞/뒤 원본값만 치환
if text.startswith(origin):
new = replace + text[len(origin):]
elif text.endswith(origin):
new = text[:-len(origin)] + replace
new_texts.append(new)
replaced = True
break
if not replaced:
new_texts.append(text)
self.logger.log(f"최종 치환 결과: {new_texts}", level=logging.INFO)
return new_texts
async def process_image_list(self, image_urls, delay=1.0, file_prefix="", use_inpainting=False):
"""
이미지 리스트를 순차적으로 처리합니다.
"""
if not image_urls:
self.logger.log("처리할 이미지가 없습니다.", level=logging.INFO)
return []
processing_mode = "인페인팅" if use_inpainting else "웨일 번역"
self.logger.log(f"이미지 {len(image_urls)}개를 {processing_mode} 모드로 처리 시작", level=logging.INFO)
processed_images = []
for i, url in enumerate(image_urls):
self.logger.log(f"이미지 {i+1}/{len(image_urls)} 처리 중... ({processing_mode} 모드)", level=logging.INFO)
result = await self.process_single_image(
url, i, delay, file_prefix, use_inpainting
)
# 결과 처리
if isinstance(result, dict):
status = result.get('status')
path = result.get('path')
if status == 'inpainted':
processed_images.append(path)
self.logger.log(f"이미지 {i+1} 인페인팅 처리 완료", level=logging.INFO)
elif status == 'original':
processed_images.append(path)
self.logger.log(f"이미지 {i+1} 원본 사용", level=logging.INFO)
elif status == 'exclude':
self.logger.log(f"이미지 {i+1} 제외됨", level=logging.INFO)
# 제외된 이미지는 리스트에 추가하지 않음
else: # failed
self.logger.log(f"이미지 {i+1} 처리 실패: {result.get('error', '알 수 없는 오류')}", level=logging.WARNING)
# 실패한 이미지는 원본 경로 추가
processed_images.append(path)
else:
# 이전 버전과의 호환성을 위한 처리
if result:
processed_images.append(result)
self.logger.log(f"이미지 처리 완료: 총 {len(processed_images)}개 ({processing_mode} 모드)", level=logging.INFO)
return processed_images
def gpt_translate_texts(self, ocr_results, gpt_client):
texts = [result['text'] for result in ocr_results]
if not texts:
return []
prompt = (
"다음 중국어 문장들을 한국어로 자연스럽고 의미가 잘 전달되게 번역해줘. "
"순서와 개수는 반드시 그대로 유지하고, 결과는 JSON 배열(리스트)로만 반환해. "
"중국어 리스트:\n" +
str(texts)
)
response = gpt_client.ask(prompt)
if isinstance(response, list):
return response
elif isinstance(response, dict) and 'result' in response:
return response['result']
else:
print("GPT 번역 결과 파싱 실패, 원본 반환")
return texts
async def save_base64_to_temp_file(self, base64_data: str, suffix: str = "") -> str:
"""
base64 인코딩된 이미지 데이터를 임시 파일로 저장
Args:
base64_data (str): base64 인코딩된 이미지 데이터
suffix (str): 파일명에 추가할 접미사
Returns:
str: 저장된 임시 파일 경로, 실패시 None
"""
try:
import uuid
import time
# data:image/png;base64, 같은 헤더가 있으면 제거
if base64_data.startswith('data:image'):
base64_data = base64_data.split(',', 1)[1]
# base64 디코딩
image_bytes = base64.b64decode(base64_data)
# 이미지 유효성 검사
if not self.is_valid_image_data(image_bytes):
self.logger.log("유효하지 않은 이미지 데이터입니다.", level=logging.ERROR)
return None
# 임시 파일명 생성
timestamp = int(time.time())
unique_id = str(uuid.uuid4())[:8]
temp_filename = f"temp_image_{timestamp}_{unique_id}{suffix}.png"
temp_path = os.path.join(self.TEMP_IMAGE_DIR, temp_filename)
# 파일로 저장
with open(temp_path, 'wb') as f:
f.write(image_bytes)
self.logger.log(f"base64 이미지 데이터를 임시 파일로 저장: {temp_path}", level=logging.INFO)
return temp_path
except Exception as e:
self.logger.log(f"base64 이미지 데이터 저장 중 오류: {e}", level=logging.ERROR, exc_info=True)
return None

View File

@ -0,0 +1,178 @@
import random
import socket
import uvicorn
from fastapi import FastAPI, Query, Body
from pydantic import BaseModel
from typing import List, Optional
import asyncio
from concurrent.futures import ThreadPoolExecutor
from modules.image_processor2 import ImageProcessor
import os
import base64
# 포트 범위 설정
PORT_RANGE = (7000, 7000)
# 사용 가능한 포트 찾기
def find_free_port():
for _ in range(20):
port = random.randint(*PORT_RANGE)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
s.bind(("127.0.0.1", port))
return port
except OSError:
continue
raise RuntimeError("사용 가능한 포트를 찾을 수 없습니다.")
# 요청 모델 정의
class ImageRequest(BaseModel):
local_image_path: Optional[str] = None
image_data: Optional[str] = None # base64 인코딩된 이미지 데이터
file_prefix: Optional[str] = ""
use_inpainting: Optional[bool] = False
toggle_states: Optional[dict] = None
unwanted_texts: Optional[dict] = None
watermark_text: Optional[str] = None
watermark_opacity: Optional[float] = None
class ImagesRequest(BaseModel):
local_image_paths: Optional[List[str]] = None
image_data_list: Optional[List[str]] = None # base64 인코딩된 이미지 데이터 리스트
file_prefix: Optional[str] = ""
use_inpainting: Optional[bool] = False
toggle_states: Optional[dict] = None
unwanted_texts: Optional[dict] = None
watermark_text: Optional[str] = None
watermark_opacity: Optional[float] = None
# FastAPI 앱 생성
def create_app(image_processor: ImageProcessor, max_workers: int = 2):
app = FastAPI()
executor = ThreadPoolExecutor(max_workers=max_workers)
@app.post("/translate_image")
async def translate_image(req: ImageRequest):
# 워터마크 관련 옵션을 toggle_states에 병합
toggle_states = req.toggle_states.copy() if req.toggle_states else {}
if req.watermark_text is not None:
toggle_states["watermark_text"] = req.watermark_text
if req.watermark_opacity is not None:
toggle_states["watermark_opacity"] = req.watermark_opacity
# 이미지 경로 또는 데이터 검증
if not req.local_image_path and not req.image_data:
return {"error": "local_image_path 또는 image_data 중 하나는 반드시 제공되어야 합니다."}
# 이미지 경로가 없고 데이터만 있는 경우, 임시 파일로 저장
image_path = req.local_image_path
if not image_path and req.image_data:
try:
image_path = await image_processor.save_base64_to_temp_file(req.image_data)
if not image_path:
return {"error": "이미지 데이터를 임시 파일로 저장하는데 실패했습니다."}
except Exception as e:
return {"error": f"이미지 데이터 처리 중 오류: {str(e)}"}
# 파일 존재 확인
if not os.path.exists(image_path):
return {"error": f"이미지 파일을 찾을 수 없습니다: {image_path}"}
# 단일 이미지 번역
result = await image_processor.process_single_image(
toggle_states, req.unwanted_texts or {}, image_path, 0, req.file_prefix
)
# 결과를 base64로 변환하여 반환
if isinstance(result, dict):
result_path = result.get("path", None)
else:
result_path = result
if result_path and os.path.exists(result_path):
try:
# 처리된 이미지를 base64로 변환
with open(result_path, "rb") as f:
image_data = f.read()
result_base64 = base64.b64encode(image_data).decode('utf-8')
return {"result": result_base64, "format": "base64"}
except Exception as e:
return {"error": f"이미지를 base64로 변환하는 중 오류: {str(e)}"}
else:
return {"error": "처리된 이미지 파일을 찾을 수 없습니다."}
@app.post("/translate_images")
async def translate_images(req: ImagesRequest):
# 워터마크 관련 옵션을 toggle_states에 병합
toggle_states = req.toggle_states.copy() if req.toggle_states else {}
if req.watermark_text is not None:
toggle_states["watermark_text"] = req.watermark_text
if req.watermark_opacity is not None:
toggle_states["watermark_opacity"] = req.watermark_opacity
# 이미지 경로 리스트 또는 데이터 리스트 검증
if not req.local_image_paths and not req.image_data_list:
return {"error": "local_image_paths 또는 image_data_list 중 하나는 반드시 제공되어야 합니다."}
image_paths = []
# 이미지 경로가 있는 경우 그대로 사용
if req.local_image_paths:
image_paths.extend(req.local_image_paths)
# 이미지 데이터가 있는 경우 임시 파일로 저장
if req.image_data_list:
for idx, image_data in enumerate(req.image_data_list):
try:
temp_path = await image_processor.save_base64_to_temp_file(image_data, suffix=f"_data_{idx}")
if temp_path:
image_paths.append(temp_path)
except Exception as e:
return {"error": f"이미지 데이터 {idx} 처리 중 오류: {str(e)}"}
# 여러 이미지 병렬 번역
loop = asyncio.get_event_loop()
tasks = []
sem = asyncio.Semaphore(max_workers)
async def sem_task(idx, path):
async with sem:
return await image_processor.process_single_image(
toggle_states, req.unwanted_texts or {}, path, idx, req.file_prefix
)
for idx, path in enumerate(image_paths):
tasks.append(sem_task(idx, path))
results = await asyncio.gather(*tasks)
# 결과들을 base64로 변환하여 반환
base64_results = []
for result in results:
if isinstance(result, dict):
result_path = result.get("path", None)
else:
result_path = result
if result_path and os.path.exists(result_path):
try:
# 처리된 이미지를 base64로 변환
with open(result_path, "rb") as f:
image_data = f.read()
result_base64 = base64.b64encode(image_data).decode('utf-8')
base64_results.append(result_base64)
except Exception as e:
base64_results.append(None) # 변환 실패시 None
else:
base64_results.append(None) # 파일이 없으면 None
return {"results": base64_results, "format": "base64"}
return app
# 서버 실행 함수
def run_server(image_processor, max_workers=2):
port = find_free_port()
app = create_app(image_processor, max_workers)
uvicorn.run(app, host="0.0.0.0", port=port, workers=1)
# FastAPI의 workers는 프로세스 수이므로, 내부 병렬은 ThreadPoolExecutor로 제어
# 실제 워커 수는 process_single_image 병렬 호출로 제한
# 서버 실행 후 포트 정보 반환 가능
return port

View File

@ -0,0 +1,27 @@
import numpy as np
import requests
import cv2
import base64
class IOPaintInpainting:
"""IOPaint 서버 연동 인페인팅 모델 (REST API /api/v1/inpaint 사용, 바이너리 PNG 반환)"""
def __init__(self, server_url="http://localhost:8080"):
self.api_url = f"http://localhost:8080/api/v1/inpaint"
def inpaint(self, image: np.ndarray, mask: np.ndarray, api_url:str = 'http://localhost:8080/api/v1/inpaint', ) -> np.ndarray:
# 이미지를 base64로 인코딩
_, img_encoded = cv2.imencode('.png', image)
_, mask_encoded = cv2.imencode('.png', mask)
img_b64 = base64.b64encode(img_encoded).decode('utf-8')
mask_b64 = base64.b64encode(mask_encoded).decode('utf-8')
payload = {
"image": img_b64,
"mask": mask_b64
}
response = requests.post(api_url, json=payload)
if response.status_code != 200:
print("IOPaint 서버 에러:", response.text)
return None
# 응답이 바이너리 PNG 이미지이므로 바로 디코딩
nparr = np.frombuffer(response.content, np.uint8)
result = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
return result

View File

@ -0,0 +1,211 @@
import numpy as np
import requests
import cv2
import base64
import subprocess
import random
import time
import threading
import os
import logging
class IOPaintManager:
"""IOPaint 서버 인스턴스 및 인페인팅 요청을 통합 관리하는 매니저"""
class ServerInstance:
def __init__(self, port, process):
self.port = port
self.process = process
self.busy = False
self.last_used = time.time()
def mark_busy(self):
self.busy = True
self.last_used = time.time()
def mark_idle(self):
self.busy = False
self.last_used = time.time()
def is_alive(self):
return self.process.poll() is None
def __init__(self, logger, num_instances=1, port_range=(8099, 8199), base_dir=None, wait_ready=30, model_dir=None):
self.logger = logger
self.instances = []
self.port_range = port_range
self.lock = threading.Lock()
self.base_dir = base_dir or os.getcwd()
self.model_dir = model_dir or os.path.join(self.base_dir, 'iop', 'models')
self.exe_path = os.path.join(self.base_dir, 'iop', 'iop.exe')
self._start_instances(num_instances, wait_ready)
def _get_random_port(self):
used_ports = {inst.port for inst in self.instances}
candidates = [p for p in range(self.port_range[0], self.port_range[1]+1) if p not in used_ports]
if not candidates:
self.logger.log("사용 가능한 포트가 없습니다.", level=logging.ERROR)
raise RuntimeError("사용 가능한 포트가 없습니다.")
return random.choice(candidates)
def wait_for_server_ready(self, port, timeout=30):
url = f"http://localhost:{port}/api/v1/server-config"
start = time.time()
last_error = None
self.logger.log(f"[{port}] 서버 준비 체크 시작 (최대 {timeout}초 대기)", level=logging.INFO)
tries = 0
while time.time() - start < timeout:
tries += 1
try:
r = requests.get(url, timeout=2)
self.logger.log(f"응답 : {r}", level=logging.INFO)
if r.status_code == 200:
elapsed = time.time() - start
self.logger.log(f"[{port}] 서버 준비 완료! (시도 {tries}회, {elapsed:.1f}초 소요)", level=logging.INFO)
return True
else:
self.logger.log(f"[{port}] 응답 코드: {r.status_code}", level=logging.INFO)
except Exception as e:
last_error = str(e)
self.logger.log(f"[{port}] 준비 체크 실패 (시도 {tries}회): {last_error}", level=logging.ERROR, exc_info=True)
time.sleep(0.5)
self.logger.log(f"[{port}] 서버 준비 실패 (총 {tries}회 시도, 마지막 에러: {last_error})", level=logging.ERROR, exc_info=True)
return False
def _start_instances(self, num, wait_ready):
self.logger.log(f"IOPaint 인스턴스 {num} 개 시작", level=logging.INFO)
try:
import torch
device_type = "cuda" if torch.cuda.is_available() else "cpu"
except Exception as e:
self.logger.log(f"torch import 또는 GPU 체크 실패: {e}", level=logging.WARNING)
device_type = "cpu"
for _ in range(num):
port = self._get_random_port()
cmd = [self.exe_path, 'start', '--model=lama', f'--device={device_type}', '--port', str(port), '--model-dir', self.model_dir]
self.logger.log(f"[{port}] 인스턴스 실행 명령: {' '.join(cmd)}", level=logging.INFO)
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
instance = self.ServerInstance(port, proc)
self.instances.append(instance)
start_wait = 8
time.sleep(start_wait)
self.logger.log(f"[{port}] 인스턴스 실행 명시대기: {start_wait}", level=logging.INFO)
if self.wait_for_server_ready(port, timeout=wait_ready):
self.logger.log(f"IOPaint 인스턴스 {instance.port} 준비됨", level=logging.INFO)
else:
self.logger.log(f"IOPaint 인스턴스 {instance.port} 시작 실패", level=logging.ERROR)
# 에러 메시지 출력
try:
out, err = proc.communicate(timeout=3)
self.logger.log(f"[{port}] 표준출력:\n{out.decode(errors='ignore')}", level=logging.INFO)
self.logger.log(f"[{port}] 표준에러:\n{err.decode(errors='ignore')}", level=logging.INFO)
except Exception as e:
self.logger.log(f"[{port}] 에러 메시지 읽기 실패: {e}", level=logging.ERROR)
def get_instance_info(self):
"""모든 인스턴스의 정보를 반환"""
info = []
for inst in self.instances:
info.append({
"port": inst.port,
"busy": inst.busy,
"alive": inst.is_alive(),
"last_used": inst.last_used
})
return info
def get_idle_instance(self):
"""놀고 있는(사용 가능한) 인스턴스 반환 (없으면 None)"""
with self.lock:
for inst in self.instances:
if not inst.busy and inst.is_alive():
inst.mark_busy()
self.logger.log(f"IOPaint 인스턴스 {inst.port} 사용 중", level=logging.INFO)
return inst
return None
def mark_instance_idle(self, port):
"""작업이 끝난 인스턴스를 idle로 표시"""
for inst in self.instances:
if inst.port == port:
inst.mark_idle()
self.logger.log(f"IOPaint 인스턴스 {inst.port} 유휴", level=logging.INFO)
break
def shutdown_all(self):
"""모든 서버 인스턴스 종료"""
for inst in self.instances:
if inst.is_alive():
inst.process.terminate()
self.logger.log(f"IOPaint 인스턴스 {inst.port} 종료", level=logging.INFO)
self.instances = []
self.logger.log("모든 IOPaint 인스턴스 종료", level=logging.INFO)
def inpaint(self, image, mask, instance=None) -> np.ndarray:
"""image와 mask를 경로나 np.ndarray 모두 지원"""
# 이미지 처리
if isinstance(image, str):
image_np = cv2.imread(image)
if image_np is None:
self.logger.log(f"이미지 로딩 실패: {image}", level=logging.ERROR)
return None
else:
image_np = image
# 마스크 처리
if isinstance(mask, str):
mask_np = cv2.imread(mask, cv2.IMREAD_GRAYSCALE)
if mask_np is None:
self.logger.log(f"마스크 로딩 실패: {mask}", level=logging.ERROR)
return None
else:
mask_np = mask
if instance is None:
instance = self.get_idle_instance()
if instance is None:
self.logger.log("사용 가능한 IOPaint 인스턴스가 없습니다.", level=logging.ERROR)
return None
api_url = f"http://localhost:{instance.port}/api/v1/inpaint"
self.logger.log(f"IOPaint 인스턴스 {instance.port} 사용", level=logging.INFO)
try:
_, img_encoded = cv2.imencode('.png', image_np)
_, mask_encoded = cv2.imencode('.png', mask_np)
img_b64 = base64.b64encode(img_encoded).decode('utf-8')
mask_b64 = base64.b64encode(mask_encoded).decode('utf-8')
payload = {
"image": img_b64,
"mask": mask_b64
}
response = requests.post(api_url, json=payload)
if response.status_code != 200:
self.logger.log(f"IOPaint 서버 에러: {response.text}", level=logging.ERROR)
return None
nparr = np.frombuffer(response.content, np.uint8)
result = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
return result
finally:
self.mark_instance_idle(instance.port)
def add_instance(self, wait_ready=30):
try:
import torch
device_type = "cuda" if torch.cuda.is_available() else "cpu"
except Exception as e:
self.logger.log(f"torch import 또는 GPU 체크 실패: {e}", level=logging.WARNING)
device_type = "cpu"
port = self._get_random_port()
cmd = [self.exe_path, 'start', '--model=lama', f'--device={device_type}', '--port', str(port), '--model-dir', self.model_dir]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
instance = self.ServerInstance(port, proc)
self.instances.append(instance)
if self.wait_for_server_ready(port, timeout=wait_ready):
self.logger.log(f"IOPaint 인스턴스 {instance.port} 시작", level=logging.INFO)
else:
self.logger.log(f"IOPaint 인스턴스 {instance.port} 시작 실패", level=logging.ERROR)
return instance
# if __name__ == '__main__':
# manager = IOPaintManager(num_instances=1)
# # result = manager.inpaint(image, mask) # 자동으로 idle 인스턴스에 요청
# print(manager.get_instance_info())
# manager.shutdown_all()

View File

@ -0,0 +1,30 @@
from simple_lama_inpainting import SimpleLama
from PIL import Image
import numpy as np
import cv2
def inpaint_with_simple_lama(image, mask, device="cuda"):
"""
simple-lama-inpainting을 사용해 인페인팅을 수행합니다.
image: 파일 경로(str), np.ndarray, 또는 PIL.Image.Image
mask: 파일 경로(str), np.ndarray, 또는 PIL.Image.Image (흑백)
device: "cuda" 또는 "cpu"
return: np.ndarray (BGR)
"""
# 이미지 로딩
if isinstance(image, str):
image = Image.open(image)
elif isinstance(image, np.ndarray):
image = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
# mask도 동일하게 처리
if isinstance(mask, str):
mask = Image.open(mask)
elif isinstance(mask, np.ndarray):
mask = Image.fromarray(mask)
# 인페인팅
simple_lama = SimpleLama(device=device)
result = simple_lama(image, mask)
# PIL.Image -> np.ndarray(BGR)
result_np = cv2.cvtColor(np.array(result), cv2.COLOR_RGB2BGR)
return result_np

View File

@ -0,0 +1,131 @@
import os
import socket
import threading
from http.server import HTTPServer, SimpleHTTPRequestHandler
import logging
class LocalImageServer:
"""로컬 이미지 파일을 웹에서 접근 가능하도록 하는 HTTP 서버"""
def __init__(self, logger, image_dir, port=8000):
self.logger = logger
self.image_dir = os.path.abspath(image_dir) # 절대 경로로 변환
self.original_cwd = os.getcwd() # 원래 작업 디렉토리 저장
self.port = self.find_available_port(port)
self.server = None
self.server_thread = None
def find_available_port(self, start_port=8000, max_port=8100):
"""사용 가능한 포트를 찾습니다"""
for port in range(start_port, max_port + 1):
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('localhost', port))
return port
except OSError:
continue
raise RuntimeError(f"포트 {start_port}-{max_port} 범위에서 사용 가능한 포트를 찾을 수 없습니다.")
def start_server(self):
"""HTTP 서버를 시작합니다"""
if self.server_thread and self.server_thread.is_alive():
self.logger.log(f"로컬 이미지 서버가 이미 포트 {self.port}에서 실행 중입니다.", level=logging.DEBUG)
return
# 이미지 디렉토리 존재 확인
if not os.path.exists(self.image_dir):
try:
os.makedirs(self.image_dir, exist_ok=True)
self.logger.log(f"이미지 디렉토리 생성: {self.image_dir}", level=logging.INFO)
except Exception as e:
self.logger.log(f"이미지 디렉토리 생성 실패: {e}", level=logging.ERROR)
raise
try:
# 작업 디렉토리 변경 없이 CustomHandler에서 직접 경로 처리
class CustomHandler(SimpleHTTPRequestHandler):
def __init__(self, *args, image_directory=None, **kwargs):
self.image_directory = image_directory
super().__init__(*args, **kwargs)
def translate_path(self, path):
"""요청 경로를 이미지 디렉토리 내의 실제 파일 경로로 변환"""
# 기본 translate_path 호출하여 상대 경로 얻기
path = super().translate_path(path)
# 현재 작업 디렉토리 대신 이미지 디렉토리 사용
rel_path = os.path.relpath(path, os.getcwd())
return os.path.join(self.image_directory, rel_path)
def log_message(self, format, *args):
# 로그 출력을 비활성화 (너무 많은 로그 방지)
pass
def end_headers(self):
# CORS 헤더 추가
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
super().end_headers()
# 핸들러에 이미지 디렉토리 전달
def handler_factory(*args, **kwargs):
return CustomHandler(*args, image_directory=self.image_dir, **kwargs)
self.server = HTTPServer(('localhost', self.port), handler_factory)
self.server_thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.server_thread.start()
self.logger.log(f"로컬 이미지 서버가 포트 {self.port}에서 시작되었습니다. (디렉토리: {self.image_dir})", level=logging.INFO)
except Exception as e:
self.logger.log(f"로컬 웹서버 시작 실패: {e}", level=logging.ERROR)
# 실패 시 상태 정리
self.server = None
self.server_thread = None
raise
def stop_server(self):
"""HTTP 서버를 중지합니다"""
if self.server:
try:
self.server.shutdown()
self.server.server_close()
if self.server_thread and self.server_thread.is_alive():
self.server_thread.join(timeout=5)
self.logger.log("로컬 이미지 서버가 중지되었습니다.", level=logging.INFO)
except Exception as e:
self.logger.log(f"로컬 웹서버 중지 중 오류 발생: {e}", level=logging.ERROR)
finally:
self.server = None
self.server_thread = None
def restart_server(self):
"""서버를 재시작합니다"""
self.logger.log("로컬 이미지 서버 재시작 중...", level=logging.INFO)
self.stop_server()
# 새로운 포트 찾기
self.port = self.find_available_port(self.port)
self.start_server()
def get_base_url(self):
"""서버의 기본 URL을 반환합니다"""
return f"http://localhost:{self.port}"
def is_running(self):
"""서버가 실행 중인지 확인합니다"""
return self.server is not None and self.server_thread and self.server_thread.is_alive()
def get_file_url(self, filename):
"""특정 파일의 URL을 반환합니다"""
if not self.is_running():
self.logger.log("서버가 실행되지 않았습니다.", level=logging.WARNING)
return None
return f"{self.get_base_url()}/{filename}"
def __del__(self):
"""소멸자에서 서버 정리"""
try:
self.stop_server()
except:
pass

View File

@ -0,0 +1,156 @@
import logging
from logging.handlers import RotatingFileHandler, BaseRotatingHandler
import os
import glob
import traceback
import inspect
class Logger1():
def __init__(self, log_file="ITServer.log", logger_name="MainLogger",
file_log_level=logging.DEBUG):
"""
Logger 초기화
:param log_file: 로그 파일 이름
:param logger_name: 로거 이름
:param file_log_level: 파일 로거의 로그 레벨
"""
super().__init__()
self.file_log_level = file_log_level
# 로그 설정
self.logger = logging.getLogger(logger_name)
self.logger.setLevel(file_log_level) # 파일 로거 레벨 설정
# 포맷 설정
self.simple_format = "[%(asctime)s] [%(levelname)s] %(message)s"
self.detailed_format = (
"[%(asctime)s] [%(threadName)s] [%(levelname)s] "
"[%(filename)s:%(funcName)s:%(lineno)d] %(message)s"
)
# 핸들러 추가
self._add_console_handler(file_log_level)
self._add_file_handler(log_file, file_log_level)
def _add_console_handler(self, level):
"""콘솔 핸들러 추가"""
console_handler = logging.StreamHandler()
console_handler.setLevel(level)
formatter = logging.Formatter(
self.detailed_format if level <= logging.DEBUG else self.simple_format
)
console_handler.setFormatter(formatter)
self.logger.addHandler(console_handler)
def _add_file_handler(self, log_file, level):
"""파일 핸들러 추가"""
# 확장자가 .log가 아니면 .log로 변경
if not log_file.endswith('.log'):
base_name, _ = os.path.splitext(log_file)
log_file = base_name + '.log'
# 커스텀 로테이팅 핸들러 사용
file_handler = CustomRotatingFileHandler(
log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
)
file_handler.setLevel(level)
formatter = logging.Formatter(
self.detailed_format if level <= logging.DEBUG else self.simple_format
)
file_handler.setFormatter(formatter)
self.logger.addHandler(file_handler)
def log(self, message, level=logging.INFO, exc_info=False):
"""로그 메시지 기록"""
if exc_info:
message = f"{message}\n{traceback.format_exc()}"
# 호출 위치 정보를 동적으로 추출
caller_frame = logging.currentframe().f_back
record = self.logger.makeRecord(
self.logger.name, level, caller_frame.f_code.co_filename,
caller_frame.f_lineno, message, None, None, caller_frame.f_code.co_name
)
# 파일 로거에 메시지 전달
if level >= self.file_log_level:
self.logger.handle(record)
class CustomRotatingFileHandler(BaseRotatingHandler):
"""로그 파일을 모두 .log 확장자로 생성하는 커스텀 핸들러"""
def __init__(self, filename, mode='a', maxBytes=0, backupCount=0, encoding=None):
super().__init__(filename, mode, encoding)
self.maxBytes = maxBytes
self.backupCount = backupCount
# 기존 로그 파일 확인 및 인덱스 설정
self._base_filename = filename
self._extension = '.log'
def doRollover(self):
"""롤오버 수행 - 파일이 최대 크기에 도달하면 새 파일 생성"""
if self.stream:
self.stream.close()
self.stream = None
# 기존 로그 파일 이름을 기반으로 백업 파일 생성
base_name, ext = os.path.splitext(self._base_filename)
# 현재 디렉토리의 모든 로그 파일 확인
log_dir = os.path.dirname(self._base_filename) or '.'
existing_logs = glob.glob(f"{base_name}*.log")
existing_logs.sort()
# 최대 백업 수 초과하는 파일 제거
while len(existing_logs) >= self.backupCount:
try:
oldest_file = existing_logs.pop(0)
os.remove(oldest_file)
except Exception:
pass
# 새 로그 파일 이름 생성 (주 로그 파일 이름은 그대로 유지)
# 예: log.log, log_1.log, log_2.log, ...
if os.path.exists(self._base_filename):
# 인덱스가 있는 로그 파일들 찾기
indexed_logs = [f for f in existing_logs if f != self._base_filename]
max_index = 0
for log_file in indexed_logs:
try:
# 파일 이름에서 인덱스 부분 추출
name_part = os.path.basename(log_file)
name_without_ext = os.path.splitext(name_part)[0]
if '_' in name_without_ext:
idx_str = name_without_ext.split('_')[-1]
if idx_str.isdigit():
max_index = max(max_index, int(idx_str))
except Exception:
pass
# 새 인덱스로 파일 이름 설정
new_index = max_index + 1
new_log_file = f"{base_name}_{new_index}.log"
# 기존 파일 이름 변경
try:
os.rename(self._base_filename, new_log_file)
except Exception:
pass
# 스트림 다시 열기
self.mode = 'w'
self.stream = self._open()
def shouldRollover(self, record):
"""롤오버가 필요한지 확인"""
if self.stream is None: # 첫 번째 로그 쓰기 시도
self.stream = self._open()
if self.maxBytes > 0: # 최대 크기가 지정된 경우만 검사
self.stream.seek(0, 2) # 파일 끝으로 이동
if self.stream.tell() >= self.maxBytes:
return True
return False

View File

@ -0,0 +1,44 @@
import cv2
import numpy as np
from typing import List, Dict, Any
from shapely.geometry import Polygon
import logging
class MaskModule:
def __init__(self, logger, base_dir):
self.logger = logger
self.base_dir = base_dir
self.logger.log("마스크 모듈 초기화 완료", level=logging.INFO)
def create_masks(self, image_path: str, ocr_results: List[Dict], expansion_size: int = 10, blur_size: int = 15, mask_option: str = "basic") -> np.ndarray:
image = cv2.imread(image_path)
if image is None:
self.logger.log(f"이미지를 읽을 수 없습니다: {image_path}", level=logging.ERROR)
return None
height, width = image.shape[:2]
mask = np.zeros((height, width), dtype=np.uint8)
for i, result in enumerate(ocr_results, 1):
polygon = result['polygon']
expanded_poly = self.expand_polygon(polygon, offset=5)
cv2.fillPoly(mask, [expanded_poly], 255)
processed_mask = self.process_mask(mask, expansion_size, blur_size)
return processed_mask
def expand_polygon(self, polygon, offset=15):
poly = Polygon(polygon)
expanded = poly.buffer(offset)
if expanded.is_empty:
return np.array(polygon, dtype=np.int32)
return np.array(expanded.exterior.coords, dtype=np.int32)
def process_mask(self, mask: np.ndarray, expansion_size: int = 5, blur_size: int = 3) -> np.ndarray:
processed_mask = mask.copy()
if expansion_size > 0:
kernel = np.ones((expansion_size, expansion_size), np.uint8)
processed_mask = cv2.dilate(processed_mask, kernel, iterations=1)
if blur_size > 0:
blur_size = blur_size if blur_size % 2 == 1 else blur_size + 1
processed_mask = cv2.GaussianBlur(processed_mask, (blur_size, blur_size), 0)
return processed_mask

View File

@ -0,0 +1,350 @@
import cv2
import numpy as np
import os
import logging
from typing import List, Dict, Any
class OCRModule:
def __init__(self, logger=None, base_dir=None):
self.logger = logger
self.base_dir = base_dir
# CUDA 사용 가능하도록 환경 변수 설정 제거
# os.environ['CUDA_VISIBLE_DEVICES'] = ''
self.ocr = None
self.ocr = self.initialize_ocr()
if self.ocr is None:
raise Exception("PaddleOCR 초기화 실패")
def initialize_ocr(self):
"""
PaddleOCR 초기화. det_enabled 옵션에 따라 Detection 모델 사용 여부 결정.
"""
# 모델 디렉토리 설정
self.rec_model_dir = os.path.join(self.base_dir, "modules", "PP_Models", "rec")
self.det_model_dir = os.path.join(self.base_dir, "modules", "PP_Models", "det")
self.cls_model_dir = os.path.join(self.base_dir, "modules", "PP_Models", "cls")
try:
from paddleocr import PaddleOCR
import paddle
use_gpu = False
use_tensorrt = False
try:
use_gpu = paddle.is_compiled_with_cuda() and paddle.device.is_compiled_with_cuda()
# TensorRT 사용 가능 여부 확인
if use_gpu:
try:
import tensorrt as trt
use_tensorrt = True
self.logger.log(f"TensorRT 사용 가능: {trt.__version__}", level=logging.INFO)
except ImportError:
self.logger.log("TensorRT 패키지가 설치되지 않음", level=logging.WARNING)
use_tensorrt = False
except Exception as e:
self.logger.log(f"GPU 사용 가능 여부 확인 중 오류: {e}", level=logging.WARNING)
use_gpu = False
use_tensorrt = False
self.logger.log(f"PaddleOCR use_gpu: {use_gpu}, use_tensorrt: {use_tensorrt}", level=logging.INFO)
ocr = PaddleOCR(
use_gpu=use_gpu, # GPU 사용 가능하면 활성화
use_tensorrt=use_tensorrt, # TensorRT 활성화
use_angle_cls=True, # 텍스트 방향 분류 활성화
lang="ch",
precision='fp16', # FP16 정밀도 사용
use_mp=True, # 멀티프로세스 활성화
show_log=True,
det_model_dir=self.det_model_dir,
rec_model_dir=self.rec_model_dir,
cls_model_dir=self.cls_model_dir
)
return ocr
except Exception as e:
self.logger.log(f"❌ PaddleOCR 초기화 실패: {e}", level=logging.ERROR, exc_info=True)
# raise e # 에러 발생시 프로그램 종료
return None
def detect_text(self, image_path: str, method: str = 'polygon') -> List[Dict[str, Any]]:
"""
이미지에서 텍스트를 감지하고 다양한 방식으로 영역 반환
Args:
image_path (str): 이미지 파일 경로
method (str): 감지 방식 ('polygon', 'bbox', 'expanded_bbox', 'rotated_bbox', 'contour')
Returns:
List[Dict]: 감지된 텍스트 정보 리스트
- text: 감지된 텍스트
- confidence: 신뢰도
- polygon: 폴리곤 좌표 (4 )
- bbox: 바운딩 박스 좌표 (x, y, w, h)
- method: 사용된 감지 방식
"""
if not os.path.exists(image_path):
self.logger.log(f"이미지 파일을 찾을 수 없습니다: {image_path}", level=logging.ERROR)
return []
try:
# 이미지 읽기
image = cv2.imread(image_path)
if image is None:
self.logger.log(f"이미지를 읽을 수 없습니다: {image_path}", level=logging.ERROR)
return []
self.logger.log(f"🔍 OCR 감지 방식: {method}", level=logging.INFO)
# 실제 OCR 실행
# ocr_raw_results = self.ocr.predict(image)
ocr_raw_results = self.ocr.ocr(image)
self.logger.log(f"ocr_raw_results: {ocr_raw_results}", level=logging.INFO)
for line in ocr_raw_results:
self.logger.log(f"line: {line}", level=logging.INFO)
if not ocr_raw_results or len(ocr_raw_results) == 0:
self.logger.log("⚠️ OCR 결과가 비어있습니다.", level=logging.WARNING)
return []
# paddleocr 2.x 결과 파싱
converted_results = []
for page in ocr_raw_results: # page는 텍스트별 결과 리스트
for line in page:
poly = line[0]
text = line[1][0]
score = line[1][1]
converted_results.append([poly, [text, score]])
# 감지 방식에 따라 결과 처리
if method == 'polygon':
ocr_results = self._detect_with_polygon(image, converted_results)
elif method == 'bbox':
ocr_results = self._detect_with_bbox(image, converted_results)
elif method == 'expanded_bbox':
ocr_results = self._detect_with_expanded_bbox(image, converted_results)
elif method == 'rotated_bbox':
ocr_results = self._detect_with_rotated_bbox(image, converted_results)
elif method == 'contour':
ocr_results = self._detect_with_contour(image, converted_results)
else:
self.logger.log(f"⚠️ 지원하지 않는 감지 방식: {method}, 기본 polygon 방식 사용", level=logging.WARNING)
ocr_results = self._detect_with_polygon(image, converted_results)
return ocr_results
except Exception as e:
self.logger.log(f"❌ OCR 처리 중 오류 발생: {e}", level=logging.ERROR, exc_info=True)
return []
def filter_chinese_text(self, ocr_results: List[Dict]) -> List[Dict]:
"""
중국어 텍스트만 필터링
Args:
ocr_results (List[Dict]): OCR 결과
Returns:
List[Dict]: 중국어 텍스트만 포함된 결과
"""
chinese_results = []
for result in ocr_results:
text = result['text']
# 중국어 문자 범위 확인 (간체/번체 포함)
if any('\u4e00' <= char <= '\u9fff' for char in text):
chinese_results.append(result)
self.logger.log(f"중국어 텍스트 {len(chinese_results)}개 필터링 완료", level=logging.INFO)
return chinese_results
def _detect_with_polygon(self, image: np.ndarray, ocr_raw_results: List) -> List[Dict[str, Any]]:
"""폴리곤 방식으로 텍스트 영역 감지 (기본 방식)"""
ocr_results = []
for line in ocr_raw_results:
if len(line) >= 2:
polygon = line[0] # 폴리곤 좌표 (4개 점)
text_info = line[1] # (텍스트, 신뢰도)
if len(text_info) >= 2:
text = text_info[0]
confidence = text_info[1]
# 폴리곤을 바운딩 박스로 변환
polygon_np = np.array(polygon, dtype=np.int32)
x, y, w, h = cv2.boundingRect(polygon_np)
ocr_result = {
'text': text,
'confidence': confidence,
'polygon': polygon,
'bbox': (x, y, w, h),
'method': 'polygon'
}
ocr_results.append(ocr_result)
return ocr_results
def _detect_with_bbox(self, image: np.ndarray, ocr_raw_results: List) -> List[Dict[str, Any]]:
"""바운딩 박스 방식으로 텍스트 영역 감지"""
ocr_results = []
for line in ocr_raw_results:
if len(line) >= 2:
polygon = line[0]
text_info = line[1]
if len(text_info) >= 2:
text = text_info[0]
confidence = text_info[1]
# 바운딩 박스 계산
polygon_np = np.array(polygon, dtype=np.int32)
x, y, w, h = cv2.boundingRect(polygon_np)
# 바운딩 박스를 폴리곤으로 변환
bbox_polygon = [
[x, y],
[x + w, y],
[x + w, y + h],
[x, y + h]
]
ocr_result = {
'text': text,
'confidence': confidence,
'polygon': bbox_polygon,
'bbox': (x, y, w, h),
'method': 'bbox'
}
ocr_results.append(ocr_result)
return ocr_results
def _detect_with_expanded_bbox(self, image: np.ndarray, ocr_raw_results: List) -> List[Dict[str, Any]]:
"""확장된 바운딩 박스 방식으로 텍스트 영역 감지"""
ocr_results = []
h_img, w_img = image.shape[:2]
for line in ocr_raw_results:
if len(line) >= 2:
polygon = line[0]
text_info = line[1]
if len(text_info) >= 2:
text = text_info[0]
confidence = text_info[1]
# 기본 바운딩 박스
polygon_np = np.array(polygon, dtype=np.int32)
x, y, w, h = cv2.boundingRect(polygon_np)
# 확장 크기 계산 (텍스트 크기의 20%)
expand_x = max(1, int(w * 0.2))
expand_y = max(1, int(h * 0.2))
# 확장된 바운딩 박스
x_exp = max(0, x - expand_x)
y_exp = max(0, y - expand_y)
w_exp = min(w_img - x_exp, w + 2 * expand_x)
h_exp = min(h_img - y_exp, h + 2 * expand_y)
# 확장된 바운딩 박스를 폴리곤으로 변환
expanded_polygon = [
[x_exp, y_exp],
[x_exp + w_exp, y_exp],
[x_exp + w_exp, y_exp + h_exp],
[x_exp, y_exp + h_exp]
]
ocr_result = {
'text': text,
'confidence': confidence,
'polygon': expanded_polygon,
'bbox': (x_exp, y_exp, w_exp, h_exp),
'method': 'expanded_bbox'
}
ocr_results.append(ocr_result)
return ocr_results
def _detect_with_rotated_bbox(self, image: np.ndarray, ocr_raw_results: List) -> List[Dict[str, Any]]:
"""회전된 바운딩 박스 방식으로 텍스트 영역 감지"""
ocr_results = []
for line in ocr_raw_results:
if len(line) >= 2:
polygon = line[0]
text_info = line[1]
if len(text_info) >= 2:
text = text_info[0]
confidence = text_info[1]
# 회전된 바운딩 박스 계산
polygon_np = np.array(polygon, dtype=np.float32)
rect = cv2.minAreaRect(polygon_np)
box = cv2.boxPoints(rect)
box = np.int32(box)
# 일반 바운딩 박스도 계산
x, y, w, h = cv2.boundingRect(polygon_np.astype(np.int32))
ocr_result = {
'text': text,
'confidence': confidence,
'polygon': box.tolist(),
'bbox': (x, y, w, h),
'method': 'rotated_bbox',
'rotation_info': {
'center': rect[0],
'size': rect[1],
'angle': rect[2]
}
}
ocr_results.append(ocr_result)
return ocr_results
def _detect_with_contour(self, image: np.ndarray, ocr_raw_results: List) -> List[Dict[str, Any]]:
"""컨투어 방식으로 텍스트 영역 감지"""
ocr_results = []
for line in ocr_raw_results:
if len(line) >= 2:
polygon = line[0]
text_info = line[1]
if len(text_info) >= 2:
text = text_info[0]
confidence = text_info[1]
# 폴리곤을 컨투어로 변환
polygon_np = np.array(polygon, dtype=np.int32)
# 컨투어 근사화
epsilon = 0.02 * cv2.arcLength(polygon_np, True)
approx_contour = cv2.approxPolyDP(polygon_np, epsilon, True)
# 컨투어를 다시 폴리곤으로 변환
contour_polygon = approx_contour.reshape(-1, 2).tolist()
# 바운딩 박스 계산
x, y, w, h = cv2.boundingRect(polygon_np)
ocr_result = {
'text': text,
'confidence': confidence,
'polygon': contour_polygon,
'bbox': (x, y, w, h),
'method': 'contour',
'contour_points': len(contour_polygon)
}
ocr_results.append(ocr_result)
return ocr_results

View File

@ -0,0 +1,160 @@
from PIL import Image, ImageFont, ImageDraw
import requests
import numpy as np
import cv2
import os
from datetime import datetime
import logging
class PostImageManager:
def __init__(self, logger, font_path):
self.logger = logger
self.font_path = font_path
# 폰트 로드
self.font_load()
def font_load(self):
# 폰트 로드
try:
self.font = ImageFont.truetype(self.font_path, 36)
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:
if image:
# 이미지를 저장 경로에 저장
self.logger.log(f"이미지 저장 완료 : {path}", level=logging.INFO)
image.save(path, format='PNG')
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:
if isinstance(image_data, np.ndarray):
image_data = Image.fromarray(cv2.cvtColor(image_data, cv2.COLOR_BGR2RGB))
# 폰트가 로드되지 않은 경우 원본 이미지 반환
if self.font is None:
self.logger.log("폰트가 로드되지 않아 워터마크를 추가할 수 없습니다. 원본 이미지를 반환합니다.", level=logging.WARNING)
return image_data
# 이미지 복사본 생성
watermark_image = image_data.copy()
# 폰트 설정 (안전한 폰트 로딩)
try:
# self.font가 있으면 크기만 조정해서 새 폰트 생성
if hasattr(self, 'font_path') and os.path.exists(self.font_path):
font = ImageFont.truetype(self.font_path, font_size)
else:
# 크기를 조정할 수 없으면 기존 폰트 사용
font = self.font
except Exception as e:
self.logger.log(f"폰트 크기 조정 실패: {e}. 기본 폰트를 사용합니다.", level=logging.WARNING)
font = self.font
# 텍스트 투명도를 0~255로 변환
opacity = int(255 * (opacity_percent / 100))
# 텍스트 크기 측정 (textbbox 사용)
draw = ImageDraw.Draw(watermark_image)
bbox = draw.textbbox((0, 0), watermark_text, font=font)
text_width, text_height = bbox[2] - bbox[0], bbox[3] - bbox[1]
# 이미지 크기
width, height = image_data.size
# 워터마크 레이어 생성
watermark_layer = Image.new("RGBA", (width, height)) # RGBA 이미지 생성
# 지그재그 간격 설정
zigzag_step = int(text_height * 2) # Y축의 지그재그 간격
# 이미지 전체에 반복적으로 워터마크 텍스트 그리기 (지그재그 형태)
for y in range(0, height, zigzag_step):
for x in range(0, width, int(text_width * 3)): # 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=font)
# 텍스트 회전
rotated_text_layer = text_layer.rotate(angle, expand=1)
# 회전된 텍스트를 워터마크 레이어에 추가
watermark_layer.paste(rotated_text_layer, (x + x_offset, y), rotated_text_layer)
# 원본 이미지와 워터마크 레이어 합성
watermark_image = Image.alpha_composite(watermark_image.convert("RGBA"), watermark_layer)
# 최종적으로 RGB 형식으로 변환 후 반환
return watermark_image.convert("RGB")
except Exception as e:
self.logger.log(f"워터마크 추가 중 오류 발생: {e}", level=logging.ERROR, exc_info=True)
return image_data
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

View File

@ -0,0 +1,17 @@
fastapi
uvicorn
pydantic
aiofiles
opencv-python
numpy
requests
pillow
openai
shapely
paddleocr==2.10.0
paddlepaddle-gpu
tensorrt
torch2trt
onnx
onnx-graphsurgeon
pycuda

View File

@ -0,0 +1,260 @@
from PySide6.QtCore import QSettings
import logging
class SettingsManager:
"""
사용자 설정값(토글, 스핀, 텍스트 ) 저장/불러오고,
각종 위젯에 따라 UI 상태까지 자동 적용하는 매니저 클래스입니다.
"""
def __init__(self, logger=None, organization="WhenRideMycar", application="EditPartTimer3"):
"""
QSettings 기반 설정 매니저 초기화
:param logger: logging.Logger 또는 print 대체용 함수
:param organization: QSettings 구분용 회사명
:param application: QSettings 구분용 앱명
"""
self.logger = logger or logging.getLogger(__name__)
self.settings = QSettings(organization, application)
self.widget_map = {} # 위젯명 → 저장키 매핑
self.toggle_dependencies = {} # 토글명 → 종속 위젯 매핑
def _log(self, message, level=logging.INFO):
"""
커스텀/표준 로거 모두 지원하는 내부 로깅 함수
"""
if hasattr(self.logger, "log") and "level" in self.logger.log.__code__.co_varnames:
# 커스텀 로거: self.logger.log(msg, level=logging.INFO)
self.logger.log(message, level=level)
else:
# 표준 로거
if level == logging.DEBUG:
self.logger.debug(message)
elif level == logging.INFO:
self.logger.info(message)
elif level == logging.WARNING:
self.logger.warning(message)
elif level == logging.ERROR:
self.logger.error(message)
elif level == logging.CRITICAL:
self.logger.critical(message)
else:
self.logger.info(message)
def bind_widgets(self, widget_map, toggle_dependencies):
"""
위젯-, 토글-종속 딕셔너리를 등록합니다.
:param widget_map: { "widget명": "저장키" }
:param toggle_dependencies: { "toggle명": { "dependents": [...], "visible": [...] } }
"""
self.widget_map = widget_map
self.toggle_dependencies = toggle_dependencies
def save_settings(self, widget_obj):
"""
현재 UI에 연결된 각종 위젯의 값을 QSettings에 저장합니다.
:param widget_obj: 실제 위젯 객체(self )
config 예시:
{
'discord_notify_toggle': {
'dependents': ['webhook_input'],
'visible': ['webhook_input'],
},
'ocr_toggle': {
'dependents': ['unwanted_words_button']
},
...
}
"""
for widget_name, key in self.widget_map.items():
widget = getattr(widget_obj, widget_name, None)
if widget is None:
self._log(f"[SettingsManager] '{widget_name}' 위젯을 찾을 수 없습니다.", logging.WARNING)
continue
try:
# 체크박스, 토글, 커스텀토글 등
if hasattr(widget, 'isChecked'):
value = bool(widget.isChecked())
self.logger.log(f"[SettingsManager] bool 타입 저장: {key} 값 저장: {value}", level=logging.DEBUG)
self.settings.setValue(key, value)
# SpinBox/DoubleSpinBox 등
elif hasattr(widget, 'value'):
self.settings.setValue(key, widget.value())
self.logger.log(f"[SettingsManager] int/float 타입 저장: {key} 값 저장: {widget.value()}", level=logging.DEBUG)
# QLineEdit 등 (단일줄 텍스트)
elif hasattr(widget, 'text'):
self.settings.setValue(key, widget.text())
self.logger.log(f"[SettingsManager] str 타입 저장: {key} 값 저장: {widget.text()}", level=logging.DEBUG)
# QTextEdit 등 (여러줄 텍스트)
elif hasattr(widget, 'toPlainText'):
self.settings.setValue(key, widget.toPlainText())
self.logger.log(f"[SettingsManager] str 타입 저장: {key} 값 저장: {widget.toPlainText()}", level=logging.DEBUG)
else:
self._log(f"[SettingsManager] '{widget_name}'의 값을 저장하는 방법을 알 수 없습니다.", logging.WARNING)
except Exception as e:
self._log(f"[SettingsManager] '{widget_name}' 저장 중 오류: {e}", logging.ERROR, exc_info=True)
def save_value(self, key, value):
"""특정 키로 단일 값을 설정에 저장합니다. 타입별로 정확히 저장."""
try:
# bool
if isinstance(value, bool):
self.settings.setValue(key, value)
self.logger.log(f"[SettingsManager] bool 타입 저장: {key} 값 저장: {value}", level=logging.DEBUG)
# int/float
elif isinstance(value, (int, float)):
self.settings.setValue(key, value)
self.logger.log(f"[SettingsManager] int/float 타입 저장: {key} 값 저장: {value}", level=logging.DEBUG)
# 그 외(특히 str)
else:
self.settings.setValue(key, str(value))
self.logger.log(f"[SettingsManager] str 타입 저장: {key} 값 저장: {value}", level=logging.DEBUG)
self.settings.sync()
self._log(f"[SettingsManager] {key} 값을 저장했습니다: {value}")
except Exception as e:
self._log(f"[SettingsManager] {key} 값 저장 중 오류: {e}", level=logging.WARNING)
# 기타 값 직접 접근용
def get_value(self, key, default=None):
return self.settings.value(key, default)
def load_settings(self, widget_obj):
"""
QSettings에서 저장된 값을 위젯에 복원합니다.
:param widget_obj: 실제 위젯 객체(self )
"""
for widget_name, key in self.widget_map.items():
widget = getattr(widget_obj, widget_name, None)
if widget is None:
self._log(f"[SettingsManager] '{widget_name}' 위젯을 찾을 수 없습니다.", logging.WARNING)
continue
val = self.settings.value(key, None)
if val is None:
continue # 미저장 값
try:
# 체크박스/토글 등 (bool 변환)
if hasattr(widget, 'setChecked'):
# QSettings는 bool을 str로 저장할 수 있어 안전하게 변환
if isinstance(val, bool):
widget.setChecked(val)
elif isinstance(val, (int, float)):
widget.setChecked(bool(val))
elif isinstance(val, str):
widget.setChecked(val.lower() in ['true', '1', 'yes'])
# SpinBox류
elif hasattr(widget, 'setValue'):
if isinstance(val, (int, float)):
widget.setValue(val)
elif isinstance(val, str):
if '.' in val:
widget.setValue(float(val))
else:
widget.setValue(int(val))
# QLineEdit 등
elif hasattr(widget, 'setText'):
widget.setText(str(val))
# QTextEdit 등
elif hasattr(widget, 'setPlainText'):
widget.setPlainText(str(val))
else:
self._log(f"[SettingsManager] '{widget_name}'에 값을 복원할 수 없습니다.", logging.WARNING)
except Exception as e:
self._log(f"[SettingsManager] '{widget_name}' 불러오기 오류: {e}", logging.ERROR, exc_info=True)
def reset_settings(self):
"""
전체 QSettings 값을 초기화(삭제)합니다.
"""
try:
self.settings.clear()
self._log("[SettingsManager] 모든 설정이 초기화되었습니다.", logging.INFO)
except Exception as e:
self._log(f"[SettingsManager] 설정 초기화 실패: {e}", logging.ERROR, exc_info=True)
def remove_setting(self, key):
"""
특정 키의 설정만 삭제합니다.
:param key: 삭제할 설정 (str)
"""
try:
self.settings.remove(key)
self._log(f"[SettingsManager] '{key}' 설정이 삭제되었습니다.", logging.INFO)
except Exception as e:
self._log(f"[SettingsManager] '{key}' 삭제 실패: {e}", logging.ERROR, exc_info=True)
def debug_print(self):
"""
QSettings에 저장된 모든 값을 로그로 출력합니다.
"""
try:
self.settings.sync()
all_keys = self.settings.allKeys()
self.logger.info("[SettingsManager] 저장된 모든 설정값:")
for key in all_keys:
value = self.settings.value(key)
self.logger.info(f" {key}: {value}")
except Exception as e:
self._log(f"[SettingsManager] 설정값 출력 실패: {e}", logging.ERROR, exc_info=True)
def apply_settings_to_ui(self, widget_obj):
"""
종속 딕셔너리(toggle_dependencies) 따라,
토글/체크박스 상태에 따라 dependents/visible 위젯을 자동으로 활성/비활성, 표시/숨김 처리합니다.
:param widget_obj: 실제 위젯 객체(self )
"""
for toggle_name, deps in self.toggle_dependencies.items():
toggle_widget = getattr(widget_obj, toggle_name, None)
if toggle_widget is None or not hasattr(toggle_widget, "isChecked"):
continue
checked = toggle_widget.isChecked()
# 종속 위젯 enable/disable
for dep in deps.get("dependents", []):
dep_widget = getattr(widget_obj, dep, None)
if dep_widget and hasattr(dep_widget, "setEnabled"):
try:
dep_widget.setEnabled(checked)
except Exception as e:
self._log(f"[SettingsManager] {dep} setEnabled 실패: {e}", logging.ERROR, exc_info=True)
# 종속 위젯 visible
for vis in deps.get("visible", []):
vis_widget = getattr(widget_obj, vis, None)
if vis_widget and hasattr(vis_widget, "setVisible"):
try:
vis_widget.setVisible(checked)
except Exception as e:
self._log(f"[SettingsManager] {vis} setVisible 실패: {e}", logging.ERROR, exc_info=True)
# (필요 시) 확장: 토글에 따라 특정 값을 리셋, 콜백 트리거 등
def save_user_info(self, user_info: dict):
for key, value in user_info.items():
self.settings.setValue(f"user/{key}", value)
self.settings.sync()
def load_user_info(self) -> dict:
info = {}
for key in ["email", "password", "id", "membership_level", "name"]:
info[key] = self.settings.value(f"user/{key}", "")
return info

View File

@ -0,0 +1,134 @@
import os
import sys
import asyncio
import shutil
from modules.image_processor2 import ImageProcessor
from modules.loggerModule import Logger1
from modules.gpt_client import GPTClient
import logging
import cv2
# 더미 Logger
class DummyLogger:
def log(self, msg, level=logging.INFO, exc_info=None):
print(f"[{logging.getLevelName(level)}] {msg}")
# 테스트용 치환단어
unwanted_texts = {
'크리스탈': '이미지삭제',
'세탁기': '세탁기는개뿔',
}
def get_image_list(img_dir):
files = [f for f in os.listdir(img_dir) if f.lower().endswith(('.jpg', '.jpeg', '.png', '.bmp', '.webp'))]
files.sort()
return [os.path.join(img_dir, f) for f in files]
def ensure_dir(path):
if not os.path.exists(path):
os.makedirs(path)
def save_image(image, path):
cv2.imwrite(path, image)
async def sequential_process(image_paths, processor, output_dir):
print("[순차처리] 시작")
results = []
for idx, img_path in enumerate(image_paths):
print(f"[{idx+1}] {img_path} 처리 중...")
# OCR, 번역, 치환, 인페인팅 등 전체 파이프라인 실행
# process_single_image는 내부적으로 모든 로직을 처리함
result = await processor.process_single_image(
page=None, # 실제 Playwright 객체 대신 None
original_image_url=img_path,
index=idx,
is_localServer=True,
delay=0.1,
file_prefix="seq",
use_inpainting=True
)
# 결과 파일명 결정
if isinstance(result, dict):
status = result.get('status', 'unknown')
path = result.get('path', img_path)
if status == 'failed':
out_name = f"{idx+1}_failed_{os.path.basename(img_path)}"
shutil.copy(img_path, os.path.join(output_dir, out_name))
elif status == 'exclude':
# 이미지삭제: 파일을 output에 저장하지 않음
print(f"[{idx+1}] 이미지삭제로 제외됨: {img_path}")
continue
else:
out_name = f"{idx+1}_{status}_{os.path.basename(img_path)}"
shutil.copy(path, os.path.join(output_dir, out_name))
else:
# result가 경로(str)라면 원본/번역된 이미지로 간주
out_name = f"{idx+1}_original_{os.path.basename(img_path)}"
shutil.copy(result, os.path.join(output_dir, out_name))
results.append(out_name)
print("[순차처리] 완료")
return results
async def parallel_process(image_paths, processor, output_dir):
print("[동시처리] 시작")
tasks = []
for idx, img_path in enumerate(image_paths):
tasks.append(processor.process_single_image(
page=None,
original_image_url=img_path,
index=idx,
is_localServer=True,
delay=0.1,
file_prefix="par",
use_inpainting=True
))
results = await asyncio.gather(*tasks)
for idx, (img_path, result) in enumerate(zip(image_paths, results)):
if isinstance(result, dict):
status = result.get('status', 'unknown')
path = result.get('path', img_path)
if status == 'failed':
out_name = f"{idx+1}_failed_{os.path.basename(img_path)}"
shutil.copy(img_path, os.path.join(output_dir, out_name))
elif status == 'exclude':
print(f"[{idx+1}] 이미지삭제로 제외됨: {img_path}")
continue
else:
out_name = f"{idx+1}_{status}_{os.path.basename(img_path)}"
shutil.copy(path, os.path.join(output_dir, out_name))
else:
out_name = f"{idx+1}_original_{os.path.basename(img_path)}"
shutil.copy(result, os.path.join(output_dir, out_name))
print("[동시처리] 완료")
return results
async def main():
base_dir = os.path.dirname(os.path.abspath(__file__))
img_dir = os.path.join(base_dir, 'img')
output_dir = os.path.join(base_dir, 'output')
ensure_dir(output_dir)
image_paths = get_image_list(img_dir)
print(f"테스트 이미지: {image_paths}")
# 더미 logger, gpt_client, toggle_states
logger = DummyLogger()
set_log = Logger1()
gpt_client = GPTClient()
toggle_states = {
'image_font_path': os.path.join(base_dir, "HakgyoansimDunggeunmisoTTFB.ttf"),
'TEMP_IMAGE_DIR': output_dir,
'ocr': True,
'watermark_text': '테스트워터마크',
}
processor = ImageProcessor(set_log, None, toggle_states, gpt_client, base_dir)
processor.update_unwanted_texts(unwanted_texts)
print("1. 순차처리 테스트")
await sequential_process(image_paths, processor, output_dir)
print("2. 동시처리 테스트")
await parallel_process(image_paths, processor, output_dir)
if __name__ == '__main__':
asyncio.run(main())

View File

@ -0,0 +1,80 @@
import requests
import json
import base64
import os
import cv2
import numpy as np
API_URL = "http://127.0.0.1:7000/translate_image"
# 방법 1: 로컬 이미지 경로 사용 (기존 방식)
def test_with_path():
payload = {
"local_image_path": "/home/ckh08045/work/IT_Server/modules/img/6.jpg",
"file_prefix": "test_path",
"toggle_states": {"ocr": True},
"unwanted_texts": {"크리스탈": "크리미"},
"watermark_text": "테스트 워터마크",
"watermark_opacity": 0.5,
"watermark_font_size": 32
}
headers = {"Content-Type": "application/json"}
response = requests.post(API_URL, data=json.dumps(payload), headers=headers)
print("=== 경로 사용 테스트 ===")
print("응답 결과:")
print(response.status_code)
# print(response.json())
# 결과 이미지를 temp_image 폴더에 PNG로 저장
result = response.json()
# 예시: result["image"]에 base64 인코딩된 이미지가 있다고 가정
image_b64 = result.get("image")
if image_b64:
os.makedirs("temp_image", exist_ok=True)
image_bytes = base64.b64decode(image_b64)
nparr = np.frombuffer(image_bytes, np.uint8)
img_np = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if img_np is not None:
out_path = os.path.join("temp_image", "result_path.png")
cv2.imwrite(out_path, img_np)
print(f"이미지 저장 완료: {out_path}")
else:
print("이미지 디코딩 실패")
else:
print("응답에 이미지 데이터가 없습니다.")
# 방법 2: base64 이미지 데이터 사용 (새로운 방식)
def test_with_base64():
# 이미지 파일을 base64로 인코딩
image_path = "/home/ckh08045/work/IT_Server/modules/img/6.jpg"
try:
with open(image_path, "rb") as f:
image_bytes = f.read()
image_base64 = base64.b64encode(image_bytes).decode('utf-8')
except FileNotFoundError:
print(f"이미지 파일을 찾을 수 없습니다: {image_path}")
return
payload = {
"image_data": image_base64, # base64 데이터 사용
"file_prefix": "test_base64",
"toggle_states": {"ocr": True},
"unwanted_texts": {"크리스탈": "크리미"},
"watermark_text": "테스트 워터마크",
"watermark_opacity": 0.5,
"watermark_font_size": 32
}
headers = {"Content-Type": "application/json"}
response = requests.post(API_URL, data=json.dumps(payload), headers=headers)
print("\n=== base64 데이터 사용 테스트 ===")
print("응답 결과:")
print(response.status_code)
print(response.json())
if __name__ == "__main__":
test_with_path()
# test_with_base64()

View File

@ -0,0 +1,83 @@
import requests
import json
import base64
import os
# API_URL = "http://192.168.0.150:7000/translate_image"
API_URL = "http://127.0.0.1:7000/translate_image"
# 이미지 파일을 base64로 변환하는 함수
def image_to_base64(image_path):
"""이미지 파일을 base64 문자열로 변환"""
if not os.path.exists(image_path):
raise FileNotFoundError(f"이미지 파일을 찾을 수 없습니다: {image_path}")
with open(image_path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
return encoded_string
# base64 데이터를 이미지 파일로 저장하는 함수
def base64_to_image(base64_data, output_path):
"""base64 문자열을 이미지 파일로 저장"""
try:
image_data = base64.b64decode(base64_data)
with open(output_path, "wb") as image_file:
image_file.write(image_data)
return True
except Exception as e:
print(f"이미지 저장 중 오류: {e}")
return False
# 이미지 파일 경로
image_path = "d:/py/IT_Server/modules/img/6.jpg"
# 이미지를 base64로 변환
try:
image_base64 = image_to_base64(image_path)
print(f"이미지 파일 '{image_path}' 를 base64로 변환했습니다.")
print(f"Base64 길이: {len(image_base64)} 문자")
except FileNotFoundError as e:
print(f"오류: {e}")
exit(1)
payload = {
"image_data": image_base64, # 필드명을 image_data로 수정
"file_prefix": "test",
"toggle_states": {"ocr": True},
"unwanted_texts": {"크리스탈": "크리미"},
"watermark_text": "테스트 워터마크",
"watermark_opacity": 0.5,
"watermark_font_size": 32
}
headers = {"Content-Type": "application/json"}
print("API 요청을 보내는 중...")
response = requests.post(API_URL, data=json.dumps(payload), headers=headers)
print("응답 결과:")
print(f"상태 코드: {response.status_code}")
try:
response_data = response.json()
print("응답 내용:")
if "error" in response_data:
print(f"오류: {response_data['error']}")
elif "result" in response_data and response_data.get("format") == "base64":
result_base64 = response_data["result"]
print(f"처리된 이미지 base64 길이: {len(result_base64)} 문자")
# 결과 이미지를 파일로 저장
output_path = "d:/py/IT_Server/modules/translated_result.png"
if base64_to_image(result_base64, output_path):
print(f"처리된 이미지가 저장되었습니다: {output_path}")
else:
print("이미지 저장에 실패했습니다.")
else:
print("예상치 못한 응답 형식:")
print(json.dumps(response_data, indent=2, ensure_ascii=False))
except json.JSONDecodeError:
print("JSON 응답이 아닙니다:")
print(response.text)

View File

@ -0,0 +1,32 @@
import requests
import json
import os
import time
API_URL = "http://127.0.0.1:7000/translate_images"
IMG_DIR = "/home/ckh08045/work/IT_Server/modules/img"
# img 폴더의 모든 이미지 파일 리스트업
image_files = [os.path.join(IMG_DIR, f) for f in os.listdir(IMG_DIR)
if f.lower().endswith((".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp", ".tif", ".tiff"))]
payload = {
"local_image_paths": image_files,
"file_prefix": "multi",
"toggle_states": {"ocr": True},
"unwanted_texts": {"크리스탈": "크리미", "세탁기": "이미지삭제"},
"watermark_text": "테스트 워터마크",
"watermark_opacity": 0.5,
"watermark_font_size": 32
}
headers = {"Content-Type": "application/json"}
start = time.time()
response = requests.post(API_URL, data=json.dumps(payload), headers=headers)
elapsed = time.time() - start
print("응답 결과:")
print(response.status_code)
print(response.json())
print(f"총 소요 시간: {elapsed:.2f}")

View File

@ -0,0 +1,252 @@
import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from typing import List, Dict, Any, Tuple, Optional
import os
import math
import logging
class TextRenderingModule:
def __init__(self, logger, font_path: Optional[str] = None):
self.logger = logger
self.font_path = font_path
self.default_font_size = 20
self.font_cache = {}
def get_font(self, size: int, font_path: Optional[str] = None) -> ImageFont.FreeTypeFont:
font_path = font_path or self.font_path
cache_key = f"{font_path}_{size}"
if cache_key not in self.font_cache:
try:
if font_path and os.path.exists(font_path):
font = ImageFont.truetype(font_path, size)
else:
font = ImageFont.load_default()
self.font_cache[cache_key] = font
except Exception as e:
print(f"폰트 로드 오류: {e}")
font = ImageFont.load_default()
self.font_cache[cache_key] = font
return self.font_cache[cache_key]
def estimate_text_size(self, text: str, font_size: int, font_path: Optional[str] = None) -> Tuple[int, int]:
font = self.get_font(font_size, font_path)
try:
bbox = font.getbbox(text)
width = bbox[2] - bbox[0]
height = bbox[3] - bbox[1]
except AttributeError:
width, height = font.getsize(text)
return width, height
def calculate_optimal_font_size(self, text: str, target_width: int, target_height: int, min_size: int = 8, max_size: int = 100, font_path: Optional[str] = None) -> int:
best_size = min_size
for size in range(min_size, max_size + 1):
width, height = self.estimate_text_size(text, size, font_path)
if width <= target_width and height <= target_height:
best_size = size
else:
break
return best_size
def _estimate_background_color(self, image: np.ndarray, x1: int, y1: int, x2: int, y2: int) -> Tuple[int, int, int]:
margin = 5
y1_exp = max(0, y1 - margin)
y2_exp = min(image.shape[0], y2 + margin)
x1_exp = max(0, x1 - margin)
x2_exp = min(image.shape[1], x2 + margin)
region = image[y1_exp:y2_exp, x1_exp:x2_exp]
mean_color = np.mean(region, axis=(0, 1))
return (int(mean_color[2]), int(mean_color[1]), int(mean_color[0]))
def _get_contrasting_color(self, bg_color: Tuple[int, int, int]) -> Tuple[int, int, int]:
brightness = (bg_color[0] * 0.299 + bg_color[1] * 0.587 + bg_color[2] * 0.114)
if brightness > 128:
return (0, 0, 0)
else:
return (255, 255, 255)
def render_text(self, image: np.ndarray, ocr_results: List[Dict], translated_texts: List[str], font_path: Optional[str] = None) -> np.ndarray:
result_image = image.copy()
for i, (ocr_result, translated_text) in enumerate(zip(ocr_results, translated_texts)):
polygon = ocr_result['polygon']
polygon_array = np.array(polygon)
x_coords = polygon_array[:, 0]
y_coords = polygon_array[:, 1]
x_min, x_max = int(np.min(x_coords)), int(np.max(x_coords))
y_min, y_max = int(np.min(y_coords)), int(np.max(y_coords))
width = x_max - x_min
height = y_max - y_min
optimal_font_size = self.calculate_optimal_font_size(translated_text, width, height, font_path=font_path)
text_width, text_height = self.estimate_text_size(translated_text, optimal_font_size, font_path)
center_x = (x_min + x_max) // 2
center_y = (y_min + y_max) // 2
text_x = center_x - text_width // 2
text_y = center_y - text_height // 2
angle = 0
if len(polygon_array) >= 2:
dx = polygon_array[1][0] - polygon_array[0][0]
dy = polygon_array[1][1] - polygon_array[0][1]
angle = math.degrees(math.atan2(dy, dx))
bg_color = self._estimate_background_color(image, x_min, y_min, x_max, y_max)
text_color = self._get_contrasting_color(bg_color)
result_image = self.render_text_on_image(
result_image, translated_text, (text_x, text_y),
font_size=optimal_font_size,
font_path=font_path,
text_color=text_color,
background_color=None,
angle=angle
)
return result_image
def render_text_on_image(self, image: np.ndarray, text: str, position: Tuple[int, int], font_size: Optional[int] = None, font_path: Optional[str] = None, text_color: Tuple[int, int, int] = (0, 0, 0), background_color: Optional[Tuple[int, int, int]] = None, angle: float = 0) -> np.ndarray:
if font_size is None:
font_size = self.default_font_size
pil_image = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
draw = ImageDraw.Draw(pil_image)
font = self.get_font(font_size, font_path)
print(f"render_text_on_image font: {font}")
text_width, text_height = self.estimate_text_size(text, font_size, font_path)
if background_color is not None:
bg_x1 = position[0] - 2
bg_y1 = position[1] - 2
bg_x2 = position[0] + text_width + 2
bg_y2 = position[1] + text_height + 2
draw.rectangle([bg_x1, bg_y1, bg_x2, bg_y2], fill=background_color)
if angle != 0:
text_image = Image.new('RGBA', (text_width + 10, text_height + 10), (255, 255, 255, 0))
text_draw = ImageDraw.Draw(text_image)
text_draw.text((5, 5), text, font=font, fill=text_color + (255,))
rotated_text = text_image.rotate(angle, expand=True)
pil_image.paste(rotated_text, position, rotated_text)
else:
draw.text(position, text, font=font, fill=text_color)
result_image = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
return result_image
def create_text_styles(self) -> Dict[str, Dict[str, Any]]:
"""다양한 텍스트 스타일 정의"""
styles = {
'default': {
'color': (0, 0, 0),
'bg_color': None,
'outline': True,
'outline_color': (255, 255, 255),
'outline_width': 1
},
'bold': {
'color': (0, 0, 0),
'bg_color': (255, 255, 255),
'outline': True,
'outline_color': (128, 128, 128),
'outline_width': 2
},
'highlight': {
'color': (255, 255, 255),
'bg_color': (255, 0, 0),
'outline': False,
'outline_color': None,
'outline_width': 0
},
'subtle': {
'color': (128, 128, 128),
'bg_color': None,
'outline': True,
'outline_color': (255, 255, 255),
'outline_width': 1
}
}
return styles
def render_with_style(self, image: np.ndarray, ocr_results: List[Dict],
translated_texts: List[str], style_name: str = 'default') -> np.ndarray:
"""스타일을 적용한 텍스트 렌더링"""
styles = self.create_text_styles()
if style_name not in styles:
print(f"알 수 없는 스타일: {style_name}")
style_name = 'default'
style = styles[style_name]
# 기본 렌더링 후 스타일 적용
result = self.render_text(image, ocr_results, translated_texts)
# 추가 스타일 처리는 여기서 구현
# (예: 그림자, 글로우 효과 등)
return result
def adjust_text_for_space(self, text: str, max_width: int, max_height: int,
font_size: int) -> Tuple[str, int]:
"""
공간에 맞게 텍스트 조정
Args:
text (str): 원본 텍스트
max_width (int): 최대 너비
max_height (int): 최대 높이
font_size (int): 폰트 크기
Returns:
Tuple[str, int]: 조정된 텍스트와 폰트 크기
"""
# 텍스트가 너무 길면 줄바꿈 또는 생략
if len(text) > 20:
# 긴 텍스트는 줄바꿈
words = text.split(' ')
if len(words) > 1:
mid = len(words) // 2
text = ' '.join(words[:mid]) + '\n' + ' '.join(words[mid:])
else:
# 단어가 하나면 생략
text = text[:15] + '...'
# 폰트 크기 조정
adjusted_font_size = font_size
while adjusted_font_size > 8:
# 실제로는 텍스트 크기를 측정해서 비교
estimated_width = len(text) * adjusted_font_size * 0.6
if estimated_width <= max_width:
break
adjusted_font_size -= 2
return text, adjusted_font_size
def _create_style_comparison(self, images: List[np.ndarray], style_names: List[str]):
"""스타일 비교 이미지 생성"""
if not images:
return
# 이미지 크기 조정
target_width = 200
target_height = int(images[0].shape[0] * target_width / images[0].shape[1])
resized_images = []
for img in images:
resized = cv2.resize(img, (target_width, target_height))
resized_images.append(resized)
# 비교 이미지 생성
num_images = len(resized_images)
comparison_width = target_width * num_images
comparison_height = target_height + 30
comparison = np.ones((comparison_height, comparison_width, 3), dtype=np.uint8) * 255
# 원본 이미지
comparison[30:30+target_height, 0:target_width] = resized_images[0]
cv2.putText(comparison, "Original", (10, 20),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1)
# 스타일 이미지들
for i, (img, style_name) in enumerate(zip(resized_images[1:], style_names)):
x_offset = target_width * (i + 1)
comparison[30:30+target_height, x_offset:x_offset+target_width] = img
cv2.putText(comparison, style_name, (x_offset + 10, 20),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1)
cv2.imwrite("test_output/text_style_comparison.jpg", comparison)
self.logger.log("스타일 비교 이미지 저장 완료", level=logging.INFO)

BIN
MST_P2M_simp.onnx Normal file

Binary file not shown.

130
README.md Normal file
View File

@ -0,0 +1,130 @@
# 이미지 처리 워커 시스템
메인서버와 분리된 독립적인 이미지 처리 워커 시스템입니다.
## 🏗️ 아키텍처
```
클라이언트 → 메인서버 (FastAPI) → Redis → 워커 시스템 (ImageProcessor)
```
- **메인서버**: 클라이언트 요청 중계, 인증, API 제공
- **워커**: 실제 OCR, 번역, 인페인팅 작업 처리
- **Redis**: 작업 큐 및 결과 저장소
## 🚀 실행 방법
### 1. Docker 모드 (권장)
```bash
cd ~/work/worker-system
WORKER_MODE=docker ./start_worker.sh
```
### 2. 로컬 모드
```bash
cd ~/work/worker-system
WORKER_MODE=local ./start_worker.sh
```
### 3. 원격 모드
```bash
cd ~/work/worker-system
REDIS_URL="redis://메인서버IP:6379/0" WORKER_MODE=remote ./start_worker.sh
```
## ⚙️ 환경 변수
| 변수 | 설명 | 기본값 |
|------|------|--------|
| `REDIS_URL` | Redis 서버 주소 | `redis://localhost:6379/0` |
| `MAIN_SERVER_HOST` | 메인 서버 주소 | `localhost` |
| `CONCURRENCY` | 동시 처리 작업 수 | `2` |
| `WORKER_MODE` | 실행 모드 | `docker` |
## 📋 사용 예시
### 로컬에서 실행
```bash
# 기본 설정으로 실행
./start_worker.sh
# 동시 처리 수 조정
CONCURRENCY=4 ./start_worker.sh
# 원격 메인서버에 연결
REDIS_URL="redis://192.168.1.100:6379/0" MAIN_SERVER_HOST="192.168.1.100" ./start_worker.sh
```
### 원격 서버에서 실행
```bash
# 원격 서버에 워커 시스템 배포
scp -r ~/work/worker-system user@remote-server:~/
# 원격 서버에서 실행
ssh user@remote-server
cd ~/worker-system
REDIS_URL="redis://메인서버IP:6379/0" WORKER_MODE=docker ./start_worker.sh
```
## 🔧 개발 모드
### 직접 실행
```bash
python worker.py --concurrency 2 --redis-url redis://localhost:6379/0
```
### 특정 큐만 처리
```bash
python worker.py --concurrency 1 --redis-url redis://localhost:6379/0
```
## 📊 모니터링
Flower를 통해 워커 상태를 모니터링할 수 있습니다:
- 메인서버의 Flower: http://메인서버:5555
## 🔄 자동 재시작
systemd를 사용한 자동 재시작 설정:
```bash
# /etc/systemd/system/image-worker.service
[Unit]
Description=Image Processing Worker
After=docker.service
[Service]
Type=forking
User=ckh08045
WorkingDirectory=/home/ckh08045/work/worker-system
Environment=WORKER_MODE=docker
Environment=REDIS_URL=redis://localhost:6379/0
ExecStart=/home/ckh08045/work/worker-system/start_worker.sh
Restart=always
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable image-worker
sudo systemctl start image-worker
```
## 🐛 문제 해결
### Redis 연결 오류
- 메인서버의 Redis가 실행 중인지 확인
- 포트 6379가 열려있는지 확인
- 방화벽 설정 확인
### Docker 권한 오류
```bash
sudo usermod -aG docker $USER
newgrp docker
```
### 의존성 오류
```bash
pip install -r requirements.txt
```

273
README_WORKER.md Normal file
View File

@ -0,0 +1,273 @@
# 이미지 번역 워커 시스템
이 워커 시스템은 Redis와 Celery를 사용하여 이미지 번역 작업을 분산 처리합니다.
메인 서버의 Redis에 연결하여 `translate_me` 요청을 처리하고, `ImageProcessor``process_single_image` 메서드를 실행합니다.
## 시스템 구조
```
메인서버 (FastAPI + Redis + Flower + Celery Client)
↓ (Redis Queue)
워커서버 (Celery Worker + ImageProcessor + Redis Client)
```
**워커 역할**: 작업 수행만 담당 (Redis 서버, Flower 모니터링은 메인서버에서 운영)
## 필요한 환경
- Python 3.8+
- **메인서버**: Redis 서버, Flower 대시보드 (이미 구축됨)
- **워커서버**: Celery 워커, Redis 클라이언트
- CUDA 지원 GPU (선택사항, 성능 향상)
## 설치 및 설정
### 1. 워커 전용 의존성 설치
```bash
pip install -r requirements.txt
```
**주요 패키지**:
- `celery`: 워커 프로세스 실행
- `redis`: Redis 서버 연결용 클라이언트
- ~~`flower`: 메인서버에서만 필요 (워커에는 불필요)~~
### 2. 환경 변수 설정
```bash
# Redis 서버 주소 (메인서버 주소)
export REDIS_URL="redis://메인서버IP:6379/0"
# 메인 서버 주소
export MAIN_SERVER_HOST="메인서버IP"
# 워커 식별명 (선택사항)
export WORKER_NAME="worker-AGX-$$"
# 동시 처리 수 (선택사항)
export CONCURRENCY="2"
```
## 워커 실행 방법
### 방법 1: 자동 시작 스크립트 사용
```bash
# Docker 모드로 실행
WORKER_MODE=docker ./start_worker.sh
# 로컬 모드로 실행
WORKER_MODE=local ./start_worker.sh
# 원격 모드로 실행 (다른 서버에서)
WORKER_MODE=remote REDIS_URL=redis://메인서버IP:6379/0 ./start_worker.sh
```
### 방법 2: 직접 실행
```bash
# Python으로 직접 실행
python worker.py --concurrency 2 --redis-url redis://메인서버IP:6379/0
# Docker Compose로 실행
docker-compose up -d
```
### 방법 3: Docker로 실행
```bash
# Docker 이미지 빌드
docker build -t image-worker .
# Docker 컨테이너 실행
docker run -d \
-e REDIS_URL=redis://메인서버IP:6379/0 \
-e MAIN_SERVER_HOST=메인서버IP \
-v ./modules:/worker/modules \
-v ./models:/worker/models \
image-worker
```
## 사용 가능한 작업 (Tasks)
### 1. translate_me_task
- **용도**: 전체 이미지 번역 처리 (OCR → 번역 → 인페인팅 → 텍스트 렌더링)
- **큐**: `translate`
- **파라미터**:
```python
{
'image_data': 'base64_encoded_image', # 또는 image_path 사용
'image_path': '/path/to/image.jpg', # 또는 image_data 사용
'toggle_states': {
'ocr': True,
'watermark_text': '번역완료'
},
'unwanted_texts': {
'광고': '이미지삭제',
'홍보': '공지'
},
'index': 0,
'file_prefix': 'test'
}
```
### 2. ocr_task
- **용도**: OCR만 수행
- **큐**: `ocr`
- **파라미터**:
```python
{
'image_data': 'base64_encoded_image', # 또는 image_path 사용
'image_path': '/path/to/image.jpg' # 또는 image_data 사용
}
```
### 3. inpaint_task
- **용도**: 인페인팅만 수행
- **큐**: `inpaint`
## 테스트 방법
### 1. 워커 상태 확인
```bash
python test_worker_client.py --test-type status
```
### 2. OCR 테스트
```bash
python test_worker_client.py --image sample.jpg --test-type ocr
```
### 3. 번역 테스트
```bash
python test_worker_client.py --image sample.jpg --test-type translate
```
### 4. 전체 테스트
```bash
python test_worker_client.py --image sample.jpg --test-type all
```
## 메인서버에서 워커 호출 예제
```python
from celery import Celery
import base64
# Celery 클라이언트 설정
celery_app = Celery(
"main_server",
broker="redis://localhost:6379/0",
backend="redis://localhost:6379/0"
)
# 이미지를 base64로 인코딩
with open("image.jpg", "rb") as f:
image_data = base64.b64encode(f.read()).decode('utf-8')
# translate_me 작업 요청
result = celery_app.send_task(
'worker.translate_me_task',
kwargs={
'image_data': image_data,
'toggle_states': {
'ocr': True,
'watermark_text': '번역완료'
},
'unwanted_texts': {
'광고': '이미지삭제'
},
'index': 0
}
)
# 결과 대기
task_result = result.get(timeout=300)
print(f"상태: {task_result['status']}")
print(f"워커: {task_result['worker_name']}")
# 결과 이미지 저장
if task_result.get('output_image'):
with open("result.png", "wb") as f:
f.write(base64.b64decode(task_result['output_image']))
```
## 모니터링
### Flower 대시보드 사용 (메인서버에서만)
**중요**: Flower는 메인서버에서만 실행합니다. 워커에서는 설치/실행할 필요가 없습니다.
```bash
# 메인서버에서만 실행
flower --broker=redis://localhost:6379/0 --port=5555
```
브라우저에서 `http://메인서버IP:5555`로 접속하여 워커 상태 확인
### 명령행으로 상태 확인 (워커서버에서도 가능)
```bash
# 활성 워커 목록
celery -A worker inspect active
# 등록된 작업 목록
celery -A worker inspect registered
# 통계 정보
celery -A worker inspect stats
```
## 문제 해결
### 1. 워커가 연결되지 않는 경우
- Redis 서버가 실행 중인지 확인
- 방화벽 설정 확인 (Redis 포트 6379)
- REDIS_URL 환경 변수 확인
### 2. 작업이 실행되지 않는 경우
- 워커가 정상적으로 시작되었는지 로그 확인
- 큐 이름이 올바른지 확인
- ImageProcessor 의존성 모듈들이 설치되었는지 확인
### 3. 메모리 부족 오류
- CONCURRENCY 값을 낮춤 (1 또는 2)
- Docker의 메모리 제한 확인
### 4. GPU 관련 오류
- CUDA 드라이버 설치 확인
- requirements.txt의 GPU 패키지들 설치 확인
## 로그 확인
```bash
# Docker 로그 확인
docker-compose logs -f worker
# 로컬 실행 시 로그는 터미널에 출력됨
```
## 성능 최적화
1. **동시 처리 수 조정**: `--concurrency` 옵션으로 CPU/GPU 리소스에 맞게 조정
2. **메모리 사용량 모니터링**: 이미지 크기에 따라 메모리 사용량 증가
3. **네트워크 최적화**: 메인서버와 워커서버 간 네트워크 지연 최소화
4. **Redis 설정 최적화**: Redis 메모리 및 연결 수 설정 조정
## 패키지 역할 정리
### 메인서버에 필요한 패키지
- `redis`: Redis 서버
- `flower`: 워커 모니터링 대시보드
- `celery`: 작업 전송용 클라이언트
### 워커서버에 필요한 패키지
- `celery`: 워커 프로세스 실행
- `redis`: Redis 연결용 클라이언트
- `ImageProcessor` 관련 의존성들
**결론**: 워커는 단순히 작업을 받아서 처리하는 역할만 하므로, Redis 서버나 Flower 대시보드를 별도로 실행할 필요가 없습니다.

24
docker-compose.yml Normal file
View File

@ -0,0 +1,24 @@
version: "3.8"
services:
worker:
build: .
container_name: image_worker
command: python worker.py --concurrency 2 --redis-url redis://host.docker.internal:6379/0
volumes:
- ./modules:/worker/modules
- ./models:/worker/models
- ./temp:/worker/temp
environment:
- REDIS_URL=redis://host.docker.internal:6379/0
- MAIN_SERVER_HOST=host.docker.internal
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
# 로컬에서 Redis가 필요한 경우 (메인서버와 분리된 환경)
# redis:
# image: redis:6.2
# container_name: worker_redis
# ports:
# - "6380:6379"

0
docker_root/Dockerfile Normal file
View File

0
docker_root/tasks.py Normal file
View File

BIN
hawp_simp.onnx Normal file

Binary file not shown.

2
iop_start.sh Normal file
View File

@ -0,0 +1,2 @@
#!/bin/bash
iopaint start --host=0.0.0.0 --port=8000 --device=cuda --enable-remove-bg --remove-bg-device=cuda --model=migan

1
lib64 Symbolic link
View File

@ -0,0 +1 @@
lib

44
main.py Normal file
View File

@ -0,0 +1,44 @@
import argparse
from modules.image_translate_server import run_server
from modules.image_processor2 import ImageProcessor
from modules.loggerModule import Logger1
from modules.gpt_client import GPTClient
import sys, os
def get_base_dir():
"""
실행 환경에 따라 base_dir을 설정하는 메서드.
cx_Freeze로 패키징된 경우 실행 파일의 경로, 일반 Python 환경일 경우 __file__을 기준으로 설정.
"""
if getattr(sys, 'frozen', False): # 패키징된 경우
base_dir = os.path.dirname(sys.executable)
internal_dir = os.path.join(base_dir, 'lib') # lib 디렉토리 포함
if os.path.exists(internal_dir): # lib 디렉토리가 존재하면 base_dir로 설정
return internal_dir
else: # 일반 Python 실행 환경
base_dir = os.path.dirname(os.path.abspath(__file__))
debug_dir = os.path.join(base_dir) # lib 디렉토리 포함
return debug_dir
def main():
parser = argparse.ArgumentParser(description="이미지 번역 FastAPI 서버 실행")
parser.add_argument('--workers', type=int, default=2, help='최대 동시 워커 수 (2~8)')
args = parser.parse_args()
max_workers = max(2, min(args.workers, 8))
# 실제 환경에 맞게 객체 생성
logger = Logger1()
gpt_client = GPTClient()
base_dir = get_base_dir()
font_path = os.path.join(base_dir, "modules", "fonts", "HakgyoansimDunggeunmisoTTFB.ttf")
print(f"font_path: {font_path}")
image_processor = ImageProcessor(logger, gpt_client, base_dir, font_path)
port = run_server(image_processor, max_workers)
print(f"서버가 127.0.0.1:{port} 에서 실행 중입니다.")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

0
modules/__init__.py Normal file
View File

3662
modules/app.log Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,764 @@
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 pywinauto
import io
import logging
class ClipboardImageManager:
def __init__(self, logger, watermark_font_size=36, debug_flag=False):
self.logger = logger
self.debug = debug_flag # 디버그 플래그를 클래스 변수로 사용
# 프로그램이 위치한 경로 기준으로 폰트 경로 설정
self.base_path = self.get_base_dir()
# 먼저 현재 모듈과 같은 디렉토리에서 폰트 파일 찾기
current_dir = os.path.dirname(os.path.abspath(__file__))
self.font_path = os.path.join(current_dir, 'HakgyoansimDunggeunmisoTTFB.ttf')
# 폰트 파일이 없으면 다른 경로들을 시도
if not os.path.exists(self.font_path):
alternative_paths = [
os.path.join(self.base_path, 'HakgyoansimDunggeunmisoTTFB.ttf'),
os.path.join(self.base_path, 'src', 'modules', 'HakgyoansimDunggeunmisoTTFB.ttf'),
os.path.join(os.path.dirname(self.base_path), 'src', 'modules', 'HakgyoansimDunggeunmisoTTFB.ttf')
]
for alt_path in alternative_paths:
if os.path.exists(alt_path):
self.font_path = alt_path
break
# 폰트 로드 (예외 처리 추가)
try:
self.font = ImageFont.truetype(self.font_path, 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
# self.debug = True
def reset_state(self):
"""클립보드 이미지 관리자의 상태를 초기화합니다."""
self.logger.log("ClipboardImageManager 상태 초기화", level=logging.DEBUG)
# 클립보드 비우기
self.clear_clipboard()
def get_base_dir(self):
"""
실행 환경에 따라 base_dir을 설정하는 메서드.
cx_Freeze로 패키징된 경우 실행 파일의 경로, 일반 Python 환경일 경우 __file__을 기준으로 설정.
"""
if getattr(sys, 'frozen', False): # 패키징된 경우
base_dir = os.path.dirname(sys.executable)
internal_dir = os.path.join(base_dir, 'lib', 'src') # lib 디렉토리 포함
if os.path.exists(internal_dir): # lib 디렉토리가 존재하면 base_dir로 설정
return internal_dir
else: # 일반 Python 실행 환경
base_dir = os.path.dirname(os.path.abspath(__file__))
debug_dir = os.path.join(base_dir, 'src') # lib 디렉토리 포함
return debug_dir
def get_clipboard_data(self):
"""클립보드의 텍스트 또는 이미지 데이터를 가져옵니다."""
self.logger.log("클립보드의 텍스트 또는 이미지 데이터를 가져옵니다", level=logging.DEBUG)
max_attempts = 5
attempt = 0
while attempt < max_attempts:
try:
# 1. 텍스트 데이터 우선 시도
clipboard_text = pyperclip.paste()
if clipboard_text:
return clipboard_text
# 2. 텍스트가 없으면 이미지 확인
self.logger.log("텍스트 데이터가 없어 이미지 데이터 확인 시도", level=logging.DEBUG)
image = ImageGrab.grabclipboard()
if isinstance(image, Image.Image): # 이미지 데이터가 있는 경우
self.logger.log("클립보드에 이미지 데이터가 확인되었습니다.", level=logging.DEBUG)
return image # PIL 이미지 객체 반환
else:
self.logger.log("클립보드에 텍스트 또는 이미지 데이터가 없습니다.", level=logging.DEBUG)
return None
except Exception as e:
attempt += 1
self.logger.log(f"클립보드 데이터를 가져오는 중 오류 발생 (시도 {attempt}/{max_attempts}): {e}", level=logging.WARNING)
if attempt < max_attempts:
time.sleep(0.5) # 0.5초 대기 후 재시도
else:
self.logger.log(f"클립보드 데이터를 가져오는 중 최대 시도 횟수 초과: {e}", level=logging.ERROR, exc_info=True)
return None
def set_image_to_clipboard(self, image):
"""이미지를 클립보드에 넣는 함수 (Windows 전용)"""
output = BytesIO()
image.save(output, "BMP")
self.logger.log(f"이미지 데이터 BMP 변환", level=logging.DEBUG)
data = output.getvalue()[14:] # BMP 헤더 제거
output.close()
self.logger.log(f"이미지 BMP 헤더 제거", level=logging.DEBUG)
# 클립보드 접근 재시도 로직
max_attempts = 5
attempt = 0
success = False
while attempt < max_attempts and not success:
try:
# 클립보드에 이미지 데이터 넣기
win32clipboard.OpenClipboard()
win32clipboard.EmptyClipboard()
win32clipboard.SetClipboardData(win32clipboard.CF_DIB, data)
win32clipboard.CloseClipboard()
success = True
self.logger.log(f"클립보드 데이터 저장 성공 (시도 {attempt+1}/{max_attempts})", level=logging.DEBUG)
except Exception as e:
attempt += 1
self.logger.log(f"클립보드 데이터 저장 실패 (시도 {attempt}/{max_attempts}): {e}", level=logging.WARNING)
if attempt < max_attempts:
time.sleep(0.5) # 0.5초 대기 후 재시도
# 클립보드가 제대로 설정되었는지 확인하는 로그
if success:
try:
time.sleep(0.1) # 아주 짧은 대기 시간
win32clipboard.OpenClipboard()
if win32clipboard.IsClipboardFormatAvailable(win32clipboard.CF_DIB):
self.logger.log("클립보드 데이터 확인 성공", level=logging.DEBUG)
else:
self.logger.log("클립보드 데이터 확인 실패", level=logging.ERROR)
win32clipboard.CloseClipboard()
except Exception as e:
self.logger.log(f"클립보드 데이터 확인 중 오류: {e}", level=logging.ERROR)
def save_image_to_path(self, image, path):
try:
if image:
# 이미지를 저장 경로에 저장
self.logger.log(f"이미지 저장 완료 : {path}", level=logging.INFO)
image.save(path, format='PNG')
return path
except Exception as e:
raise RuntimeError(f"이미지 저장 중 오류 발생: {e}")
def add_watermark(self, image, watermark_text="Watermark", opacity_percent=30, angle=30, font_size=36):
"""
이미지에 텍스트 워터마크를 이미지 전체에 걸쳐서 추가하는 함수
:param image: PIL 이미지 객체
:param watermark_text: 워터마크로 추가할 텍스트
:param opacity_percent: 워터마크의 투명도 (0~100)
:param angle: 워터마크 텍스트 회전 각도 (기본 30)
:param font_size: 워터마크 텍스트의 폰트 크기 (기본 36)
:return: 워터마크가 추가된 이미지
"""
# 폰트가 로드되지 않은 경우 원본 이미지 반환
if self.font is None:
self.logger.log("폰트가 로드되지 않아 워터마크를 추가할 수 없습니다. 원본 이미지를 반환합니다.", level=logging.WARNING)
return image
# 이미지 복사본 생성
watermark_image = image.copy()
# 폰트 설정 (안전한 폰트 로딩)
try:
# self.font가 있으면 크기만 조정해서 새 폰트 생성
if hasattr(self, 'font_path') and os.path.exists(self.font_path):
font = ImageFont.truetype(self.font_path, font_size)
else:
# 크기를 조정할 수 없으면 기존 폰트 사용
font = self.font
except Exception as e:
self.logger.log(f"폰트 크기 조정 실패: {e}. 기본 폰트를 사용합니다.", level=logging.WARNING)
font = self.font
# 텍스트 투명도를 0~255로 변환
opacity = int(255 * (opacity_percent / 100))
# 텍스트 크기 측정 (textbbox 사용)
draw = ImageDraw.Draw(watermark_image)
bbox = draw.textbbox((0, 0), watermark_text, font=font)
text_width, text_height = bbox[2] - bbox[0], bbox[3] - bbox[1]
# 이미지 크기
width, height = image.size
# 워터마크 레이어 생성
watermark_layer = Image.new("RGBA", (width, height)) # RGBA 이미지 생성
# 지그재그 간격 설정
zigzag_step = int(text_height * 2) # Y축의 지그재그 간격
# 이미지 전체에 반복적으로 워터마크 텍스트 그리기 (지그재그 형태)
for y in range(0, height, zigzag_step):
for x in range(0, width, int(text_width * 3)): # 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=font)
# 텍스트 회전
rotated_text_layer = text_layer.rotate(angle, expand=1)
# 회전된 텍스트를 워터마크 레이어에 추가
watermark_layer.paste(rotated_text_layer, (x + x_offset, y), rotated_text_layer)
# 원본 이미지와 워터마크 레이어 합성
watermark_image = Image.alpha_composite(watermark_image.convert("RGBA"), watermark_layer)
# 최종적으로 RGB 형식으로 변환 후 반환
return watermark_image.convert("RGB")
def base64_to_image(self, base64_data):
"""Base64 데이터를 이미지로 변환하는 함수"""
if base64_data.startswith('data:image'):
header, encoded = base64_data.split(',', 1)
img_data = base64.b64decode(encoded)
image = Image.open(BytesIO(img_data))
return image
else:
self.logger.log("유효하지 않은 Base64 이미지 데이터입니다.", level=logging.DEBUG)
return None
def image_to_base64(self, image):
# 이미지 Base64로 변환
buffer = io.BytesIO()
image.save(buffer, format="PNG")
base64_image = base64.b64encode(buffer.getvalue()).decode('utf-8')
return base64_image
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 process_clipboard(self, original_url, is_success_translated, toggle_states, path=None, is_thumb=False):
"""클립보드의 내용을 처리하고, 필요한 경우 이미지 변환, 크롭 또는 클립보드 비우기"""
try:
is_watermark = toggle_states.get('watermark')
self.logger.log(f"is_watermark : {is_watermark}", level=logging.DEBUG)
watermark_text = toggle_states.get('watermark_text')
self.logger.log(f"watermark_text : {watermark_text}", level=logging.DEBUG)
opacity_percent = toggle_states.get('opacity_percent')
self.logger.log(f"opacity_percent : {opacity_percent}", level=logging.DEBUG)
clipboard_data = self.get_clipboard_data()
self.logger.log("clipboard_data", level=logging.DEBUG)
self.logger.log(f"{clipboard_data}", level=logging.DEBUG)
self.logger.log(f"============================", level=logging.DEBUG)
# 1. 클립보드의 데이터가 Base64 이미지일 경우
if isinstance(clipboard_data, str) and clipboard_data.startswith('data:image'):
self.logger.log("[process_clipboard] data:image 감지 : 이미지 데이터로 변환", level=logging.INFO)
image = self.base64_to_image(clipboard_data)
if image:
width, _ = image.size
self.logger.log(f"Base64 이미지 크기: {width}px", level=logging.DEBUG)
# 가로 크기가 200픽셀 이상이면 크롭
if width >= 200:
self.logger.log("이미지 가로 크기 200픽셀 이상: 크롭 진행 중...", level=logging.DEBUG)
cropped_image = self.crop_image(image, is_thumb) # 크롭 메서드 사용
# 워터마크 추가
if is_watermark and not is_thumb: # is_thumb가 True라면 워터마크 추가를 건너뜁니다
self.logger.log("워터마크 추가 중...", level=logging.DEBUG)
cropped_watermark_image = self.add_watermark(cropped_image, watermark_text, opacity_percent) # 워터마크 추가
cropped_image = cropped_watermark_image
self.set_image_to_clipboard(cropped_image) # 클립보드에 저장
if path:
self.logger.log("이미지 저장 시도...", level=logging.DEBUG)
self.save_image_to_path(cropped_image, path)
else:
self.logger.log("이미지 가로 크기 200픽셀 이하: 클립보드 비움.", level=logging.DEBUG)
self.clear_clipboard()
else:
self.logger.log("Base64 이미지 변환 실패.", level=logging.DEBUG)
# 2. 클립보드에 이미지가 있을 경우
elif isinstance(clipboard_data, Image.Image):
self.logger.log("[process_clipboard] 클립보드 이미지 확인", level=logging.INFO)
image = clipboard_data
width, _ = image.size
self.logger.log(f"클립보드에 있는 이미지 크기: {width}px", level=logging.DEBUG)
if width >= 200:
self.logger.log("이미지 가로 크기 200픽셀 이상: 크롭 진행 중...", level=logging.DEBUG)
cropped_image = self.crop_image(image, is_thumb) # 크롭 메서드 사용
# 워터마크 추가
if is_watermark and not is_thumb: # is_thumb가 True라면 워터마크 추가를 건너뜁니다
self.logger.log("워터마크 추가 중...", level=logging.DEBUG)
cropped_watermark_image = self.add_watermark(cropped_image, watermark_text, opacity_percent) # 워터마크 추가
cropped_image = cropped_watermark_image
self.set_image_to_clipboard(cropped_image) # 클립보드에 저장
if path:
self.logger.log("이미지 저장 시도...", level=logging.DEBUG)
self.save_image_to_path(cropped_image, path)
else:
self.logger.log("이미지 가로 크기 200픽셀 이하: 클립보드 비움.", level=logging.DEBUG)
self.clear_clipboard()
# 3. 클립보드에 데이터가 없거나 html > whale-ocr 처리
elif clipboard_data == "html > whale-ocr" or clipboard_data is None or not is_success_translated:
if clipboard_data == "html > whale-ocr":
self.logger.log("[process_clipboard] html > whale-ocr 감지 : 이미지 번역 실패 확인", level=logging.INFO)
elif clipboard_data is None:
self.logger.log("[process_clipboard] 클립보드에 이미지 없음", level=logging.INFO)
elif is_success_translated is None:
self.logger.log("[process_clipboard] 번역 실패로 인한 원본이미지 다운로드", level=logging.INFO)
if original_url:
image = self.download_image_from_url(original_url)
if image:
self.logger.log("원본 이미지 다운로드 성공!", level=logging.DEBUG)
self.set_image_to_clipboard(image) # 크롭 없이 저장
if path:
self.logger.log("이미지 저장 시도...", level=logging.DEBUG)
self.save_image_to_path(image, path)
else:
self.logger.log("원본 이미지 다운로드 실패.", level=logging.DEBUG)
else:
self.logger.log("원본 이미지 URL을 찾을 수 없습니다.", level=logging.DEBUG)
except Exception as e:
self.logger.log(f"클립보드에서 이미지를 처리하는 중 오류 발생: {e}", level=logging.ERROR, exc_info=True)
def process_clipboard_to_save_path(self, original_url, is_success_translated, toggle_states, path=None, is_thumb=False):
"""클립보드의 내용을 처리하고, 필요한 경우 이미지 변환, 크롭 또는 클립보드 비우기"""
try:
is_watermark = toggle_states.get('watermark')
self.logger.log(f"is_watermark : {is_watermark}", level=logging.DEBUG)
watermark_text = toggle_states.get('watermark_text')
self.logger.log(f"watermark_text : {watermark_text}", level=logging.DEBUG)
opacity_percent = toggle_states.get('opacity_percent')
self.logger.log(f"opacity_percent : {opacity_percent}", level=logging.DEBUG)
clipboard_data = self.get_clipboard_data()
self.logger.log("clipboard_data", level=logging.DEBUG)
self.logger.log(f"{clipboard_data}", level=logging.DEBUG)
self.logger.log(f"============================", level=logging.DEBUG)
# 1. 클립보드의 데이터가 Base64 이미지일 경우
if isinstance(clipboard_data, str) and clipboard_data.startswith('data:image'):
self.logger.log("[process_clipboard] data:image 감지 : 이미지 데이터로 변환", level=logging.INFO)
image = self.base64_to_image(clipboard_data)
if image:
width, _ = image.size
self.logger.log(f"Base64 이미지 크기: {width}px", level=logging.DEBUG)
# 가로 크기가 200픽셀 이상이면 크롭
if width >= 200:
self.logger.log("이미지 가로 크기 200픽셀 이상: 크롭 진행 중...", level=logging.DEBUG)
cropped_image = self.crop_image(image, is_thumb) # 크롭 메서드 사용
# 워터마크 추가
if is_watermark and not is_thumb: # is_thumb가 True라면 워터마크 추가를 건너뜁니다
self.logger.log("워터마크 추가 중...", level=logging.DEBUG)
cropped_watermark_image = self.add_watermark(cropped_image, watermark_text, opacity_percent) # 워터마크 추가
cropped_image = cropped_watermark_image
if path:
self.logger.log("이미지 저장 시도...", level=logging.DEBUG)
return self.save_image_to_path(cropped_image, path)
else:
self.set_image_to_clipboard(cropped_image) # 클립보드에 저장
else:
self.logger.log("이미지 가로 크기 200픽셀 이하로 처리불가: 클립보드 비움.", level=logging.DEBUG)
self.clear_clipboard()
else:
self.logger.log("Base64 이미지 변환 실패.", level=logging.DEBUG)
# 2. 클립보드에 이미지가 있을 경우
elif isinstance(clipboard_data, Image.Image):
self.logger.log("[process_clipboard] 클립보드 이미지 확인", level=logging.INFO)
image = clipboard_data
width, _ = image.size
self.logger.log(f"클립보드에 있는 이미지 크기: {width}px", level=logging.DEBUG)
if width >= 200:
self.logger.log("이미지 가로 크기 200픽셀 이상: 크롭 진행 중...", level=logging.DEBUG)
cropped_image = self.crop_image(image, is_thumb) # 크롭 메서드 사용
# 워터마크 추가
if is_watermark and not is_thumb: # is_thumb가 True라면 워터마크 추가를 건너뜁니다
self.logger.log("워터마크 추가 중...", level=logging.DEBUG)
cropped_watermark_image = self.add_watermark(cropped_image, watermark_text, opacity_percent) # 워터마크 추가
cropped_image = cropped_watermark_image
if path:
self.logger.log("이미지 저장 시도...", level=logging.DEBUG)
return self.save_image_to_path(cropped_image, path)
else:
self.set_image_to_clipboard(cropped_image) # 클립보드에 저장
else:
self.logger.log("이미지 가로 크기 200픽셀 이하로 처리불가: 클립보드 비움.", level=logging.DEBUG)
self.clear_clipboard()
# 3. 클립보드에 데이터가 없거나 html > whale-ocr 처리
elif clipboard_data == "html > whale-ocr" or clipboard_data is None or not is_success_translated or clipboard_data.startswith("https://") or clipboard_data.startswith("http://"):
if clipboard_data == "html > whale-ocr":
self.logger.log("[process_clipboard] html > whale-ocr 감지 : 이미지 번역 실패 확인", level=logging.INFO)
elif clipboard_data is None:
self.logger.log("[process_clipboard] 클립보드에 이미지 없음", level=logging.INFO)
elif is_success_translated is None:
self.logger.log("[process_clipboard] 번역 실패로 인한 원본이미지 다운로드", level=logging.INFO)
elif clipboard_data.startswith("https://") or clipboard_data.startswith("http://"):
self.logger.log("[process_clipboard] 타임아웃으로 인한 번역 실패 - 원본이미지 다운로드", level=logging.INFO)
if original_url:
image = self.download_image_from_url(original_url)
if image:
self.logger.log("원본 이미지 다운로드 성공!", level=logging.DEBUG)
if path:
self.logger.log("이미지 저장 시도...", level=logging.DEBUG)
return self.save_image_to_path(image, path)
else:
self.set_image_to_clipboard(cropped_image) # 클립보드에 저장
else:
self.logger.log("원본 이미지 다운로드 실패.", level=logging.DEBUG)
else:
self.logger.log("원본 이미지 URL을 찾을 수 없습니다.", level=logging.DEBUG)
except Exception as e:
self.logger.log(f"클립보드에서 이미지를 처리하는 중 오류 발생: {e}", level=logging.ERROR, exc_info=True)
def process_clipboard_to_save_path_with_local_hosted_image(self, local_image_path, is_success_translated, toggle_states, path=None, is_thumb=False):
"""클립보드의 내용을 처리하고, 필요한 경우 이미지 변환, 크롭 또는 클립보드 비우기
Returns:
str: 처리된 이미지 파일 경로 (성공 )
str: 원본 이미지 파일 경로 (실패 )
"""
# 매개변수 유효성 검사
if not local_image_path or not os.path.exists(local_image_path):
self.logger.log(f"유효하지 않은 로컬 이미지 경로: {local_image_path}", level=logging.ERROR)
return local_image_path if local_image_path else None
if not toggle_states:
self.logger.log("toggle_states가 제공되지 않았습니다", level=logging.WARNING)
toggle_states = {}
try:
is_watermark = toggle_states.get('watermark', False)
self.logger.log(f"is_watermark : {is_watermark}", level=logging.DEBUG)
watermark_text = toggle_states.get('watermark_text', '')
self.logger.log(f"watermark_text : {watermark_text}", level=logging.DEBUG)
opacity_percent = toggle_states.get('opacity_percent', 20)
self.logger.log(f"opacity_percent : {opacity_percent}", level=logging.DEBUG)
clipboard_data = self.get_clipboard_data()
self.logger.log(f"type(clipboard_data) : {type(clipboard_data)}", level=logging.DEBUG)
self.logger.log(f"============================", level=logging.DEBUG)
# 1. 클립보드의 데이터가 Base64 이미지일 경우
if isinstance(clipboard_data, str) and clipboard_data.startswith('data:image'):
self.logger.log("[process_clipboard] data:image 감지 : 이미지 데이터로 변환", level=logging.INFO)
image = self.base64_to_image(clipboard_data)
if image:
width, _ = image.size
self.logger.log(f"Base64 이미지 크기: {width}px", level=logging.DEBUG)
# 가로 크기가 200픽셀 이상이면 크롭
if width >= 200:
self.logger.log("이미지 가로 크기 200픽셀 이상: 크롭 진행 중...", level=logging.DEBUG)
cropped_image = self.crop_image(image, is_thumb) # 크롭 메서드 사용
# 워터마크 추가
if is_watermark and not is_thumb: # is_thumb가 True라면 워터마크 추가를 건너뜁니다
self.logger.log("워터마크 추가 중...", level=logging.DEBUG)
cropped_watermark_image = self.add_watermark(cropped_image, watermark_text, opacity_percent) # 워터마크 추가
cropped_image = cropped_watermark_image
if path:
self.logger.log("이미지 저장 시도...", level=logging.DEBUG)
saved_path = self.save_image_to_path(cropped_image, path)
return saved_path if saved_path else local_image_path
else:
self.set_image_to_clipboard(cropped_image) # 클립보드에 저장
return local_image_path # path가 없으면 원본 경로 반환
else:
self.logger.log("이미지 가로 크기 200픽셀 이하로 처리불가: 클립보드 비움.", level=logging.DEBUG)
self.clear_clipboard()
return local_image_path
else:
self.logger.log("Base64 이미지 변환 실패.", level=logging.DEBUG)
return local_image_path
# 2. 클립보드에 이미지가 있을 경우
elif isinstance(clipboard_data, Image.Image):
self.logger.log("[process_clipboard] 클립보드 이미지 확인", level=logging.INFO)
image = clipboard_data
width, _ = image.size
self.logger.log(f"클립보드에 있는 이미지 크기: {width}px", level=logging.DEBUG)
if width >= 200:
self.logger.log("이미지 가로 크기 200픽셀 이상: 크롭 진행 중...", level=logging.DEBUG)
cropped_image = self.crop_image(image, is_thumb) # 크롭 메서드 사용
# 워터마크 추가
if is_watermark and not is_thumb: # is_thumb가 True라면 워터마크 추가를 건너뜁니다
self.logger.log("워터마크 추가 중...", level=logging.DEBUG)
cropped_watermark_image = self.add_watermark(cropped_image, watermark_text, opacity_percent) # 워터마크 추가
cropped_image = cropped_watermark_image
if path:
self.logger.log("이미지 저장 시도...", level=logging.DEBUG)
saved_path = self.save_image_to_path(cropped_image, path)
return saved_path if saved_path else local_image_path
else:
self.set_image_to_clipboard(cropped_image) # 클립보드에 저장
return local_image_path # path가 없으면 원본 경로 반환
else:
self.logger.log("이미지 가로 크기 200픽셀 이하로 처리불가: 클립보드 비움.", level=logging.DEBUG)
self.clear_clipboard()
return local_image_path
# 3. 클립보드에 데이터가 없거나 html > whale-ocr 처리
elif clipboard_data == "html > whale-ocr" or clipboard_data is None or not is_success_translated or clipboard_data.startswith("https://") or clipboard_data.startswith("http://"):
if clipboard_data == "html > whale-ocr":
self.logger.log("[process_clipboard] html > whale-ocr 감지 : 이미지 번역 실패 확인", level=logging.INFO)
elif clipboard_data is None:
self.logger.log("[process_clipboard] 클립보드에 이미지 없음", level=logging.INFO)
elif not is_success_translated:
self.logger.log("[process_clipboard] 번역 실패로 인한 원본이미지 사용", level=logging.INFO)
elif clipboard_data.startswith("https://") or clipboard_data.startswith("http://"):
self.logger.log("[process_clipboard] 타임아웃으로 인한 번역 실패 - 원본이미지 사용", level=logging.INFO)
return local_image_path
# 4. 기타 예상하지 못한 클립보드 데이터
else:
self.logger.log(f"[process_clipboard] 예상하지 못한 클립보드 데이터 타입: {type(clipboard_data)}", level=logging.WARNING)
return local_image_path
except Exception as e:
self.logger.log(f"클립보드에서 이미지를 처리하는 중 오류 발생: {e}", level=logging.ERROR, exc_info=True)
return local_image_path # 오류 시 원본 경로 반환
def is_clipboard_image(self):
"""클립보드에 이미지가 있는지 확인하는 함수"""
max_attempts = 5
attempt = 0
while attempt < max_attempts:
try:
win32clipboard.OpenClipboard()
is_clipboard_image_flag = win32clipboard.IsClipboardFormatAvailable(win32clipboard.CF_DIB)
win32clipboard.CloseClipboard()
if is_clipboard_image_flag:
self.logger.log("클립보드에 이미지가 존재합니다.", level=logging.DEBUG)
else:
self.logger.log("클립보드에 이미지가 없습니다.", level=logging.DEBUG)
return is_clipboard_image_flag
except Exception as e:
attempt += 1
# 클립보드가 열려있으면 닫기 시도
try:
win32clipboard.CloseClipboard()
except:
pass
self.logger.log(f"클립보드 이미지 확인 중 오류 발생 (시도 {attempt}/{max_attempts}): {e}", level=logging.WARNING)
if attempt < max_attempts:
time.sleep(0.5) # 0.5초 대기 후 재시도
else:
self.logger.log(f"클립보드 이미지 확인 중 최대 시도 횟수 초과: {e}", level=logging.ERROR, exc_info=True)
return False
def get_image_from_clipboard(self):
"""클립보드에서 이미지를 가져오는 함수"""
max_attempts = 5
attempt = 0
while attempt < max_attempts:
try:
win32clipboard.OpenClipboard()
if self.is_clipboard_image():
dib_data = win32clipboard.GetClipboardData(win32clipboard.CF_DIB)
win32clipboard.CloseClipboard()
image = Image.open(BytesIO(dib_data))
return image
else:
win32clipboard.CloseClipboard()
self.logger.log("클립보드에 이미지가 없습니다.", level=logging.DEBUG)
return None
except Exception as e:
attempt += 1
# 클립보드가 열려있으면 닫기 시도
try:
win32clipboard.CloseClipboard()
except:
pass
self.logger.log(f"클립보드에서 이미지를 가져오는 중 오류 발생 (시도 {attempt}/{max_attempts}): {e}", level=logging.WARNING)
if attempt < max_attempts:
time.sleep(0.5) # 0.5초 대기 후 재시도
else:
self.logger.log(f"클립보드에서 이미지를 가져오는 중 최대 시도 횟수 초과: {e}", level=logging.ERROR, exc_info=True)
return None
def clear_clipboard(self):
"""클립보드를 비우는 함수"""
max_attempts = 5
attempt = 0
success = False
while attempt < max_attempts and not success:
try:
# 먼저 pywinauto로 시도
try:
pywinauto.clipboard.EmptyClipboard()
success = True
except:
# pywinauto 실패 시 win32clipboard로 시도
win32clipboard.OpenClipboard()
win32clipboard.EmptyClipboard()
win32clipboard.CloseClipboard()
success = True
self.logger.log(f"클립보드가 비워졌습니다. (시도 {attempt+1}/{max_attempts})", level=logging.DEBUG)
except Exception as e:
attempt += 1
self.logger.log(f"클립보드를 비우는 중 오류 발생 (시도 {attempt}/{max_attempts}): {e}", level=logging.WARNING)
if attempt < max_attempts:
time.sleep(0.5) # 0.5초 대기 후 재시도
if not success:
self.logger.log("최대 시도 횟수를 초과하여 클립보드를 비우지 못했습니다.", level=logging.ERROR)
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

Binary file not shown.

41
modules/gpt_client.py Normal file
View File

@ -0,0 +1,41 @@
import logging
from openai import OpenAI
import json
import re
import logging
class GPTClient:
def __init__(self, model="gpt-4o-mini", temperature=0.2):
self.client = None
self.model = model
self.temperature = temperature
self.set_client(api_key='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')
def set_client(self, api_key):
self.client = OpenAI(api_key=api_key)
def ask(self, prompt: str) -> dict:
"""프롬프트를 이용하여 GPT 모델로부터 응답을 받습니다. 항상 JSON 형식으로 반환."""
try:
response = self.client.chat.completions.create(
model=self.model,
temperature=self.temperature,
messages=[{"role": "user", "content": prompt}]
)
# GPT 응답 내용 가져오기
content = response.choices[0].message.content.strip()
print(f'GPT 응답: {content}')
# 불필요한 포맷팅 제거 (```json```)
cleaned_content = re.sub(r"^```json|```$", "", content).strip()
# JSON 변환 시도
return json.loads(cleaned_content)
except json.JSONDecodeError as e:
print(f'JSON 디코딩 실패: {e}. 원본 응답: {content}')
return {}
except Exception as e:
print(f'GPT 통신 오류: {e}')
return {}

385
modules/image_processor2.py Normal file
View File

@ -0,0 +1,385 @@
import os
import asyncio
import aiofiles
import logging
from urllib.parse import urlparse
import shutil
import sys
from concurrent.futures import ThreadPoolExecutor
import re
import cv2
import base64
import requests
import numpy as np
from modules.ocr_module import OCRModule
from modules.mask_module import MaskModule
from modules.text_rendering_module import TextRenderingModule
from modules.postImageManager import PostImageManager
from modules.lama_inpaint import inpaint_with_simple_lama
from translatepy import Translator
class ImageProcessor:
"""이미지 다운로드, OCR, 번역 처리를 담당하는 클래스"""
def __init__(self, logger, gpt_client, base_dir, font_path):
self.logger = logger
self.base_dir = base_dir
self.gpt_client = gpt_client
# OCR 관련
self.inpaint_sv_port = 8100
self.font_path = font_path
self.TEMP_IMAGE_DIR = os.path.join(self.base_dir, "temp_images")
os.makedirs(self.TEMP_IMAGE_DIR, exist_ok=True)
self.ocr_module = OCRModule(logger=self.logger, base_dir=self.base_dir)
self.mask_module = MaskModule(logger=self.logger, base_dir=self.base_dir)
self.text_rendering_module = TextRenderingModule(logger=self.logger, font_path=self.font_path)
self.postImageManager = PostImageManager(logger=self.logger, font_path=self.font_path)
def __del__(self):
"""소멸자에서 리소스 정리"""
self.cleanup()
def cleanup(self):
"""리소스 정리"""
try:
# 임시 폴더 삭제
if hasattr(self, 'TEMP_IMAGE_DIR') and os.path.exists(self.TEMP_IMAGE_DIR):
shutil.rmtree(self.TEMP_IMAGE_DIR)
self.logger.log(f"임시 폴더 삭제됨: {self.TEMP_IMAGE_DIR}", level=logging.INFO)
except Exception as e:
self.logger.log(f"리소스 정리 중 오류: {e}", level=logging.ERROR, exc_info=True)
def is_valid_image_path(self, path):
# http/https 또는 로컬 파일(.jpg, .png 등) 모두 허용
if re.match(r'^(http|https)://.*\\.(jpg|jpeg|png|bmp|gif|webp|tiff?)(\\?.*)?$', path, re.IGNORECASE):
return True
if os.path.isfile(path) and path.lower().endswith(('.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp', '.tif', '.tiff')):
return True
return False
async def process_single_image(self, toggle_states, unwanted_texts, local_image_path, index, file_prefix=""):
"""
단일 이미지를 처리합니다 (다운로드 -> OCR -> 인페인팅)
Args:
toggle_states: 토글 상태 딕셔너리
local_image_path (str): 처리할 이미지 경로
index (int): 이미지 인덱스
unwanted_texts: 치환할 텍스트 딕셔너리
file_prefix (str): 파일명에 추가할 접두사 (: "detail", "option")
Returns:
dict: 처리 결과를 포함한 딕셔너리
- status: 'inpainted', 'original', 'exclude', 'error' 하나
- path: 처리된 이미지 파일 경로 또는 원본 이미지 파일 경로
- error: 오류 메시지 (status가 'error' 경우에만 포함)
"""
ocr_enabled = toggle_states.get('ocr', False)
unwanted_texts = unwanted_texts
try:
ocr_results = self.ocr_module.detect_text(local_image_path)
# 3. 중국어 텍스트 없는 경우 원본 이미지 반환
if not self.ocr_module.filter_chinese_text(ocr_results):
self.logger.log(f"이미지 {index+1} 중국어 텍스트 없음, 원본 이미지 반환", level=logging.INFO)
return local_image_path
# 4. 텍스트 번역 (GPT)
translated_texts = self.gpt_translate_texts(ocr_results, self.gpt_client)
# translated_texts = self.google_translate_texts(ocr_results)
if ocr_enabled:
filtered_translated_texts = self.process_translated_texts(translated_texts, unwanted_texts, local_image_path, index)
if not filtered_translated_texts:
self.logger.log(f"이미지 {index+1} 제외됨", level=logging.INFO)
return None
else:
self.logger.log(f"이미지 {index+1} 치환됨", level=logging.INFO)
# 마스크 생성 (basic 방식만 사용)
masks = self.mask_module.create_masks(
image_path=local_image_path, ocr_results=ocr_results, mask_option="basic"
)
self.logger.log(f"마스크 생성 완료", level=logging.INFO)
# 인페인팅
inpainted_image = self.call_inpaint_api(local_image_path, masks)
self.logger.log(f"인페인팅 완료", level=logging.INFO)
# inpainted_image = inpaint_with_simple_lama(local_image_path, masks)
# self.logger.log(f"인페인팅 완료", level=logging.INFO)
# import modules.migan.inpaint_with_migan as migan_module
# self.migan_obj = migan_module.get_migan(device="cuda", model_path="modules/migan/migan_traced.pt")
# inpainted_image = migan_module.inpaint_with_migan(
# local_image_path, masks, device="cuda", model_path="modules/migan/migan_traced.pt", migan_obj=self.migan_obj
# )
# self.logger.log(f"인페인팅 완료", level=logging.INFO)
# 텍스트 렌더링
text_rendered_image = self.text_rendering_module.render_text(
inpainted_image, ocr_results, filtered_translated_texts, font_path=self.font_path)
self.logger.log(f"텍스트 렌더링 완료", level=logging.INFO)
# 결과 저장
translated_img_path = await self.postProcess_and_save_image(local_image_path, text_rendered_image, index, file_prefix, toggle_states)
self.logger.log(f"이미지 {index+1} 번역 완료: {translated_img_path}", level=logging.INFO)
return translated_img_path
except Exception as e:
self.logger.log(f"이미지 {index+1} 처리 중 오류: {e}", level=logging.ERROR, exc_info=True)
return {'status': 'failed', 'path': local_image_path, 'error': str(e)}
async def postProcess_and_save_image(self, local_image_path, text_rendered_image, index, file_prefix, toggle_states):
"""로컬 서버 URL을 사용해 이미지를 번역하고 로컬에 저장합니다"""
try:
# 파일명에 접두사 포함
if file_prefix:
img_path = os.path.join(self.TEMP_IMAGE_DIR, f"translated_{file_prefix}_img_{index+1}.png")
else:
img_path = os.path.join(self.TEMP_IMAGE_DIR, f"translated_img_{index+1}.png")
watermarked_image_data = self.postImageManager.add_watermark(image_data=text_rendered_image, watermark_text=toggle_states.get("watermark_text", "워터마크"))
final_image_path = self.postImageManager.save_image_to_path(watermarked_image_data, img_path)
return final_image_path
except Exception as e:
self.logger.log(f"이미지 {index+1} 번역 처리 중 오류: {e}", level=logging.ERROR, exc_info=True)
return local_image_path
def is_valid_image_data(self, image_data: bytes) -> bool:
"""이미지 데이터가 유효한지 확인합니다"""
if not image_data or len(image_data) < 100:
return False
# 일반적인 이미지 파일 시그니처 확인
image_signatures = [
b'\xFF\xD8\xFF', # JPEG
b'\x89PNG\r\n\x1a\n', # PNG
b'GIF87a', # GIF87a
b'GIF89a', # GIF89a
b'RIFF', # WebP (RIFF 컨테이너)
]
return any(image_data.startswith(sig) for sig in image_signatures)
def call_inpaint_api(self, image, mask):
"""
인페인팅 API를 호출하여 이미지를 인페인팅합니다.
"""
try:
# 이미지 처리
if isinstance(image, str):
image_np = cv2.imread(image)
if image_np is None:
self.logger.log(f"이미지 로딩 실패: {image}", level=logging.ERROR)
return None
else:
image_np = image
# 마스크 처리
if isinstance(mask, str):
mask_np = cv2.imread(mask, cv2.IMREAD_GRAYSCALE)
if mask_np is None:
self.logger.log(f"마스크 로딩 실패: {mask}", level=logging.ERROR)
return None
else:
mask_np = mask
api_url = f"http://localhost:{self.inpaint_sv_port}/api/v1/inpaint"
_, img_encoded = cv2.imencode('.png', image_np)
_, mask_encoded = cv2.imencode('.png', mask_np)
img_b64 = base64.b64encode(img_encoded).decode('utf-8')
mask_b64 = base64.b64encode(mask_encoded).decode('utf-8')
payload = {
"image": img_b64,
"mask": mask_b64
}
response = requests.post(api_url, json=payload)
if response.status_code != 200:
self.logger.log(f"IOPaint 서버 에러: {response.text}", level=logging.ERROR)
return None
nparr = np.frombuffer(response.content, np.uint8)
result = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
return result
except Exception as e:
self.logger.log(f"인페인팅 API 호출 중 오류: {e}", level=logging.ERROR, exc_info=True)
return None
def process_translated_texts(self, translated_texts, unwanted_texts, local_image_path, index):
"""
번역된 단어 리스트(translated_texts)에서 unwanted_texts의 원본값이
앞이나 뒤에 포함되면 치환값으로 바꿉니다.
치환값이 '이미지삭제'라면 None 반환(이미지 제외)
"""
new_texts = []
for text in translated_texts:
replaced = False
for origin, replace in unwanted_texts.items():
# 앞/뒤에 원본값이 있는지 확인
if text.startswith(origin) or text.endswith(origin):
self.logger.log(f"[{text}] -> [{replace}] (치환)", level=logging.INFO)
if replace == "이미지삭제":
self.logger.log(f"이미지 {index+1} 제외됨: {local_image_path}", level=logging.INFO)
return None
# 앞/뒤 원본값만 치환
if text.startswith(origin):
new = replace + text[len(origin):]
elif text.endswith(origin):
new = text[:-len(origin)] + replace
new_texts.append(new)
replaced = True
break
if not replaced:
new_texts.append(text)
self.logger.log(f"최종 치환 결과: {new_texts}", level=logging.INFO)
return new_texts
async def process_image_list(self, image_urls, delay=1.0, file_prefix="", use_inpainting=False):
"""
이미지 리스트를 순차적으로 처리합니다.
"""
if not image_urls:
self.logger.log("처리할 이미지가 없습니다.", level=logging.INFO)
return []
processing_mode = "인페인팅" if use_inpainting else "웨일 번역"
self.logger.log(f"이미지 {len(image_urls)}개를 {processing_mode} 모드로 처리 시작", level=logging.INFO)
processed_images = []
for i, url in enumerate(image_urls):
self.logger.log(f"이미지 {i+1}/{len(image_urls)} 처리 중... ({processing_mode} 모드)", level=logging.INFO)
result = await self.process_single_image(
url, i, delay, file_prefix, use_inpainting
)
# 결과 처리
if isinstance(result, dict):
status = result.get('status')
path = result.get('path')
if status == 'inpainted':
processed_images.append(path)
self.logger.log(f"이미지 {i+1} 인페인팅 처리 완료", level=logging.INFO)
elif status == 'original':
processed_images.append(path)
self.logger.log(f"이미지 {i+1} 원본 사용", level=logging.INFO)
elif status == 'exclude':
self.logger.log(f"이미지 {i+1} 제외됨", level=logging.INFO)
# 제외된 이미지는 리스트에 추가하지 않음
else: # failed
self.logger.log(f"이미지 {i+1} 처리 실패: {result.get('error', '알 수 없는 오류')}", level=logging.WARNING)
# 실패한 이미지는 원본 경로 추가
processed_images.append(path)
else:
# 이전 버전과의 호환성을 위한 처리
if result:
processed_images.append(result)
self.logger.log(f"이미지 처리 완료: 총 {len(processed_images)}개 ({processing_mode} 모드)", level=logging.INFO)
return processed_images
def gpt_translate_texts(self, ocr_results, gpt_client):
texts = [result['text'] for result in ocr_results]
if not texts:
return []
prompt = (
"다음 중국어 문장들을 한국어로 자연스럽고 의미가 잘 전달되게 번역해줘. "
"순서와 개수는 반드시 그대로 유지하고, 결과는 JSON 배열(리스트)로만 반환해. "
"중국어 리스트:\n" +
str(texts)
)
response = gpt_client.ask(prompt)
if isinstance(response, list):
return response
elif isinstance(response, dict) and 'result' in response:
return response['result']
else:
print("GPT 번역 결과 파싱 실패, 원본 반환")
return texts
async def save_base64_to_temp_file(self, base64_data: str, suffix: str = "") -> str:
"""
base64 인코딩된 이미지 데이터를 임시 파일로 저장
Args:
base64_data (str): base64 인코딩된 이미지 데이터
suffix (str): 파일명에 추가할 접미사
Returns:
str: 저장된 임시 파일 경로, 실패시 None
"""
try:
import uuid
import time
# data:image/png;base64, 같은 헤더가 있으면 제거
if base64_data.startswith('data:image'):
base64_data = base64_data.split(',', 1)[1]
# base64 디코딩
image_bytes = base64.b64decode(base64_data)
# 이미지 유효성 검사
if not self.is_valid_image_data(image_bytes):
self.logger.log("유효하지 않은 이미지 데이터입니다.", level=logging.ERROR)
return None
# 임시 파일명 생성
timestamp = int(time.time())
unique_id = str(uuid.uuid4())[:8]
temp_filename = f"temp_image_{timestamp}_{unique_id}{suffix}.png"
temp_path = os.path.join(self.TEMP_IMAGE_DIR, temp_filename)
# 파일로 저장
with open(temp_path, 'wb') as f:
f.write(image_bytes)
self.logger.log(f"base64 이미지 데이터를 임시 파일로 저장: {temp_path}", level=logging.INFO)
return temp_path
except Exception as e:
self.logger.log(f"base64 이미지 데이터 저장 중 오류: {e}", level=logging.ERROR, exc_info=True)
return None
def google_translate_texts(self, ocr_results, src_lang='zh-cn', dest_lang='ko'):
"""
ocr_results에서 추출한 텍스트 리스트를 구글 번역기로 번역하여 반환합니다.
Args:
ocr_results: OCR 결과 리스트 ( 원소는 {'text': ...} 형태)
src_lang: 원본 언어 코드 (기본값: 중국어)
dest_lang: 번역할 언어 코드 (기본값: 한국어)
Returns:
번역된 텍스트 리스트
"""
texts = [result['text'] for result in ocr_results]
if not texts:
return []
translator = Translator()
try:
translations = translator.translate(texts, src=src_lang, dest=dest_lang)
# googletrans의 translate는 단일/복수 입력 모두 지원
if isinstance(translations, list):
return [t.text for t in translations]
else:
return [translations.text]
except Exception as e:
print(f"구글 번역 실패: {e}")
return texts

View File

@ -0,0 +1,178 @@
import random
import socket
import uvicorn
from fastapi import FastAPI, Query, Body
from pydantic import BaseModel
from typing import List, Optional
import asyncio
from concurrent.futures import ThreadPoolExecutor
from modules.image_processor2 import ImageProcessor
import os
import base64
# 포트 범위 설정
PORT_RANGE = (7000, 7000)
# 사용 가능한 포트 찾기
def find_free_port():
for _ in range(20):
port = random.randint(*PORT_RANGE)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
s.bind(("127.0.0.1", port))
return port
except OSError:
continue
raise RuntimeError("사용 가능한 포트를 찾을 수 없습니다.")
# 요청 모델 정의
class ImageRequest(BaseModel):
local_image_path: Optional[str] = None
image_data: Optional[str] = None # base64 인코딩된 이미지 데이터
file_prefix: Optional[str] = ""
use_inpainting: Optional[bool] = False
toggle_states: Optional[dict] = None
unwanted_texts: Optional[dict] = None
watermark_text: Optional[str] = None
watermark_opacity: Optional[float] = None
class ImagesRequest(BaseModel):
local_image_paths: Optional[List[str]] = None
image_data_list: Optional[List[str]] = None # base64 인코딩된 이미지 데이터 리스트
file_prefix: Optional[str] = ""
use_inpainting: Optional[bool] = False
toggle_states: Optional[dict] = None
unwanted_texts: Optional[dict] = None
watermark_text: Optional[str] = None
watermark_opacity: Optional[float] = None
# FastAPI 앱 생성
def create_app(image_processor: ImageProcessor, max_workers: int = 2):
app = FastAPI()
executor = ThreadPoolExecutor(max_workers=max_workers)
@app.post("/translate_image")
async def translate_image(req: ImageRequest):
# 워터마크 관련 옵션을 toggle_states에 병합
toggle_states = req.toggle_states.copy() if req.toggle_states else {}
if req.watermark_text is not None:
toggle_states["watermark_text"] = req.watermark_text
if req.watermark_opacity is not None:
toggle_states["watermark_opacity"] = req.watermark_opacity
# 이미지 경로 또는 데이터 검증
if not req.local_image_path and not req.image_data:
return {"error": "local_image_path 또는 image_data 중 하나는 반드시 제공되어야 합니다."}
# 이미지 경로가 없고 데이터만 있는 경우, 임시 파일로 저장
image_path = req.local_image_path
if not image_path and req.image_data:
try:
image_path = await image_processor.save_base64_to_temp_file(req.image_data)
if not image_path:
return {"error": "이미지 데이터를 임시 파일로 저장하는데 실패했습니다."}
except Exception as e:
return {"error": f"이미지 데이터 처리 중 오류: {str(e)}"}
# 파일 존재 확인
if not os.path.exists(image_path):
return {"error": f"이미지 파일을 찾을 수 없습니다: {image_path}"}
# 단일 이미지 번역
result = await image_processor.process_single_image(
toggle_states, req.unwanted_texts or {}, image_path, 0, req.file_prefix
)
# 결과를 base64로 변환하여 반환
if isinstance(result, dict):
result_path = result.get("path", None)
else:
result_path = result
if result_path and os.path.exists(result_path):
try:
# 처리된 이미지를 base64로 변환
with open(result_path, "rb") as f:
image_data = f.read()
result_base64 = base64.b64encode(image_data).decode('utf-8')
return {"result": result_base64, "format": "base64"}
except Exception as e:
return {"error": f"이미지를 base64로 변환하는 중 오류: {str(e)}"}
else:
return {"error": "처리된 이미지 파일을 찾을 수 없습니다."}
@app.post("/translate_images")
async def translate_images(req: ImagesRequest):
# 워터마크 관련 옵션을 toggle_states에 병합
toggle_states = req.toggle_states.copy() if req.toggle_states else {}
if req.watermark_text is not None:
toggle_states["watermark_text"] = req.watermark_text
if req.watermark_opacity is not None:
toggle_states["watermark_opacity"] = req.watermark_opacity
# 이미지 경로 리스트 또는 데이터 리스트 검증
if not req.local_image_paths and not req.image_data_list:
return {"error": "local_image_paths 또는 image_data_list 중 하나는 반드시 제공되어야 합니다."}
image_paths = []
# 이미지 경로가 있는 경우 그대로 사용
if req.local_image_paths:
image_paths.extend(req.local_image_paths)
# 이미지 데이터가 있는 경우 임시 파일로 저장
if req.image_data_list:
for idx, image_data in enumerate(req.image_data_list):
try:
temp_path = await image_processor.save_base64_to_temp_file(image_data, suffix=f"_data_{idx}")
if temp_path:
image_paths.append(temp_path)
except Exception as e:
return {"error": f"이미지 데이터 {idx} 처리 중 오류: {str(e)}"}
# 여러 이미지 병렬 번역
loop = asyncio.get_event_loop()
tasks = []
sem = asyncio.Semaphore(max_workers)
async def sem_task(idx, path):
async with sem:
return await image_processor.process_single_image(
toggle_states, req.unwanted_texts or {}, path, idx, req.file_prefix
)
for idx, path in enumerate(image_paths):
tasks.append(sem_task(idx, path))
results = await asyncio.gather(*tasks)
# 결과들을 base64로 변환하여 반환
base64_results = []
for result in results:
if isinstance(result, dict):
result_path = result.get("path", None)
else:
result_path = result
if result_path and os.path.exists(result_path):
try:
# 처리된 이미지를 base64로 변환
with open(result_path, "rb") as f:
image_data = f.read()
result_base64 = base64.b64encode(image_data).decode('utf-8')
base64_results.append(result_base64)
except Exception as e:
base64_results.append(None) # 변환 실패시 None
else:
base64_results.append(None) # 파일이 없으면 None
return {"results": base64_results, "format": "base64"}
return app
# 서버 실행 함수
def run_server(image_processor, max_workers=2):
port = find_free_port()
app = create_app(image_processor, max_workers)
uvicorn.run(app, host="0.0.0.0", port=port, workers=1)
# FastAPI의 workers는 프로세스 수이므로, 내부 병렬은 ThreadPoolExecutor로 제어
# 실제 워커 수는 process_single_image 병렬 호출로 제한
# 서버 실행 후 포트 정보 반환 가능
return port

BIN
modules/img/1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
modules/img/2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

BIN
modules/img/3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

BIN
modules/img/4.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
modules/img/5.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

BIN
modules/img/6.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

BIN
modules/img/7.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -0,0 +1,22 @@
# modules/migan_inpaint.py
import numpy as np
from migan import MIGAN
import cv2
def inpaint_with_migan(image, mask, device="cuda"):
"""
MIGAN을 사용해 인페인팅을 수행합니다.
"""
# MIGAN 모델 로딩
model = MIGAN(device=device)
# 이미지 전처리
if isinstance(image, str):
image = cv2.imread(image)
if isinstance(mask, str):
mask = cv2.imread(mask, cv2.IMREAD_GRAYSCALE)
# 인페인팅 수행
result = model.inpaint(image, mask)
return result

View File

@ -0,0 +1,27 @@
import numpy as np
import requests
import cv2
import base64
class IOPaintInpainting:
"""IOPaint 서버 연동 인페인팅 모델 (REST API /api/v1/inpaint 사용, 바이너리 PNG 반환)"""
def __init__(self, server_url="http://localhost:8080"):
self.api_url = f"http://localhost:8080/api/v1/inpaint"
def inpaint(self, image: np.ndarray, mask: np.ndarray, api_url:str = 'http://localhost:8080/api/v1/inpaint', ) -> np.ndarray:
# 이미지를 base64로 인코딩
_, img_encoded = cv2.imencode('.png', image)
_, mask_encoded = cv2.imencode('.png', mask)
img_b64 = base64.b64encode(img_encoded).decode('utf-8')
mask_b64 = base64.b64encode(mask_encoded).decode('utf-8')
payload = {
"image": img_b64,
"mask": mask_b64
}
response = requests.post(api_url, json=payload)
if response.status_code != 200:
print("IOPaint 서버 에러:", response.text)
return None
# 응답이 바이너리 PNG 이미지이므로 바로 디코딩
nparr = np.frombuffer(response.content, np.uint8)
result = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
return result

211
modules/iop_Manager.py Normal file
View File

@ -0,0 +1,211 @@
import numpy as np
import requests
import cv2
import base64
import subprocess
import random
import time
import threading
import os
import logging
class IOPaintManager:
"""IOPaint 서버 인스턴스 및 인페인팅 요청을 통합 관리하는 매니저"""
class ServerInstance:
def __init__(self, port, process):
self.port = port
self.process = process
self.busy = False
self.last_used = time.time()
def mark_busy(self):
self.busy = True
self.last_used = time.time()
def mark_idle(self):
self.busy = False
self.last_used = time.time()
def is_alive(self):
return self.process.poll() is None
def __init__(self, logger, num_instances=1, port_range=(8100, 8100), base_dir=None, wait_ready=30, model_dir=None):
self.logger = logger
self.instances = []
self.port_range = port_range
self.lock = threading.Lock()
self.base_dir = base_dir or os.getcwd()
self.model_dir = model_dir or os.path.join(self.base_dir, 'iop', 'models')
self.exe_path = os.path.join(self.base_dir, 'iop', 'iop.exe')
self._start_instances(num_instances, wait_ready)
def _get_random_port(self):
used_ports = {inst.port for inst in self.instances}
candidates = [p for p in range(self.port_range[0], self.port_range[1]+1) if p not in used_ports]
if not candidates:
self.logger.log("사용 가능한 포트가 없습니다.", level=logging.ERROR)
raise RuntimeError("사용 가능한 포트가 없습니다.")
return random.choice(candidates)
def wait_for_server_ready(self, port, timeout=30):
url = f"http://localhost:{port}/api/v1/server-config"
start = time.time()
last_error = None
self.logger.log(f"[{port}] 서버 준비 체크 시작 (최대 {timeout}초 대기)", level=logging.INFO)
tries = 0
while time.time() - start < timeout:
tries += 1
try:
r = requests.get(url, timeout=2)
self.logger.log(f"응답 : {r}", level=logging.INFO)
if r.status_code == 200:
elapsed = time.time() - start
self.logger.log(f"[{port}] 서버 준비 완료! (시도 {tries}회, {elapsed:.1f}초 소요)", level=logging.INFO)
return True
else:
self.logger.log(f"[{port}] 응답 코드: {r.status_code}", level=logging.INFO)
except Exception as e:
last_error = str(e)
self.logger.log(f"[{port}] 준비 체크 실패 (시도 {tries}회): {last_error}", level=logging.ERROR, exc_info=True)
time.sleep(0.5)
self.logger.log(f"[{port}] 서버 준비 실패 (총 {tries}회 시도, 마지막 에러: {last_error})", level=logging.ERROR, exc_info=True)
return False
def _start_instances(self, num, wait_ready):
self.logger.log(f"IOPaint 인스턴스 {num} 개 시작", level=logging.INFO)
try:
import torch
device_type = "cuda" if torch.cuda.is_available() else "cpu"
except Exception as e:
self.logger.log(f"torch import 또는 GPU 체크 실패: {e}", level=logging.WARNING)
device_type = "cpu"
for _ in range(num):
port = self._get_random_port()
cmd = [self.exe_path, 'start', '--model=lama', f'--device={device_type}', '--port', str(port), '--model-dir', self.model_dir]
self.logger.log(f"[{port}] 인스턴스 실행 명령: {' '.join(cmd)}", level=logging.INFO)
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
instance = self.ServerInstance(port, proc)
self.instances.append(instance)
start_wait = 8
time.sleep(start_wait)
self.logger.log(f"[{port}] 인스턴스 실행 명시대기: {start_wait}", level=logging.INFO)
if self.wait_for_server_ready(port, timeout=wait_ready):
self.logger.log(f"IOPaint 인스턴스 {instance.port} 준비됨", level=logging.INFO)
else:
self.logger.log(f"IOPaint 인스턴스 {instance.port} 시작 실패", level=logging.ERROR)
# 에러 메시지 출력
try:
out, err = proc.communicate(timeout=3)
self.logger.log(f"[{port}] 표준출력:\n{out.decode(errors='ignore')}", level=logging.INFO)
self.logger.log(f"[{port}] 표준에러:\n{err.decode(errors='ignore')}", level=logging.INFO)
except Exception as e:
self.logger.log(f"[{port}] 에러 메시지 읽기 실패: {e}", level=logging.ERROR)
def get_instance_info(self):
"""모든 인스턴스의 정보를 반환"""
info = []
for inst in self.instances:
info.append({
"port": inst.port,
"busy": inst.busy,
"alive": inst.is_alive(),
"last_used": inst.last_used
})
return info
def get_idle_instance(self):
"""놀고 있는(사용 가능한) 인스턴스 반환 (없으면 None)"""
with self.lock:
for inst in self.instances:
if not inst.busy and inst.is_alive():
inst.mark_busy()
self.logger.log(f"IOPaint 인스턴스 {inst.port} 사용 중", level=logging.INFO)
return inst
return None
def mark_instance_idle(self, port):
"""작업이 끝난 인스턴스를 idle로 표시"""
for inst in self.instances:
if inst.port == port:
inst.mark_idle()
self.logger.log(f"IOPaint 인스턴스 {inst.port} 유휴", level=logging.INFO)
break
def shutdown_all(self):
"""모든 서버 인스턴스 종료"""
for inst in self.instances:
if inst.is_alive():
inst.process.terminate()
self.logger.log(f"IOPaint 인스턴스 {inst.port} 종료", level=logging.INFO)
self.instances = []
self.logger.log("모든 IOPaint 인스턴스 종료", level=logging.INFO)
def inpaint(self, image, mask, instance=None) -> np.ndarray:
"""image와 mask를 경로나 np.ndarray 모두 지원"""
# 이미지 처리
if isinstance(image, str):
image_np = cv2.imread(image)
if image_np is None:
self.logger.log(f"이미지 로딩 실패: {image}", level=logging.ERROR)
return None
else:
image_np = image
# 마스크 처리
if isinstance(mask, str):
mask_np = cv2.imread(mask, cv2.IMREAD_GRAYSCALE)
if mask_np is None:
self.logger.log(f"마스크 로딩 실패: {mask}", level=logging.ERROR)
return None
else:
mask_np = mask
if instance is None:
instance = self.get_idle_instance()
if instance is None:
self.logger.log("사용 가능한 IOPaint 인스턴스가 없습니다.", level=logging.ERROR)
return None
api_url = f"http://localhost:{instance.port}/api/v1/inpaint"
self.logger.log(f"IOPaint 인스턴스 {instance.port} 사용", level=logging.INFO)
try:
_, img_encoded = cv2.imencode('.png', image_np)
_, mask_encoded = cv2.imencode('.png', mask_np)
img_b64 = base64.b64encode(img_encoded).decode('utf-8')
mask_b64 = base64.b64encode(mask_encoded).decode('utf-8')
payload = {
"image": img_b64,
"mask": mask_b64
}
response = requests.post(api_url, json=payload)
if response.status_code != 200:
self.logger.log(f"IOPaint 서버 에러: {response.text}", level=logging.ERROR)
return None
nparr = np.frombuffer(response.content, np.uint8)
result = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
return result
finally:
self.mark_instance_idle(instance.port)
def add_instance(self, wait_ready=30):
try:
import torch
device_type = "cuda" if torch.cuda.is_available() else "cpu"
except Exception as e:
self.logger.log(f"torch import 또는 GPU 체크 실패: {e}", level=logging.WARNING)
device_type = "cpu"
port = self._get_random_port()
cmd = [self.exe_path, 'start', '--model=lama', f'--device={device_type}', '--port', str(port), '--model-dir', self.model_dir]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
instance = self.ServerInstance(port, proc)
self.instances.append(instance)
if self.wait_for_server_ready(port, timeout=wait_ready):
self.logger.log(f"IOPaint 인스턴스 {instance.port} 시작", level=logging.INFO)
else:
self.logger.log(f"IOPaint 인스턴스 {instance.port} 시작 실패", level=logging.ERROR)
return instance
# if __name__ == '__main__':
# manager = IOPaintManager(num_instances=1)
# # result = manager.inpaint(image, mask) # 자동으로 idle 인스턴스에 요청
# print(manager.get_instance_info())
# manager.shutdown_all()

View File

@ -0,0 +1,31 @@
# iop_tensorrt.py
import torch
from torch2trt import torch2trt, TRTModule
# --- 1. 모델 정의 및 로드 (nn.Module 형태) ---
# MIGAN 예시: 원본 모델 클래스 import 필요
from model_zoo.migan_inference import MIGAN # 실제 경로로 수정하세요
model = MIGAN().eval().cuda()
# 필요 시 weight 로드
model.load_state_dict(torch.load("migan_traced_weights.pth")) # weights 파일 위치
# --- 2. 더미 입력 (4채널: RGB + 마스크) ---
H, W = 512, 512 # 이미지 크기에 맞게 조정
dummy = torch.randn((1, 4, H, W)).cuda().half() # FP16 입력 가능
# --- 3. TensorRT 변환 ---
model_trt = torch2trt(
model, [dummy],
fp16_mode=True,
max_batch_size=1,
default_device_type=None, # DLA 사용 시 trt.DeviceType.DLA 지정 가능
gpu_fallback=True
)
# --- 4. TRTModule wrapping 및 저장 ---
trt_mod = TRTModule()
trt_mod.load_state_dict(model_trt.state_dict())
torch.save(trt_mod.state_dict(), "migan_trt.pth")
print("✅ TensorRT 변환 완료 — migan_trt.pth 저장됨")

30
modules/lama_inpaint.py Normal file
View File

@ -0,0 +1,30 @@
from simple_lama_inpainting import SimpleLama
from PIL import Image
import numpy as np
import cv2
def inpaint_with_simple_lama(image, mask, device="cuda"):
"""
simple-lama-inpainting을 사용해 인페인팅을 수행합니다.
image: 파일 경로(str), np.ndarray, 또는 PIL.Image.Image
mask: 파일 경로(str), np.ndarray, 또는 PIL.Image.Image (흑백)
device: "cuda" 또는 "cpu"
return: np.ndarray (BGR)
"""
# 이미지 로딩
if isinstance(image, str):
image = Image.open(image)
elif isinstance(image, np.ndarray):
image = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
# mask도 동일하게 처리
if isinstance(mask, str):
mask = Image.open(mask)
elif isinstance(mask, np.ndarray):
mask = Image.fromarray(mask)
# 인페인팅
simple_lama = SimpleLama(device=device)
result = simple_lama(image, mask)
# PIL.Image -> np.ndarray(BGR)
result_np = cv2.cvtColor(np.array(result), cv2.COLOR_RGB2BGR)
return result_np

View File

@ -0,0 +1,131 @@
import os
import socket
import threading
from http.server import HTTPServer, SimpleHTTPRequestHandler
import logging
class LocalImageServer:
"""로컬 이미지 파일을 웹에서 접근 가능하도록 하는 HTTP 서버"""
def __init__(self, logger, image_dir, port=8000):
self.logger = logger
self.image_dir = os.path.abspath(image_dir) # 절대 경로로 변환
self.original_cwd = os.getcwd() # 원래 작업 디렉토리 저장
self.port = self.find_available_port(port)
self.server = None
self.server_thread = None
def find_available_port(self, start_port=8000, max_port=8100):
"""사용 가능한 포트를 찾습니다"""
for port in range(start_port, max_port + 1):
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('localhost', port))
return port
except OSError:
continue
raise RuntimeError(f"포트 {start_port}-{max_port} 범위에서 사용 가능한 포트를 찾을 수 없습니다.")
def start_server(self):
"""HTTP 서버를 시작합니다"""
if self.server_thread and self.server_thread.is_alive():
self.logger.log(f"로컬 이미지 서버가 이미 포트 {self.port}에서 실행 중입니다.", level=logging.DEBUG)
return
# 이미지 디렉토리 존재 확인
if not os.path.exists(self.image_dir):
try:
os.makedirs(self.image_dir, exist_ok=True)
self.logger.log(f"이미지 디렉토리 생성: {self.image_dir}", level=logging.INFO)
except Exception as e:
self.logger.log(f"이미지 디렉토리 생성 실패: {e}", level=logging.ERROR)
raise
try:
# 작업 디렉토리 변경 없이 CustomHandler에서 직접 경로 처리
class CustomHandler(SimpleHTTPRequestHandler):
def __init__(self, *args, image_directory=None, **kwargs):
self.image_directory = image_directory
super().__init__(*args, **kwargs)
def translate_path(self, path):
"""요청 경로를 이미지 디렉토리 내의 실제 파일 경로로 변환"""
# 기본 translate_path 호출하여 상대 경로 얻기
path = super().translate_path(path)
# 현재 작업 디렉토리 대신 이미지 디렉토리 사용
rel_path = os.path.relpath(path, os.getcwd())
return os.path.join(self.image_directory, rel_path)
def log_message(self, format, *args):
# 로그 출력을 비활성화 (너무 많은 로그 방지)
pass
def end_headers(self):
# CORS 헤더 추가
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
super().end_headers()
# 핸들러에 이미지 디렉토리 전달
def handler_factory(*args, **kwargs):
return CustomHandler(*args, image_directory=self.image_dir, **kwargs)
self.server = HTTPServer(('localhost', self.port), handler_factory)
self.server_thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.server_thread.start()
self.logger.log(f"로컬 이미지 서버가 포트 {self.port}에서 시작되었습니다. (디렉토리: {self.image_dir})", level=logging.INFO)
except Exception as e:
self.logger.log(f"로컬 웹서버 시작 실패: {e}", level=logging.ERROR)
# 실패 시 상태 정리
self.server = None
self.server_thread = None
raise
def stop_server(self):
"""HTTP 서버를 중지합니다"""
if self.server:
try:
self.server.shutdown()
self.server.server_close()
if self.server_thread and self.server_thread.is_alive():
self.server_thread.join(timeout=5)
self.logger.log("로컬 이미지 서버가 중지되었습니다.", level=logging.INFO)
except Exception as e:
self.logger.log(f"로컬 웹서버 중지 중 오류 발생: {e}", level=logging.ERROR)
finally:
self.server = None
self.server_thread = None
def restart_server(self):
"""서버를 재시작합니다"""
self.logger.log("로컬 이미지 서버 재시작 중...", level=logging.INFO)
self.stop_server()
# 새로운 포트 찾기
self.port = self.find_available_port(self.port)
self.start_server()
def get_base_url(self):
"""서버의 기본 URL을 반환합니다"""
return f"http://localhost:{self.port}"
def is_running(self):
"""서버가 실행 중인지 확인합니다"""
return self.server is not None and self.server_thread and self.server_thread.is_alive()
def get_file_url(self, filename):
"""특정 파일의 URL을 반환합니다"""
if not self.is_running():
self.logger.log("서버가 실행되지 않았습니다.", level=logging.WARNING)
return None
return f"{self.get_base_url()}/{filename}"
def __del__(self):
"""소멸자에서 서버 정리"""
try:
self.stop_server()
except:
pass

156
modules/loggerModule.py Normal file
View File

@ -0,0 +1,156 @@
import logging
from logging.handlers import RotatingFileHandler, BaseRotatingHandler
import os
import glob
import traceback
import inspect
class Logger1():
def __init__(self, log_file="ITServer.log", logger_name="MainLogger",
file_log_level=logging.DEBUG):
"""
Logger 초기화
:param log_file: 로그 파일 이름
:param logger_name: 로거 이름
:param file_log_level: 파일 로거의 로그 레벨
"""
super().__init__()
self.file_log_level = file_log_level
# 로그 설정
self.logger = logging.getLogger(logger_name)
self.logger.setLevel(file_log_level) # 파일 로거 레벨 설정
# 포맷 설정
self.simple_format = "[%(asctime)s] [%(levelname)s] %(message)s"
self.detailed_format = (
"[%(asctime)s] [%(threadName)s] [%(levelname)s] "
"[%(filename)s:%(funcName)s:%(lineno)d] %(message)s"
)
# 핸들러 추가
self._add_console_handler(file_log_level)
self._add_file_handler(log_file, file_log_level)
def _add_console_handler(self, level):
"""콘솔 핸들러 추가"""
console_handler = logging.StreamHandler()
console_handler.setLevel(level)
formatter = logging.Formatter(
self.detailed_format if level <= logging.DEBUG else self.simple_format
)
console_handler.setFormatter(formatter)
self.logger.addHandler(console_handler)
def _add_file_handler(self, log_file, level):
"""파일 핸들러 추가"""
# 확장자가 .log가 아니면 .log로 변경
if not log_file.endswith('.log'):
base_name, _ = os.path.splitext(log_file)
log_file = base_name + '.log'
# 커스텀 로테이팅 핸들러 사용
file_handler = CustomRotatingFileHandler(
log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
)
file_handler.setLevel(level)
formatter = logging.Formatter(
self.detailed_format if level <= logging.DEBUG else self.simple_format
)
file_handler.setFormatter(formatter)
self.logger.addHandler(file_handler)
def log(self, message, level=logging.INFO, exc_info=False):
"""로그 메시지 기록"""
if exc_info:
message = f"{message}\n{traceback.format_exc()}"
# 호출 위치 정보를 동적으로 추출
caller_frame = logging.currentframe().f_back
record = self.logger.makeRecord(
self.logger.name, level, caller_frame.f_code.co_filename,
caller_frame.f_lineno, message, None, None, caller_frame.f_code.co_name
)
# 파일 로거에 메시지 전달
if level >= self.file_log_level:
self.logger.handle(record)
class CustomRotatingFileHandler(BaseRotatingHandler):
"""로그 파일을 모두 .log 확장자로 생성하는 커스텀 핸들러"""
def __init__(self, filename, mode='a', maxBytes=0, backupCount=0, encoding=None):
super().__init__(filename, mode, encoding)
self.maxBytes = maxBytes
self.backupCount = backupCount
# 기존 로그 파일 확인 및 인덱스 설정
self._base_filename = filename
self._extension = '.log'
def doRollover(self):
"""롤오버 수행 - 파일이 최대 크기에 도달하면 새 파일 생성"""
if self.stream:
self.stream.close()
self.stream = None
# 기존 로그 파일 이름을 기반으로 백업 파일 생성
base_name, ext = os.path.splitext(self._base_filename)
# 현재 디렉토리의 모든 로그 파일 확인
log_dir = os.path.dirname(self._base_filename) or '.'
existing_logs = glob.glob(f"{base_name}*.log")
existing_logs.sort()
# 최대 백업 수 초과하는 파일 제거
while len(existing_logs) >= self.backupCount:
try:
oldest_file = existing_logs.pop(0)
os.remove(oldest_file)
except Exception:
pass
# 새 로그 파일 이름 생성 (주 로그 파일 이름은 그대로 유지)
# 예: log.log, log_1.log, log_2.log, ...
if os.path.exists(self._base_filename):
# 인덱스가 있는 로그 파일들 찾기
indexed_logs = [f for f in existing_logs if f != self._base_filename]
max_index = 0
for log_file in indexed_logs:
try:
# 파일 이름에서 인덱스 부분 추출
name_part = os.path.basename(log_file)
name_without_ext = os.path.splitext(name_part)[0]
if '_' in name_without_ext:
idx_str = name_without_ext.split('_')[-1]
if idx_str.isdigit():
max_index = max(max_index, int(idx_str))
except Exception:
pass
# 새 인덱스로 파일 이름 설정
new_index = max_index + 1
new_log_file = f"{base_name}_{new_index}.log"
# 기존 파일 이름 변경
try:
os.rename(self._base_filename, new_log_file)
except Exception:
pass
# 스트림 다시 열기
self.mode = 'w'
self.stream = self._open()
def shouldRollover(self, record):
"""롤오버가 필요한지 확인"""
if self.stream is None: # 첫 번째 로그 쓰기 시도
self.stream = self._open()
if self.maxBytes > 0: # 최대 크기가 지정된 경우만 검사
self.stream.seek(0, 2) # 파일 끝으로 이동
if self.stream.tell() >= self.maxBytes:
return True
return False

44
modules/mask_module.py Normal file
View File

@ -0,0 +1,44 @@
import cv2
import numpy as np
from typing import List, Dict, Any
from shapely.geometry import Polygon
import logging
class MaskModule:
def __init__(self, logger, base_dir):
self.logger = logger
self.base_dir = base_dir
self.logger.log("마스크 모듈 초기화 완료", level=logging.INFO)
def create_masks(self, image_path: str, ocr_results: List[Dict], expansion_size: int = 10, blur_size: int = 15, mask_option: str = "basic") -> np.ndarray:
image = cv2.imread(image_path)
if image is None:
self.logger.log(f"이미지를 읽을 수 없습니다: {image_path}", level=logging.ERROR)
return None
height, width = image.shape[:2]
mask = np.zeros((height, width), dtype=np.uint8)
for i, result in enumerate(ocr_results, 1):
polygon = result['polygon']
expanded_poly = self.expand_polygon(polygon, offset=5)
cv2.fillPoly(mask, [expanded_poly], 255)
processed_mask = self.process_mask(mask, expansion_size, blur_size)
return processed_mask
def expand_polygon(self, polygon, offset=15):
poly = Polygon(polygon)
expanded = poly.buffer(offset)
if expanded.is_empty:
return np.array(polygon, dtype=np.int32)
return np.array(expanded.exterior.coords, dtype=np.int32)
def process_mask(self, mask: np.ndarray, expansion_size: int = 5, blur_size: int = 3) -> np.ndarray:
processed_mask = mask.copy()
if expansion_size > 0:
kernel = np.ones((expansion_size, expansion_size), np.uint8)
processed_mask = cv2.dilate(processed_mask, kernel, iterations=1)
if blur_size > 0:
blur_size = blur_size if blur_size % 2 == 1 else blur_size + 1
processed_mask = cv2.GaussianBlur(processed_mask, (blur_size, blur_size), 0)
return processed_mask

View File

@ -0,0 +1,55 @@
# migan_inpaint.py (이름 예시)
import torch
import numpy as np
import cv2
from PIL import Image
from iopaint.model.mi_gan import MIGAN
from iopaint.schema import InpaintRequest
# MIGAN 인스턴스(모델 로드)는 미리 만들어서 재사용 권장!
# def get_migan(device="cuda", model_path="modules/migan/migan_traced.pt"):
def get_migan(device, model_path):
migan = MIGAN(device)
migan.model = torch.jit.load(model_path, map_location=device).eval()
migan.device = torch.device(device)
return migan
def inpaint_with_migan(image, mask, device="cuda", model_path="modules/migan/migan_traced.pt", migan_obj=None):
"""
MIGAN을 사용해 인페인팅을 수행합니다.
image: 파일 경로(str), np.ndarray, 또는 PIL.Image.Image
mask: 파일 경로(str), np.ndarray, 또는 PIL.Image.Image (흑백)
device: "cuda" 또는 "cpu"
migan_obj: 이미 로드한 MIGAN 인스턴스 (권장)
return: np.ndarray (BGR)
"""
# 이미지 로딩 (RGB)
if isinstance(image, str):
image = cv2.imread(image)
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
elif isinstance(image, Image.Image):
image = np.array(image.convert("RGB"))
elif isinstance(image, np.ndarray):
if image.shape[2] == 4:
image = cv2.cvtColor(image, cv2.COLOR_BGRA2RGB)
elif image.shape[2] == 3:
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
# 이미 RGB일 수도 있으니 확인 후 위 생략 가능
# 마스크 로딩 (흑백)
if isinstance(mask, str):
mask = cv2.imread(mask, cv2.IMREAD_GRAYSCALE)
elif isinstance(mask, Image.Image):
mask = np.array(mask.convert("L"))
elif isinstance(mask, np.ndarray):
if len(mask.shape) == 3:
mask = cv2.cvtColor(mask, cv2.COLOR_BGR2GRAY)
# MIGAN 인스턴스 준비
if migan_obj is None:
migan_obj = get_migan(device=device, model_path=model_path)
# 인페인팅 (InpaintRequest 기본값)
result_bgr = migan_obj(image, mask, InpaintRequest())
return result_bgr

View File

@ -0,0 +1,56 @@
import os
import importlib.util
import shutil
import ctypes
import logging
os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1"
# https://github.com/pytorch/pytorch/issues/27971#issuecomment-1768868068
os.environ["ONEDNN_PRIMITIVE_CACHE_CAPACITY"] = "1"
os.environ["LRU_CACHE_CAPACITY"] = "1"
# prevent CPU memory leak when run model on GPU
# https://github.com/pytorch/pytorch/issues/98688#issuecomment-1869288431
# https://github.com/pytorch/pytorch/issues/108334#issuecomment-1752763633
os.environ["TORCH_CUDNN_V8_API_LRU_CACHE_LIMIT"] = "1"
import warnings
warnings.simplefilter("ignore", UserWarning)
def fix_window_pytorch():
# copy from: https://github.com/comfyanonymous/ComfyUI/blob/5cbaa9e07c97296b536f240688f5a19300ecf30d/fix_torch.py#L4
import platform
try:
if platform.system() != "Windows":
return
torch_spec = importlib.util.find_spec("torch")
for folder in torch_spec.submodule_search_locations:
lib_folder = os.path.join(folder, "lib")
test_file = os.path.join(lib_folder, "fbgemm.dll")
dest = os.path.join(lib_folder, "libomp140.x86_64.dll")
if os.path.exists(dest):
break
with open(test_file, "rb") as f:
contents = f.read()
if b"libomp140.x86_64.dll" not in contents:
break
try:
mydll = ctypes.cdll.LoadLibrary(test_file)
except FileNotFoundError:
logging.warning("Detected pytorch version with libomp issue, patching.")
shutil.copyfile(os.path.join(lib_folder, "libiomp5md.dll"), dest)
except:
pass
def entry_point():
# To make os.environ["XDG_CACHE_HOME"] = args.model_cache_dir works for diffusers
# https://github.com/huggingface/diffusers/blob/be99201a567c1ccd841dc16fb24e88f7f239c187/src/diffusers/utils/constants.py#L18
from iopaint.cli import typer_app
fix_window_pytorch()
typer_app()

View File

@ -0,0 +1,4 @@
from iopaint import entry_point
if __name__ == "__main__":
entry_point()

View File

@ -0,0 +1,411 @@
import asyncio
import os
import threading
import time
import traceback
from pathlib import Path
from typing import Optional, Dict, List
import cv2
import numpy as np
import socketio
import torch
try:
torch._C._jit_override_can_fuse_on_cpu(False)
torch._C._jit_override_can_fuse_on_gpu(False)
torch._C._jit_set_texpr_fuser_enabled(False)
torch._C._jit_set_nvfuser_enabled(False)
torch._C._jit_set_profiling_mode(False)
except:
pass
import uvicorn
from PIL import Image
from fastapi import APIRouter, FastAPI, Request, UploadFile
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse, Response
from fastapi.staticfiles import StaticFiles
from loguru import logger
from socketio import AsyncServer
from iopaint.file_manager import FileManager
from iopaint.helper import (
load_img,
decode_base64_to_image,
pil_to_bytes,
numpy_to_bytes,
concat_alpha_channel,
gen_frontend_mask,
adjust_mask,
)
from iopaint.model.utils import torch_gc
from iopaint.model_manager import ModelManager
from iopaint.plugins import build_plugins, RealESRGANUpscaler, InteractiveSeg
from iopaint.plugins.base_plugin import BasePlugin
from iopaint.plugins.remove_bg import RemoveBG
from iopaint.schema import (
GenInfoResponse,
ApiConfig,
ServerConfigResponse,
SwitchModelRequest,
InpaintRequest,
RunPluginRequest,
SDSampler,
PluginInfo,
AdjustMaskRequest,
RemoveBGModel,
SwitchPluginModelRequest,
ModelInfo,
InteractiveSegModel,
RealESRGANModel,
)
CURRENT_DIR = Path(__file__).parent.absolute().resolve()
WEB_APP_DIR = CURRENT_DIR / "web_app"
def api_middleware(app: FastAPI):
rich_available = False
try:
if os.environ.get("WEBUI_RICH_EXCEPTIONS", None) is not None:
import anyio # importing just so it can be placed on silent list
import starlette # importing just so it can be placed on silent list
from rich.console import Console
console = Console()
rich_available = True
except Exception:
pass
def handle_exception(request: Request, e: Exception):
err = {
"error": type(e).__name__,
"detail": vars(e).get("detail", ""),
"body": vars(e).get("body", ""),
"errors": str(e),
}
if not isinstance(
e, HTTPException
): # do not print backtrace on known httpexceptions
message = f"API error: {request.method}: {request.url} {err}"
if rich_available:
print(message)
console.print_exception(
show_locals=True,
max_frames=2,
extra_lines=1,
suppress=[anyio, starlette],
word_wrap=False,
width=min([console.width, 200]),
)
else:
traceback.print_exc()
return JSONResponse(
status_code=vars(e).get("status_code", 500), content=jsonable_encoder(err)
)
@app.middleware("http")
async def exception_handling(request: Request, call_next):
try:
return await call_next(request)
except Exception as e:
return handle_exception(request, e)
@app.exception_handler(Exception)
async def fastapi_exception_handler(request: Request, e: Exception):
return handle_exception(request, e)
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, e: HTTPException):
return handle_exception(request, e)
cors_options = {
"allow_methods": ["*"],
"allow_headers": ["*"],
"allow_origins": ["*"],
"allow_credentials": True,
"expose_headers": ["X-Seed"],
}
app.add_middleware(CORSMiddleware, **cors_options)
global_sio: AsyncServer = None
def diffuser_callback(pipe, step: int, timestep: int, callback_kwargs: Dict = {}):
# self: DiffusionPipeline, step: int, timestep: int, callback_kwargs: Dict
# logger.info(f"diffusion callback: step={step}, timestep={timestep}")
# We use asyncio loos for task processing. Perhaps in the future, we can add a processing queue similar to InvokeAI,
# but for now let's just start a separate event loop. It shouldn't make a difference for single person use
asyncio.run(global_sio.emit("diffusion_progress", {"step": step}))
return {}
class Api:
def __init__(self, app: FastAPI, config: ApiConfig):
self.app = app
self.config = config
self.router = APIRouter()
self.queue_lock = threading.Lock()
api_middleware(self.app)
self.file_manager = self._build_file_manager()
self.plugins = self._build_plugins()
self.model_manager = self._build_model_manager()
# fmt: off
self.add_api_route("/api/v1/gen-info", self.api_geninfo, methods=["POST"], response_model=GenInfoResponse)
self.add_api_route("/api/v1/server-config", self.api_server_config, methods=["GET"],
response_model=ServerConfigResponse)
self.add_api_route("/api/v1/model", self.api_current_model, methods=["GET"], response_model=ModelInfo)
self.add_api_route("/api/v1/model", self.api_switch_model, methods=["POST"], response_model=ModelInfo)
self.add_api_route("/api/v1/inputimage", self.api_input_image, methods=["GET"])
self.add_api_route("/api/v1/inpaint", self.api_inpaint, methods=["POST"])
self.add_api_route("/api/v1/switch_plugin_model", self.api_switch_plugin_model, methods=["POST"])
self.add_api_route("/api/v1/run_plugin_gen_mask", self.api_run_plugin_gen_mask, methods=["POST"])
self.add_api_route("/api/v1/run_plugin_gen_image", self.api_run_plugin_gen_image, methods=["POST"])
self.add_api_route("/api/v1/samplers", self.api_samplers, methods=["GET"])
self.add_api_route("/api/v1/adjust_mask", self.api_adjust_mask, methods=["POST"])
self.add_api_route("/api/v1/save_image", self.api_save_image, methods=["POST"])
self.app.mount("/", StaticFiles(directory=WEB_APP_DIR, html=True), name="assets")
# fmt: on
global global_sio
self.sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
self.combined_asgi_app = socketio.ASGIApp(self.sio, self.app)
self.app.mount("/ws", self.combined_asgi_app)
global_sio = self.sio
def add_api_route(self, path: str, endpoint, **kwargs):
return self.app.add_api_route(path, endpoint, **kwargs)
def api_save_image(self, file: UploadFile):
# Sanitize filename to prevent path traversal
safe_filename = Path(file.filename).name # Get just the filename component
# Construct the full path within output_dir
output_path = self.config.output_dir / safe_filename
# Ensure output directory exists
if not self.config.output_dir or not self.config.output_dir.exists():
raise HTTPException(
status_code=400,
detail="Output directory not configured or doesn't exist",
)
# Read and write the file
origin_image_bytes = file.file.read()
with open(output_path, "wb") as fw:
fw.write(origin_image_bytes)
def api_current_model(self) -> ModelInfo:
return self.model_manager.current_model
def api_switch_model(self, req: SwitchModelRequest) -> ModelInfo:
if req.name == self.model_manager.name:
return self.model_manager.current_model
self.model_manager.switch(req.name)
return self.model_manager.current_model
def api_switch_plugin_model(self, req: SwitchPluginModelRequest):
if req.plugin_name in self.plugins:
self.plugins[req.plugin_name].switch_model(req.model_name)
if req.plugin_name == RemoveBG.name:
self.config.remove_bg_model = req.model_name
if req.plugin_name == RealESRGANUpscaler.name:
self.config.realesrgan_model = req.model_name
if req.plugin_name == InteractiveSeg.name:
self.config.interactive_seg_model = req.model_name
torch_gc()
def api_server_config(self) -> ServerConfigResponse:
plugins = []
for it in self.plugins.values():
plugins.append(
PluginInfo(
name=it.name,
support_gen_image=it.support_gen_image,
support_gen_mask=it.support_gen_mask,
)
)
return ServerConfigResponse(
plugins=plugins,
modelInfos=self.model_manager.scan_models(),
removeBGModel=self.config.remove_bg_model,
removeBGModels=RemoveBGModel.values(),
realesrganModel=self.config.realesrgan_model,
realesrganModels=RealESRGANModel.values(),
interactiveSegModel=self.config.interactive_seg_model,
interactiveSegModels=InteractiveSegModel.values(),
enableFileManager=self.file_manager is not None,
enableAutoSaving=self.config.output_dir is not None,
enableControlnet=self.model_manager.enable_controlnet,
controlnetMethod=self.model_manager.controlnet_method,
disableModelSwitch=False,
isDesktop=False,
samplers=self.api_samplers(),
)
def api_input_image(self) -> FileResponse:
if self.config.input is None:
raise HTTPException(status_code=200, detail="No input image configured")
if self.config.input.is_file():
return FileResponse(self.config.input)
raise HTTPException(status_code=404, detail="Input image not found")
def api_geninfo(self, file: UploadFile) -> GenInfoResponse:
_, _, info = load_img(file.file.read(), return_info=True)
parts = info.get("parameters", "").split("Negative prompt: ")
prompt = parts[0].strip()
negative_prompt = ""
if len(parts) > 1:
negative_prompt = parts[1].split("\n")[0].strip()
return GenInfoResponse(prompt=prompt, negative_prompt=negative_prompt)
def api_inpaint(self, req: InpaintRequest):
image, alpha_channel, infos, ext = decode_base64_to_image(req.image)
mask, _, _, _ = decode_base64_to_image(req.mask, gray=True)
logger.info(f"image ext: {ext}")
mask = cv2.threshold(mask, 127, 255, cv2.THRESH_BINARY)[1]
if image.shape[:2] != mask.shape[:2]:
raise HTTPException(
400,
detail=f"Image size({image.shape[:2]}) and mask size({mask.shape[:2]}) not match.",
)
start = time.time()
rgb_np_img = self.model_manager(image, mask, req)
logger.info(f"process time: {(time.time() - start) * 1000:.2f}ms")
torch_gc()
rgb_np_img = cv2.cvtColor(rgb_np_img.astype(np.uint8), cv2.COLOR_BGR2RGB)
rgb_res = concat_alpha_channel(rgb_np_img, alpha_channel)
res_img_bytes = pil_to_bytes(
Image.fromarray(rgb_res),
ext=ext,
quality=self.config.quality,
infos=infos,
)
asyncio.run(self.sio.emit("diffusion_finish"))
return Response(
content=res_img_bytes,
media_type=f"image/{ext}",
headers={"X-Seed": str(req.sd_seed)},
)
def api_run_plugin_gen_image(self, req: RunPluginRequest):
ext = "png"
if req.name not in self.plugins:
raise HTTPException(status_code=422, detail="Plugin not found")
if not self.plugins[req.name].support_gen_image:
raise HTTPException(
status_code=422, detail="Plugin does not support output image"
)
rgb_np_img, alpha_channel, infos, _ = decode_base64_to_image(req.image)
bgr_or_rgba_np_img = self.plugins[req.name].gen_image(rgb_np_img, req)
torch_gc()
if bgr_or_rgba_np_img.shape[2] == 4:
rgba_np_img = bgr_or_rgba_np_img
else:
rgba_np_img = cv2.cvtColor(bgr_or_rgba_np_img, cv2.COLOR_BGR2RGB)
rgba_np_img = concat_alpha_channel(rgba_np_img, alpha_channel)
return Response(
content=pil_to_bytes(
Image.fromarray(rgba_np_img),
ext=ext,
quality=self.config.quality,
infos=infos,
),
media_type=f"image/{ext}",
)
def api_run_plugin_gen_mask(self, req: RunPluginRequest):
if req.name not in self.plugins:
raise HTTPException(status_code=422, detail="Plugin not found")
if not self.plugins[req.name].support_gen_mask:
raise HTTPException(
status_code=422, detail="Plugin does not support output image"
)
rgb_np_img, _, _, _ = decode_base64_to_image(req.image)
bgr_or_gray_mask = self.plugins[req.name].gen_mask(rgb_np_img, req)
torch_gc()
res_mask = gen_frontend_mask(bgr_or_gray_mask)
return Response(
content=numpy_to_bytes(res_mask, "png"),
media_type="image/png",
)
def api_samplers(self) -> List[str]:
return [member.value for member in SDSampler.__members__.values()]
def api_adjust_mask(self, req: AdjustMaskRequest):
mask, _, _, _ = decode_base64_to_image(req.mask, gray=True)
mask = adjust_mask(mask, req.kernel_size, req.operate)
return Response(content=numpy_to_bytes(mask, "png"), media_type="image/png")
def launch(self):
self.app.include_router(self.router)
uvicorn.run(
self.combined_asgi_app,
host=self.config.host,
port=self.config.port,
timeout_keep_alive=999999999,
)
def _build_file_manager(self) -> Optional[FileManager]:
if self.config.input and self.config.input.is_dir():
logger.info(
f"Input is directory, initialize file manager {self.config.input}"
)
return FileManager(
app=self.app,
input_dir=self.config.input,
mask_dir=self.config.mask_dir,
output_dir=self.config.output_dir,
)
return None
def _build_plugins(self) -> Dict[str, BasePlugin]:
return build_plugins(
self.config.enable_interactive_seg,
self.config.interactive_seg_model,
self.config.interactive_seg_device,
self.config.enable_remove_bg,
self.config.remove_bg_device,
self.config.remove_bg_model,
self.config.enable_anime_seg,
self.config.enable_realesrgan,
self.config.realesrgan_device,
self.config.realesrgan_model,
self.config.enable_gfpgan,
self.config.gfpgan_device,
self.config.enable_restoreformer,
self.config.restoreformer_device,
self.config.no_half,
)
def _build_model_manager(self):
return ModelManager(
name=self.config.model,
device=torch.device(self.config.device),
no_half=self.config.no_half,
low_mem=self.config.low_mem,
disable_nsfw=self.config.disable_nsfw_checker,
sd_cpu_textencoder=self.config.cpu_textencoder,
local_files_only=self.config.local_files_only,
cpu_offload=self.config.cpu_offload,
callback=diffuser_callback,
)

View File

@ -0,0 +1,128 @@
import json
from pathlib import Path
from typing import Dict, Optional
import cv2
import numpy as np
from PIL import Image
from loguru import logger
from rich.console import Console
from rich.progress import (
Progress,
SpinnerColumn,
TimeElapsedColumn,
MofNCompleteColumn,
TextColumn,
BarColumn,
TaskProgressColumn,
)
from iopaint.helper import pil_to_bytes
from iopaint.model.utils import torch_gc
from iopaint.model_manager import ModelManager
from iopaint.schema import InpaintRequest
def glob_images(path: Path) -> Dict[str, Path]:
# png/jpg/jpeg
if path.is_file():
return {path.stem: path}
elif path.is_dir():
res = {}
for it in path.glob("*.*"):
if it.suffix.lower() in [".png", ".jpg", ".jpeg"]:
res[it.stem] = it
return res
def batch_inpaint(
model: str,
device,
image: Path,
mask: Path,
output: Path,
config: Optional[Path] = None,
concat: bool = False,
):
if image.is_dir() and output.is_file():
logger.error(
"invalid --output: when image is a directory, output should be a directory"
)
exit(-1)
output.mkdir(parents=True, exist_ok=True)
image_paths = glob_images(image)
mask_paths = glob_images(mask)
if len(image_paths) == 0:
logger.error("invalid --image: empty image folder")
exit(-1)
if len(mask_paths) == 0:
logger.error("invalid --mask: empty mask folder")
exit(-1)
if config is None:
inpaint_request = InpaintRequest()
logger.info(f"Using default config: {inpaint_request}")
else:
with open(config, "r", encoding="utf-8") as f:
inpaint_request = InpaintRequest(**json.load(f))
logger.info(f"Using config: {inpaint_request}")
model_manager = ModelManager(name=model, device=device)
first_mask = list(mask_paths.values())[0]
console = Console()
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
MofNCompleteColumn(),
TimeElapsedColumn(),
console=console,
transient=False,
) as progress:
task = progress.add_task("Batch processing...", total=len(image_paths))
for stem, image_p in image_paths.items():
if stem not in mask_paths and mask.is_dir():
progress.log(f"mask for {image_p} not found")
progress.update(task, advance=1)
continue
mask_p = mask_paths.get(stem, first_mask)
infos = Image.open(image_p).info
img = np.array(Image.open(image_p).convert("RGB"))
mask_img = np.array(Image.open(mask_p).convert("L"))
if mask_img.shape[:2] != img.shape[:2]:
progress.log(
f"resize mask {mask_p.name} to image {image_p.name} size: {img.shape[:2]}"
)
mask_img = cv2.resize(
mask_img,
(img.shape[1], img.shape[0]),
interpolation=cv2.INTER_NEAREST,
)
mask_img[mask_img >= 127] = 255
mask_img[mask_img < 127] = 0
# bgr
inpaint_result = model_manager(img, mask_img, inpaint_request)
inpaint_result = cv2.cvtColor(inpaint_result, cv2.COLOR_BGR2RGB)
if concat:
mask_img = cv2.cvtColor(mask_img, cv2.COLOR_GRAY2RGB)
inpaint_result = cv2.hconcat([img, mask_img, inpaint_result])
img_bytes = pil_to_bytes(Image.fromarray(inpaint_result), "png", 100, infos)
save_p = output / f"{stem}.png"
with open(save_p, "wb") as fw:
fw.write(img_bytes)
progress.update(task, advance=1)
torch_gc()
# pid = psutil.Process().pid
# memory_info = psutil.Process(pid).memory_info()
# memory_in_mb = memory_info.rss / (1024 * 1024)
# print(f"原图大小:{img.shape},当前进程的内存占用:{memory_in_mb}MB")

View File

@ -0,0 +1,109 @@
#!/usr/bin/env python3
import argparse
import os
import time
import numpy as np
import nvidia_smi
import psutil
import torch
from iopaint.model_manager import ModelManager
from iopaint.schema import InpaintRequest, HDStrategy, SDSampler
try:
torch._C._jit_override_can_fuse_on_cpu(False)
torch._C._jit_override_can_fuse_on_gpu(False)
torch._C._jit_set_texpr_fuser_enabled(False)
torch._C._jit_set_nvfuser_enabled(False)
except:
pass
NUM_THREADS = str(4)
os.environ["OMP_NUM_THREADS"] = NUM_THREADS
os.environ["OPENBLAS_NUM_THREADS"] = NUM_THREADS
os.environ["MKL_NUM_THREADS"] = NUM_THREADS
os.environ["VECLIB_MAXIMUM_THREADS"] = NUM_THREADS
os.environ["NUMEXPR_NUM_THREADS"] = NUM_THREADS
if os.environ.get("CACHE_DIR"):
os.environ["TORCH_HOME"] = os.environ["CACHE_DIR"]
def run_model(model, size):
# RGB
image = np.random.randint(0, 256, (size[0], size[1], 3)).astype(np.uint8)
mask = np.random.randint(0, 255, size).astype(np.uint8)
config = InpaintRequest(
ldm_steps=2,
hd_strategy=HDStrategy.ORIGINAL,
hd_strategy_crop_margin=128,
hd_strategy_crop_trigger_size=128,
hd_strategy_resize_limit=128,
prompt="a fox is sitting on a bench",
sd_steps=5,
sd_sampler=SDSampler.ddim,
)
model(image, mask, config)
def benchmark(model, times: int, empty_cache: bool):
sizes = [(512, 512)]
nvidia_smi.nvmlInit()
device_id = 0
handle = nvidia_smi.nvmlDeviceGetHandleByIndex(device_id)
def format(metrics):
return f"{np.mean(metrics):.2f} ± {np.std(metrics):.2f}"
process = psutil.Process(os.getpid())
# 每个 size 给出显存和内存占用的指标
for size in sizes:
torch.cuda.empty_cache()
time_metrics = []
cpu_metrics = []
memory_metrics = []
gpu_memory_metrics = []
for _ in range(times):
start = time.time()
run_model(model, size)
torch.cuda.synchronize()
# cpu_metrics.append(process.cpu_percent())
time_metrics.append((time.time() - start) * 1000)
memory_metrics.append(process.memory_info().rss / 1024 / 1024)
gpu_memory_metrics.append(
nvidia_smi.nvmlDeviceGetMemoryInfo(handle).used / 1024 / 1024
)
print(f"size: {size}".center(80, "-"))
# print(f"cpu: {format(cpu_metrics)}")
print(f"latency: {format(time_metrics)}ms")
print(f"memory: {format(memory_metrics)} MB")
print(f"gpu memory: {format(gpu_memory_metrics)} MB")
nvidia_smi.nvmlShutdown()
def get_args_parser():
parser = argparse.ArgumentParser()
parser.add_argument("--name")
parser.add_argument("--device", default="cuda", type=str)
parser.add_argument("--times", default=10, type=int)
parser.add_argument("--empty-cache", action="store_true")
return parser.parse_args()
if __name__ == "__main__":
args = get_args_parser()
device = torch.device(args.device)
model = ModelManager(
name=args.name,
device=device,
disable_nsfw=True,
sd_cpu_textencoder=True,
)
benchmark(model, args.times, args.empty_cache)

View File

@ -0,0 +1,240 @@
import webbrowser
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
import typer
from fastapi import FastAPI
from loguru import logger
from typer import Option
from typer_config import use_json_config
from iopaint.const import *
from iopaint.runtime import setup_model_dir, dump_environment_info, check_device
from iopaint.schema import InteractiveSegModel, Device, RealESRGANModel, RemoveBGModel
typer_app = typer.Typer(pretty_exceptions_show_locals=False, add_completion=False)
@typer_app.command(help="Install all plugins dependencies")
def install_plugins_packages():
from iopaint.installer import install_plugins_package
install_plugins_package()
@typer_app.command(help="Download SD/SDXL normal/inpainting model from HuggingFace")
def download(
model: str = Option(
..., help="Model id on HuggingFace e.g: runwayml/stable-diffusion-inpainting"
),
model_dir: Path = Option(
DEFAULT_MODEL_DIR,
help=MODEL_DIR_HELP,
file_okay=False,
callback=setup_model_dir,
),
):
from iopaint.download import cli_download_model
cli_download_model(model)
@typer_app.command(name="list", help="List downloaded models")
def list_model(
model_dir: Path = Option(
DEFAULT_MODEL_DIR,
help=MODEL_DIR_HELP,
file_okay=False,
callback=setup_model_dir,
),
):
from iopaint.download import scan_models
scanned_models = scan_models()
for it in scanned_models:
print(it.name)
@typer_app.command(help="Batch processing images")
def run(
model: str = Option("lama"),
device: Device = Option(Device.cpu),
image: Path = Option(..., help="Image folders or file path"),
mask: Path = Option(
...,
help="Mask folders or file path. "
"If it is a directory, the mask images in the directory should have the same name as the original image."
"If it is a file, all images will use this mask."
"Mask will automatically resize to the same size as the original image.",
),
output: Path = Option(..., help="Output directory or file path"),
config: Path = Option(
None, help="Config file path. You can use dump command to create a base config."
),
concat: bool = Option(
False, help="Concat original image, mask and output images into one image"
),
model_dir: Path = Option(
DEFAULT_MODEL_DIR,
help=MODEL_DIR_HELP,
file_okay=False,
callback=setup_model_dir,
),
):
from iopaint.download import cli_download_model, scan_models
scanned_models = scan_models()
if model not in [it.name for it in scanned_models]:
logger.info(f"{model} not found in {model_dir}, try to downloading")
cli_download_model(model)
from iopaint.batch_processing import batch_inpaint
batch_inpaint(model, device, image, mask, output, config, concat)
@typer_app.command(help="Start IOPaint server")
@use_json_config()
def start(
host: str = Option("127.0.0.1"),
port: int = Option(8080),
inbrowser: bool = Option(False, help=INBROWSER_HELP),
model: str = Option(
DEFAULT_MODEL,
help=f"Erase models: [{', '.join(AVAILABLE_MODELS)}].\n"
f"Diffusion models: [{', '.join(DIFFUSION_MODELS)}] or any SD/SDXL normal/inpainting models on HuggingFace.",
),
model_dir: Path = Option(
DEFAULT_MODEL_DIR,
help=MODEL_DIR_HELP,
dir_okay=True,
file_okay=False,
callback=setup_model_dir,
),
low_mem: bool = Option(False, help=LOW_MEM_HELP),
no_half: bool = Option(False, help=NO_HALF_HELP),
cpu_offload: bool = Option(False, help=CPU_OFFLOAD_HELP),
disable_nsfw_checker: bool = Option(False, help=DISABLE_NSFW_HELP),
cpu_textencoder: bool = Option(False, help=CPU_TEXTENCODER_HELP),
local_files_only: bool = Option(False, help=LOCAL_FILES_ONLY_HELP),
device: Device = Option(Device.cpu),
input: Optional[Path] = Option(None, help=INPUT_HELP),
mask_dir: Optional[Path] = Option(
None, help=MODEL_DIR_HELP, dir_okay=True, file_okay=False
),
output_dir: Optional[Path] = Option(
None, help=OUTPUT_DIR_HELP, dir_okay=True, file_okay=False
),
quality: int = Option(100, help=QUALITY_HELP),
enable_interactive_seg: bool = Option(False, help=INTERACTIVE_SEG_HELP),
interactive_seg_model: InteractiveSegModel = Option(
InteractiveSegModel.sam2_1_tiny, help=INTERACTIVE_SEG_MODEL_HELP
),
interactive_seg_device: Device = Option(Device.cpu),
enable_remove_bg: bool = Option(False, help=REMOVE_BG_HELP),
remove_bg_device: Device = Option(Device.cpu, help=REMOVE_BG_DEVICE_HELP),
remove_bg_model: RemoveBGModel = Option(RemoveBGModel.briaai_rmbg_1_4),
enable_anime_seg: bool = Option(False, help=ANIMESEG_HELP),
enable_realesrgan: bool = Option(False),
realesrgan_device: Device = Option(Device.cpu),
realesrgan_model: RealESRGANModel = Option(RealESRGANModel.realesr_general_x4v3),
enable_gfpgan: bool = Option(False),
gfpgan_device: Device = Option(Device.cpu),
enable_restoreformer: bool = Option(False),
restoreformer_device: Device = Option(Device.cpu),
):
dump_environment_info()
device = check_device(device)
remove_bg_device = check_device(remove_bg_device)
realesrgan_device = check_device(realesrgan_device)
gfpgan_device = check_device(gfpgan_device)
if input and not input.exists():
logger.error(f"invalid --input: {input} not exists")
exit(-1)
if mask_dir and not mask_dir.exists():
logger.error(f"invalid --mask-dir: {mask_dir} not exists")
exit(-1)
if input and input.is_dir() and not output_dir:
logger.error(
"invalid --output-dir: --output-dir must be set when --input is a directory"
)
exit(-1)
if output_dir:
output_dir = output_dir.expanduser().absolute()
logger.info(f"Image will be saved to {output_dir}")
if not output_dir.exists():
logger.info(f"Create output directory {output_dir}")
output_dir.mkdir(parents=True)
if mask_dir:
mask_dir = mask_dir.expanduser().absolute()
model_dir = model_dir.expanduser().absolute()
if local_files_only:
os.environ["TRANSFORMERS_OFFLINE"] = "1"
os.environ["HF_HUB_OFFLINE"] = "1"
from iopaint.download import cli_download_model, scan_models
scanned_models = scan_models()
if model not in [it.name for it in scanned_models]:
logger.info(f"{model} not found in {model_dir}, try to downloading")
cli_download_model(model)
from iopaint.api import Api
from iopaint.schema import ApiConfig
@asynccontextmanager
async def lifespan(app: FastAPI):
if inbrowser:
webbrowser.open(f"http://localhost:{port}", new=0, autoraise=True)
yield
app = FastAPI(lifespan=lifespan)
api_config = ApiConfig(
host=host,
port=port,
inbrowser=inbrowser,
model=model,
no_half=no_half,
low_mem=low_mem,
cpu_offload=cpu_offload,
disable_nsfw_checker=disable_nsfw_checker,
local_files_only=local_files_only,
cpu_textencoder=cpu_textencoder if device == Device.cuda else False,
device=device,
input=input,
mask_dir=mask_dir,
output_dir=output_dir,
quality=quality,
enable_interactive_seg=enable_interactive_seg,
interactive_seg_model=interactive_seg_model,
interactive_seg_device=interactive_seg_device,
enable_remove_bg=enable_remove_bg,
remove_bg_device=remove_bg_device,
remove_bg_model=remove_bg_model,
enable_anime_seg=enable_anime_seg,
enable_realesrgan=enable_realesrgan,
realesrgan_device=realesrgan_device,
realesrgan_model=realesrgan_model,
enable_gfpgan=enable_gfpgan,
gfpgan_device=gfpgan_device,
enable_restoreformer=enable_restoreformer,
restoreformer_device=restoreformer_device,
)
print(api_config.model_dump_json(indent=4))
api = Api(app, api_config)
api.launch()
@typer_app.command(help="Start IOPaint web config page")
def start_web_config(
config_file: Path = Option("config.json"),
):
dump_environment_info()
from iopaint.web_config import main
main(config_file)

View File

@ -0,0 +1,136 @@
import os
from typing import List
INSTRUCT_PIX2PIX_NAME = "timbrooks/instruct-pix2pix"
KANDINSKY22_NAME = "kandinsky-community/kandinsky-2-2-decoder-inpaint"
POWERPAINT_NAME = "Sanster/PowerPaint-V1-stable-diffusion-inpainting"
ANYTEXT_NAME = "Sanster/AnyText"
DIFFUSERS_SD_CLASS_NAME = "StableDiffusionPipeline"
DIFFUSERS_SD_INPAINT_CLASS_NAME = "StableDiffusionInpaintPipeline"
DIFFUSERS_SDXL_CLASS_NAME = "StableDiffusionXLPipeline"
DIFFUSERS_SDXL_INPAINT_CLASS_NAME = "StableDiffusionXLInpaintPipeline"
MPS_UNSUPPORT_MODELS = [
"lama",
"ldm",
"zits",
"mat",
"fcf",
"cv2",
"manga",
]
DEFAULT_MODEL = "lama"
AVAILABLE_MODELS = ["lama", "ldm", "zits", "mat", "fcf", "manga", "cv2", "migan"]
DIFFUSION_MODELS = [
"runwayml/stable-diffusion-inpainting",
"Uminosachi/realisticVisionV51_v51VAE-inpainting",
"redstonehero/dreamshaper-inpainting",
"Sanster/anything-4.0-inpainting",
"diffusers/stable-diffusion-xl-1.0-inpainting-0.1",
"Fantasy-Studio/Paint-by-Example",
"RunDiffusion/Juggernaut-XI-v11",
"SG161222/RealVisXL_V5.0",
"eienmojiki/Anything-XL",
POWERPAINT_NAME,
ANYTEXT_NAME,
]
NO_HALF_HELP = """
Using full precision(fp32) model.
If your diffusion model generate result is always black or green, use this argument.
"""
CPU_OFFLOAD_HELP = """
Offloads diffusion model's weight to CPU RAM, significantly reducing vRAM usage.
"""
LOW_MEM_HELP = "Enable attention slicing and vae tiling to save memory."
DISABLE_NSFW_HELP = """
Disable NSFW checker for diffusion model.
"""
CPU_TEXTENCODER_HELP = """
Run diffusion models text encoder on CPU to reduce vRAM usage.
"""
SD_CONTROLNET_CHOICES: List[str] = [
"lllyasviel/control_v11p_sd15_canny",
# "lllyasviel/control_v11p_sd15_seg",
"lllyasviel/control_v11p_sd15_openpose",
"lllyasviel/control_v11p_sd15_inpaint",
"lllyasviel/control_v11f1p_sd15_depth",
]
SD_BRUSHNET_CHOICES: List[str] = [
"Sanster/brushnet_random_mask",
"Sanster/brushnet_segmentation_mask",
]
SD2_CONTROLNET_CHOICES = [
"thibaud/controlnet-sd21-canny-diffusers",
"thibaud/controlnet-sd21-depth-diffusers",
"thibaud/controlnet-sd21-openpose-diffusers",
]
SDXL_CONTROLNET_CHOICES = [
"thibaud/controlnet-openpose-sdxl-1.0",
"destitech/controlnet-inpaint-dreamer-sdxl",
"diffusers/controlnet-canny-sdxl-1.0",
"diffusers/controlnet-canny-sdxl-1.0-mid",
"diffusers/controlnet-canny-sdxl-1.0-small",
"diffusers/controlnet-depth-sdxl-1.0",
"diffusers/controlnet-depth-sdxl-1.0-mid",
"diffusers/controlnet-depth-sdxl-1.0-small",
]
SDXL_BRUSHNET_CHOICES = [
"Regulus0725/random_mask_brushnet_ckpt_sdxl_regulus_v1"
]
LOCAL_FILES_ONLY_HELP = """
When loading diffusion models, using local files only, not connect to HuggingFace server.
"""
DEFAULT_MODEL_DIR = os.path.abspath(
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
)
MODEL_DIR_HELP = f"""
Model download directory (by setting XDG_CACHE_HOME environment variable), by default model download to {DEFAULT_MODEL_DIR}
"""
OUTPUT_DIR_HELP = """
Result images will be saved to output directory automatically.
"""
MASK_DIR_HELP = """
You can view masks in FileManager
"""
INPUT_HELP = """
If input is image, it will be loaded by default.
If input is directory, you can browse and select image in file manager.
"""
GUI_HELP = """
Launch Lama Cleaner as desktop app
"""
QUALITY_HELP = """
Quality of image encoding, 0-100. Default is 95, higher quality will generate larger file size.
"""
INTERACTIVE_SEG_HELP = "Enable interactive segmentation using Segment Anything."
INTERACTIVE_SEG_MODEL_HELP = "Model size: mobile_sam < vit_b < vit_l < vit_h. Bigger model size means better segmentation but slower speed."
REMOVE_BG_HELP = "Enable remove background plugin."
REMOVE_BG_DEVICE_HELP = "Device for remove background plugin. 'cuda' only supports briaai models(briaai/RMBG-1.4 and briaai/RMBG-2.0)"
ANIMESEG_HELP = "Enable anime segmentation plugin. Always run on CPU"
REALESRGAN_HELP = "Enable realesrgan super resolution"
GFPGAN_HELP = "Enable GFPGAN face restore. To also enhance background, use with --enable-realesrgan"
RESTOREFORMER_HELP = "Enable RestoreFormer face restore. To also enhance background, use with --enable-realesrgan"
GIF_HELP = "Enable GIF plugin. Make GIF to compare original and cleaned image"
INBROWSER_HELP = "Automatically launch IOPaint in a new tab on the default browser"

View File

@ -0,0 +1,314 @@
import glob
import json
import os
from functools import lru_cache
from typing import List, Optional
from iopaint.schema import ModelType, ModelInfo
from loguru import logger
from pathlib import Path
from iopaint.const import (
DEFAULT_MODEL_DIR,
DIFFUSERS_SD_CLASS_NAME,
DIFFUSERS_SD_INPAINT_CLASS_NAME,
DIFFUSERS_SDXL_CLASS_NAME,
DIFFUSERS_SDXL_INPAINT_CLASS_NAME,
ANYTEXT_NAME,
)
from iopaint.model.original_sd_configs import get_config_files
def cli_download_model(model: str):
from iopaint.model import models
from iopaint.model.utils import handle_from_pretrained_exceptions
if model in models and models[model].is_erase_model:
logger.info(f"Downloading {model}...")
models[model].download()
logger.info("Done.")
elif model == ANYTEXT_NAME:
logger.info(f"Downloading {model}...")
models[model].download()
logger.info("Done.")
else:
logger.info(f"Downloading model from Huggingface: {model}")
from diffusers import DiffusionPipeline
downloaded_path = handle_from_pretrained_exceptions(
DiffusionPipeline.download, pretrained_model_name=model, variant="fp16"
)
logger.info(f"Done. Downloaded to {downloaded_path}")
def folder_name_to_show_name(name: str) -> str:
return name.replace("models--", "").replace("--", "/")
@lru_cache(maxsize=512)
def get_sd_model_type(model_abs_path: str) -> Optional[ModelType]:
if "inpaint" in Path(model_abs_path).name.lower():
model_type = ModelType.DIFFUSERS_SD_INPAINT
else:
# load once to check num_in_channels
from diffusers import StableDiffusionInpaintPipeline
try:
StableDiffusionInpaintPipeline.from_single_file(
model_abs_path,
load_safety_checker=False,
num_in_channels=9,
original_config_file=get_config_files()["v1"],
)
model_type = ModelType.DIFFUSERS_SD_INPAINT
except ValueError as e:
if "[320, 4, 3, 3]" in str(e):
model_type = ModelType.DIFFUSERS_SD
else:
logger.info(f"Ignore non sdxl file: {model_abs_path}")
return
except Exception as e:
logger.error(f"Failed to load {model_abs_path}: {e}")
return
return model_type
@lru_cache()
def get_sdxl_model_type(model_abs_path: str) -> Optional[ModelType]:
if "inpaint" in model_abs_path:
model_type = ModelType.DIFFUSERS_SDXL_INPAINT
else:
# load once to check num_in_channels
from diffusers import StableDiffusionXLInpaintPipeline
try:
model = StableDiffusionXLInpaintPipeline.from_single_file(
model_abs_path,
load_safety_checker=False,
num_in_channels=9,
original_config_file=get_config_files()["xl"],
)
if model.unet.config.in_channels == 9:
# https://github.com/huggingface/diffusers/issues/6610
model_type = ModelType.DIFFUSERS_SDXL_INPAINT
else:
model_type = ModelType.DIFFUSERS_SDXL
except ValueError as e:
if "[320, 4, 3, 3]" in str(e):
model_type = ModelType.DIFFUSERS_SDXL
else:
logger.info(f"Ignore non sdxl file: {model_abs_path}")
return
except Exception as e:
logger.error(f"Failed to load {model_abs_path}: {e}")
return
return model_type
def scan_single_file_diffusion_models(cache_dir) -> List[ModelInfo]:
cache_dir = Path(cache_dir)
stable_diffusion_dir = cache_dir / "stable_diffusion"
cache_file = stable_diffusion_dir / "iopaint_cache.json"
model_type_cache = {}
if cache_file.exists():
try:
with open(cache_file, "r", encoding="utf-8") as f:
model_type_cache = json.load(f)
assert isinstance(model_type_cache, dict)
except:
pass
res = []
for it in stable_diffusion_dir.glob("*.*"):
if it.suffix not in [".safetensors", ".ckpt"]:
continue
model_abs_path = str(it.absolute())
model_type = model_type_cache.get(it.name)
if model_type is None:
model_type = get_sd_model_type(model_abs_path)
if model_type is None:
continue
model_type_cache[it.name] = model_type
res.append(
ModelInfo(
name=it.name,
path=model_abs_path,
model_type=model_type,
is_single_file_diffusers=True,
)
)
if stable_diffusion_dir.exists():
with open(cache_file, "w", encoding="utf-8") as fw:
json.dump(model_type_cache, fw, indent=2, ensure_ascii=False)
stable_diffusion_xl_dir = cache_dir / "stable_diffusion_xl"
sdxl_cache_file = stable_diffusion_xl_dir / "iopaint_cache.json"
sdxl_model_type_cache = {}
if sdxl_cache_file.exists():
try:
with open(sdxl_cache_file, "r", encoding="utf-8") as f:
sdxl_model_type_cache = json.load(f)
assert isinstance(sdxl_model_type_cache, dict)
except:
pass
for it in stable_diffusion_xl_dir.glob("*.*"):
if it.suffix not in [".safetensors", ".ckpt"]:
continue
model_abs_path = str(it.absolute())
model_type = sdxl_model_type_cache.get(it.name)
if model_type is None:
model_type = get_sdxl_model_type(model_abs_path)
if model_type is None:
continue
sdxl_model_type_cache[it.name] = model_type
if stable_diffusion_xl_dir.exists():
with open(sdxl_cache_file, "w", encoding="utf-8") as fw:
json.dump(sdxl_model_type_cache, fw, indent=2, ensure_ascii=False)
res.append(
ModelInfo(
name=it.name,
path=model_abs_path,
model_type=model_type,
is_single_file_diffusers=True,
)
)
return res
def scan_inpaint_models(model_dir: Path) -> List[ModelInfo]:
res = []
from iopaint.model import models
# logger.info(f"Scanning inpaint models in {model_dir}")
for name, m in models.items():
if m.is_erase_model and m.is_downloaded():
res.append(
ModelInfo(
name=name,
path=name,
model_type=ModelType.INPAINT,
)
)
return res
def scan_diffusers_models() -> List[ModelInfo]:
from huggingface_hub.constants import HF_HUB_CACHE
available_models = []
cache_dir = Path(HF_HUB_CACHE)
# logger.info(f"Scanning diffusers models in {cache_dir}")
diffusers_model_names = []
model_index_files = glob.glob(
os.path.join(cache_dir, "**/*", "model_index.json"), recursive=True
)
for it in model_index_files:
it = Path(it)
try:
with open(it, "r", encoding="utf-8") as f:
data = json.load(f)
except:
continue
_class_name = data["_class_name"]
name = folder_name_to_show_name(it.parent.parent.parent.name)
if name in diffusers_model_names:
continue
if "PowerPaint" in name:
model_type = ModelType.DIFFUSERS_OTHER
elif _class_name == DIFFUSERS_SD_CLASS_NAME:
model_type = ModelType.DIFFUSERS_SD
elif _class_name == DIFFUSERS_SD_INPAINT_CLASS_NAME:
model_type = ModelType.DIFFUSERS_SD_INPAINT
elif _class_name == DIFFUSERS_SDXL_CLASS_NAME:
model_type = ModelType.DIFFUSERS_SDXL
elif _class_name == DIFFUSERS_SDXL_INPAINT_CLASS_NAME:
model_type = ModelType.DIFFUSERS_SDXL_INPAINT
elif _class_name in [
"StableDiffusionInstructPix2PixPipeline",
"PaintByExamplePipeline",
"KandinskyV22InpaintPipeline",
"AnyText",
]:
model_type = ModelType.DIFFUSERS_OTHER
else:
continue
diffusers_model_names.append(name)
available_models.append(
ModelInfo(
name=name,
path=name,
model_type=model_type,
)
)
return available_models
def _scan_converted_diffusers_models(cache_dir) -> List[ModelInfo]:
cache_dir = Path(cache_dir)
available_models = []
diffusers_model_names = []
model_index_files = glob.glob(
os.path.join(cache_dir, "**/*", "model_index.json"), recursive=True
)
for it in model_index_files:
it = Path(it)
with open(it, "r", encoding="utf-8") as f:
try:
data = json.load(f)
except:
logger.error(
f"Failed to load {it}, please try revert from original model or fix model_index.json by hand."
)
continue
_class_name = data["_class_name"]
name = folder_name_to_show_name(it.parent.name)
if name in diffusers_model_names:
continue
elif _class_name == DIFFUSERS_SD_CLASS_NAME:
model_type = ModelType.DIFFUSERS_SD
elif _class_name == DIFFUSERS_SD_INPAINT_CLASS_NAME:
model_type = ModelType.DIFFUSERS_SD_INPAINT
elif _class_name == DIFFUSERS_SDXL_CLASS_NAME:
model_type = ModelType.DIFFUSERS_SDXL
elif _class_name == DIFFUSERS_SDXL_INPAINT_CLASS_NAME:
model_type = ModelType.DIFFUSERS_SDXL_INPAINT
else:
continue
diffusers_model_names.append(name)
available_models.append(
ModelInfo(
name=name,
path=str(it.parent.absolute()),
model_type=model_type,
)
)
return available_models
def scan_converted_diffusers_models(cache_dir) -> List[ModelInfo]:
cache_dir = Path(cache_dir)
available_models = []
stable_diffusion_dir = cache_dir / "stable_diffusion"
stable_diffusion_xl_dir = cache_dir / "stable_diffusion_xl"
available_models.extend(_scan_converted_diffusers_models(stable_diffusion_dir))
available_models.extend(_scan_converted_diffusers_models(stable_diffusion_xl_dir))
return available_models
def scan_models() -> List[ModelInfo]:
model_dir = os.getenv("XDG_CACHE_HOME", DEFAULT_MODEL_DIR)
available_models = []
available_models.extend(scan_inpaint_models(model_dir))
available_models.extend(scan_single_file_diffusion_models(model_dir))
available_models.extend(scan_diffusers_models())
available_models.extend(scan_converted_diffusers_models(model_dir))
return available_models

View File

@ -0,0 +1 @@
from .file_manager import FileManager

View File

@ -0,0 +1,220 @@
import os
from io import BytesIO
from pathlib import Path
from typing import List
from PIL import Image, ImageOps, PngImagePlugin
from fastapi import FastAPI, HTTPException
from starlette.responses import FileResponse
from ..schema import MediasResponse, MediaTab
LARGE_ENOUGH_NUMBER = 100
PngImagePlugin.MAX_TEXT_CHUNK = LARGE_ENOUGH_NUMBER * (1024**2)
from .storage_backends import FilesystemStorageBackend
from .utils import aspect_to_string, generate_filename, glob_img
class FileManager:
def __init__(self, app: FastAPI, input_dir: Path, mask_dir: Path, output_dir: Path):
self.app = app
self.input_dir: Path = input_dir
self.mask_dir: Path = mask_dir
self.output_dir: Path = output_dir
self.image_dir_filenames = []
self.output_dir_filenames = []
if not self.thumbnail_directory.exists():
self.thumbnail_directory.mkdir(parents=True)
# fmt: off
self.app.add_api_route("/api/v1/medias", self.api_medias, methods=["GET"], response_model=List[MediasResponse])
self.app.add_api_route("/api/v1/media_file", self.api_media_file, methods=["GET"])
self.app.add_api_route("/api/v1/media_thumbnail_file", self.api_media_thumbnail_file, methods=["GET"])
# fmt: on
def api_medias(self, tab: MediaTab) -> List[MediasResponse]:
img_dir = self._get_dir(tab)
return self._media_names(img_dir)
def api_media_file(self, tab: MediaTab, filename: str) -> FileResponse:
file_path = self._get_file(tab, filename)
return FileResponse(file_path, media_type="image/png")
# tab=${tab}?filename=${filename.name}?width=${width}&height=${height}
def api_media_thumbnail_file(
self, tab: MediaTab, filename: str, width: int, height: int
) -> FileResponse:
img_dir = self._get_dir(tab)
thumb_filename, (width, height) = self.get_thumbnail(
img_dir, filename, width=width, height=height
)
thumbnail_filepath = self.thumbnail_directory / thumb_filename
return FileResponse(
thumbnail_filepath,
headers={
"X-Width": str(width),
"X-Height": str(height),
},
media_type="image/jpeg",
)
def _get_dir(self, tab: MediaTab) -> Path:
if tab == "input":
return self.input_dir
elif tab == "output":
return self.output_dir
elif tab == "mask":
return self.mask_dir
else:
raise HTTPException(status_code=422, detail=f"tab not found: {tab}")
def _get_file(self, tab: MediaTab, filename: str) -> Path:
file_path = self._get_dir(tab) / filename
if not file_path.exists():
raise HTTPException(status_code=422, detail=f"file not found: {file_path}")
return file_path
@property
def thumbnail_directory(self) -> Path:
return self.output_dir / "thumbnails"
@staticmethod
def _media_names(directory: Path) -> List[MediasResponse]:
if directory is None:
return []
names = sorted([it.name for it in glob_img(directory)])
res = []
for name in names:
path = os.path.join(directory, name)
img = Image.open(path)
res.append(
MediasResponse(
name=name,
height=img.height,
width=img.width,
ctime=os.path.getctime(path),
mtime=os.path.getmtime(path),
)
)
return res
def get_thumbnail(
self, directory: Path, original_filename: str, width, height, **options
):
directory = Path(directory)
storage = FilesystemStorageBackend(self.app)
crop = options.get("crop", "fit")
background = options.get("background")
quality = options.get("quality", 90)
original_path, original_filename = os.path.split(original_filename)
original_filepath = os.path.join(directory, original_path, original_filename)
image = Image.open(BytesIO(storage.read(original_filepath)))
# keep ratio resize
if not width and not height:
width = 256
if width != 0:
height = int(image.height * width / image.width)
else:
width = int(image.width * height / image.height)
thumbnail_size = (width, height)
thumbnail_filename = generate_filename(
directory,
original_filename,
aspect_to_string(thumbnail_size),
crop,
background,
quality,
)
thumbnail_filepath = os.path.join(
self.thumbnail_directory, original_path, thumbnail_filename
)
if storage.exists(thumbnail_filepath):
return thumbnail_filepath, (width, height)
try:
image.load()
except (IOError, OSError):
self.app.logger.warning("Thumbnail not load image: %s", original_filepath)
return thumbnail_filepath, (width, height)
# get original image format
options["format"] = options.get("format", image.format)
image = self._create_thumbnail(
image, thumbnail_size, crop, background=background
)
raw_data = self.get_raw_data(image, **options)
storage.save(thumbnail_filepath, raw_data)
return thumbnail_filepath, (width, height)
def get_raw_data(self, image, **options):
data = {
"format": self._get_format(image, **options),
"quality": options.get("quality", 90),
}
_file = BytesIO()
image.save(_file, **data)
return _file.getvalue()
@staticmethod
def colormode(image, colormode="RGB"):
if colormode == "RGB" or colormode == "RGBA":
if image.mode == "RGBA":
return image
if image.mode == "LA":
return image.convert("RGBA")
return image.convert(colormode)
if colormode == "GRAY":
return image.convert("L")
return image.convert(colormode)
@staticmethod
def background(original_image, color=0xFF):
size = (max(original_image.size),) * 2
image = Image.new("L", size, color)
image.paste(
original_image,
tuple(map(lambda x: (x[0] - x[1]) / 2, zip(size, original_image.size))),
)
return image
def _get_format(self, image, **options):
if options.get("format"):
return options.get("format")
if image.format:
return image.format
return "JPEG"
def _create_thumbnail(self, image, size, crop="fit", background=None):
try:
resample = Image.Resampling.LANCZOS
except AttributeError: # pylint: disable=raise-missing-from
resample = Image.ANTIALIAS
if crop == "fit":
image = ImageOps.fit(image, size, resample)
else:
image = image.copy()
image.thumbnail(size, resample=resample)
if background is not None:
image = self.background(image)
image = self.colormode(image)
return image

View File

@ -0,0 +1,46 @@
# Copy from https://github.com/silentsokolov/flask-thumbnails/blob/master/flask_thumbnails/storage_backends.py
import errno
import os
from abc import ABC, abstractmethod
class BaseStorageBackend(ABC):
def __init__(self, app=None):
self.app = app
@abstractmethod
def read(self, filepath, mode="rb", **kwargs):
raise NotImplementedError
@abstractmethod
def exists(self, filepath):
raise NotImplementedError
@abstractmethod
def save(self, filepath, data):
raise NotImplementedError
class FilesystemStorageBackend(BaseStorageBackend):
def read(self, filepath, mode="rb", **kwargs):
with open(filepath, mode) as f: # pylint: disable=unspecified-encoding
return f.read()
def exists(self, filepath):
return os.path.exists(filepath)
def save(self, filepath, data):
directory = os.path.dirname(filepath)
if not os.path.exists(directory):
try:
os.makedirs(directory)
except OSError as e:
if e.errno != errno.EEXIST:
raise
if not os.path.isdir(directory):
raise IOError("{} is not a directory".format(directory))
with open(filepath, "wb") as f:
f.write(data)

View File

@ -0,0 +1,65 @@
# Copy from: https://github.com/silentsokolov/flask-thumbnails/blob/master/flask_thumbnails/utils.py
import hashlib
from pathlib import Path
from typing import Union
def generate_filename(directory: Path, original_filename, *options) -> str:
text = str(directory.absolute()) + original_filename
for v in options:
text += "%s" % v
md5_hash = hashlib.md5()
md5_hash.update(text.encode("utf-8"))
return md5_hash.hexdigest() + ".jpg"
def parse_size(size):
if isinstance(size, int):
# If the size parameter is a single number, assume square aspect.
return [size, size]
if isinstance(size, (tuple, list)):
if len(size) == 1:
# If single value tuple/list is provided, exand it to two elements
return size + type(size)(size)
return size
try:
thumbnail_size = [int(x) for x in size.lower().split("x", 1)]
except ValueError:
raise ValueError( # pylint: disable=raise-missing-from
"Bad thumbnail size format. Valid format is INTxINT."
)
if len(thumbnail_size) == 1:
# If the size parameter only contains a single integer, assume square aspect.
thumbnail_size.append(thumbnail_size[0])
return thumbnail_size
def aspect_to_string(size):
if isinstance(size, str):
return size
return "x".join(map(str, size))
IMG_SUFFIX = {".jpg", ".jpeg", ".png", ".JPG", ".JPEG", ".PNG"}
def glob_img(p: Union[Path, str], recursive: bool = False):
p = Path(p)
if p.is_file() and p.suffix in IMG_SUFFIX:
yield p
else:
if recursive:
files = Path(p).glob("**/*.*")
else:
files = Path(p).glob("*.*")
for it in files:
if it.suffix not in IMG_SUFFIX:
continue
yield it

View File

@ -0,0 +1,410 @@
import base64
import imghdr
import io
import os
import sys
from typing import List, Optional, Dict, Tuple
from urllib.parse import urlparse
import cv2
from PIL import Image, ImageOps, PngImagePlugin
import numpy as np
import torch
from iopaint.const import MPS_UNSUPPORT_MODELS
from loguru import logger
from torch.hub import download_url_to_file, get_dir
import hashlib
def md5sum(filename):
md5 = hashlib.md5()
with open(filename, "rb") as f:
for chunk in iter(lambda: f.read(128 * md5.block_size), b""):
md5.update(chunk)
return md5.hexdigest()
def switch_mps_device(model_name, device):
if model_name in MPS_UNSUPPORT_MODELS and str(device) == "mps":
logger.info(f"{model_name} not support mps, switch to cpu")
return torch.device("cpu")
return device
def get_cache_path_by_url(url):
parts = urlparse(url)
hub_dir = get_dir()
model_dir = os.path.join(hub_dir, "checkpoints")
if not os.path.isdir(model_dir):
os.makedirs(model_dir)
filename = os.path.basename(parts.path)
cached_file = os.path.join(model_dir, filename)
return cached_file
def download_model(url, model_md5: str = None):
if os.path.exists(url):
cached_file = url
else:
cached_file = get_cache_path_by_url(url)
if not os.path.exists(cached_file):
sys.stderr.write('Downloading: "{}" to {}\n'.format(url, cached_file))
hash_prefix = None
download_url_to_file(url, cached_file, hash_prefix, progress=True)
if model_md5:
_md5 = md5sum(cached_file)
if model_md5 == _md5:
logger.info(f"Download model success, md5: {_md5}")
else:
try:
os.remove(cached_file)
logger.error(
f"Model md5: {_md5}, expected md5: {model_md5}, wrong model deleted. Please restart iopaint."
f"If you still have errors, please try download model manually first https://lama-cleaner-docs.vercel.app/install/download_model_manually.\n"
)
except:
logger.error(
f"Model md5: {_md5}, expected md5: {model_md5}, please delete {cached_file} and restart iopaint."
)
exit(-1)
return cached_file
def ceil_modulo(x, mod):
if x % mod == 0:
return x
return (x // mod + 1) * mod
def handle_error(model_path, model_md5, e):
_md5 = md5sum(model_path)
if _md5 != model_md5:
try:
os.remove(model_path)
logger.error(
f"Model md5: {_md5}, expected md5: {model_md5}, wrong model deleted. Please restart iopaint."
f"If you still have errors, please try download model manually first https://lama-cleaner-docs.vercel.app/install/download_model_manually.\n"
)
except:
logger.error(
f"Model md5: {_md5}, expected md5: {model_md5}, please delete {model_path} and restart iopaint."
)
else:
logger.error(
f"Failed to load model {model_path},"
f"please submit an issue at https://github.com/Sanster/lama-cleaner/issues and include a screenshot of the error:\n{e}"
)
exit(-1)
def load_jit_model(url_or_path, device, model_md5: str):
if os.path.exists(url_or_path):
model_path = url_or_path
else:
model_path = download_model(url_or_path, model_md5)
logger.info(f"Loading model from: {model_path}")
try:
model = torch.jit.load(model_path, map_location="cpu").to(device)
except Exception as e:
handle_error(model_path, model_md5, e)
model.eval()
return model
def load_model(model: torch.nn.Module, url_or_path, device, model_md5):
if os.path.exists(url_or_path):
model_path = url_or_path
else:
model_path = download_model(url_or_path, model_md5)
try:
logger.info(f"Loading model from: {model_path}")
state_dict = torch.load(model_path, map_location="cpu")
model.load_state_dict(state_dict, strict=True)
model.to(device)
except Exception as e:
handle_error(model_path, model_md5, e)
model.eval()
return model
def numpy_to_bytes(image_numpy: np.ndarray, ext: str) -> bytes:
data = cv2.imencode(
f".{ext}",
image_numpy,
[int(cv2.IMWRITE_JPEG_QUALITY), 100, int(cv2.IMWRITE_PNG_COMPRESSION), 0],
)[1]
image_bytes = data.tobytes()
return image_bytes
def pil_to_bytes(pil_img, ext: str, quality: int = 95, infos={}) -> bytes:
with io.BytesIO() as output:
kwargs = {k: v for k, v in infos.items() if v is not None}
if ext == "jpg":
ext = "jpeg"
if "png" == ext.lower() and "parameters" in kwargs:
pnginfo_data = PngImagePlugin.PngInfo()
pnginfo_data.add_text("parameters", kwargs["parameters"])
kwargs["pnginfo"] = pnginfo_data
pil_img.save(output, format=ext, quality=quality, **kwargs)
image_bytes = output.getvalue()
return image_bytes
def load_img(img_bytes, gray: bool = False, return_info: bool = False):
alpha_channel = None
image = Image.open(io.BytesIO(img_bytes))
if return_info:
infos = image.info
try:
image = ImageOps.exif_transpose(image)
except:
pass
if gray:
image = image.convert("L")
np_img = np.array(image)
else:
if image.mode == "RGBA":
np_img = np.array(image)
alpha_channel = np_img[:, :, -1]
np_img = cv2.cvtColor(np_img, cv2.COLOR_RGBA2RGB)
else:
image = image.convert("RGB")
np_img = np.array(image)
if return_info:
return np_img, alpha_channel, infos
return np_img, alpha_channel
def norm_img(np_img):
if len(np_img.shape) == 2:
np_img = np_img[:, :, np.newaxis]
np_img = np.transpose(np_img, (2, 0, 1))
np_img = np_img.astype("float32") / 255
return np_img
def resize_max_size(
np_img, size_limit: int, interpolation=cv2.INTER_CUBIC
) -> np.ndarray:
# Resize image's longer size to size_limit if longer size larger than size_limit
h, w = np_img.shape[:2]
if max(h, w) > size_limit:
ratio = size_limit / max(h, w)
new_w = int(w * ratio + 0.5)
new_h = int(h * ratio + 0.5)
return cv2.resize(np_img, dsize=(new_w, new_h), interpolation=interpolation)
else:
return np_img
def pad_img_to_modulo(
img: np.ndarray, mod: int, square: bool = False, min_size: Optional[int] = None
):
"""
Args:
img: [H, W, C]
mod:
square: 是否为正方形
min_size:
Returns:
"""
if len(img.shape) == 2:
img = img[:, :, np.newaxis]
height, width = img.shape[:2]
out_height = ceil_modulo(height, mod)
out_width = ceil_modulo(width, mod)
if min_size is not None:
assert min_size % mod == 0
out_width = max(min_size, out_width)
out_height = max(min_size, out_height)
if square:
max_size = max(out_height, out_width)
out_height = max_size
out_width = max_size
return np.pad(
img,
((0, out_height - height), (0, out_width - width), (0, 0)),
mode="symmetric",
)
def boxes_from_mask(mask: np.ndarray) -> List[np.ndarray]:
"""
Args:
mask: (h, w, 1) 0~255
Returns:
"""
height, width = mask.shape[:2]
_, thresh = cv2.threshold(mask, 127, 255, 0)
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
boxes = []
for cnt in contours:
x, y, w, h = cv2.boundingRect(cnt)
box = np.array([x, y, x + w, y + h]).astype(int)
box[::2] = np.clip(box[::2], 0, width)
box[1::2] = np.clip(box[1::2], 0, height)
boxes.append(box)
return boxes
def only_keep_largest_contour(mask: np.ndarray) -> List[np.ndarray]:
"""
Args:
mask: (h, w) 0~255
Returns:
"""
_, thresh = cv2.threshold(mask, 127, 255, 0)
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
max_area = 0
max_index = -1
for i, cnt in enumerate(contours):
area = cv2.contourArea(cnt)
if area > max_area:
max_area = area
max_index = i
if max_index != -1:
new_mask = np.zeros_like(mask)
return cv2.drawContours(new_mask, contours, max_index, 255, -1)
else:
return mask
def is_mac():
return sys.platform == "darwin"
def get_image_ext(img_bytes):
w = imghdr.what("", img_bytes)
if w is None:
w = "jpeg"
return w
def decode_base64_to_image(
encoding: str, gray=False
) -> Tuple[np.array, Optional[np.array], Dict, str]:
if encoding.startswith("data:image/") or encoding.startswith(
"data:application/octet-stream;base64,"
):
encoding = encoding.split(";")[1].split(",")[1]
image_bytes = base64.b64decode(encoding)
ext = get_image_ext(image_bytes)
image = Image.open(io.BytesIO(image_bytes))
alpha_channel = None
try:
image = ImageOps.exif_transpose(image)
except:
pass
# exif_transpose will remove exif rotate infowe must call image.info after exif_transpose
infos = image.info
if gray:
image = image.convert("L")
np_img = np.array(image)
else:
if image.mode == "RGBA":
np_img = np.array(image)
alpha_channel = np_img[:, :, -1]
np_img = cv2.cvtColor(np_img, cv2.COLOR_RGBA2RGB)
else:
image = image.convert("RGB")
np_img = np.array(image)
return np_img, alpha_channel, infos, ext
def encode_pil_to_base64(image: Image, quality: int, infos: Dict) -> bytes:
img_bytes = pil_to_bytes(
image,
"png",
quality=quality,
infos=infos,
)
return base64.b64encode(img_bytes)
def concat_alpha_channel(rgb_np_img, alpha_channel) -> np.ndarray:
if alpha_channel is not None:
if alpha_channel.shape[:2] != rgb_np_img.shape[:2]:
alpha_channel = cv2.resize(
alpha_channel, dsize=(rgb_np_img.shape[1], rgb_np_img.shape[0])
)
rgb_np_img = np.concatenate(
(rgb_np_img, alpha_channel[:, :, np.newaxis]), axis=-1
)
return rgb_np_img
def adjust_mask(mask: np.ndarray, kernel_size: int, operate):
# fronted brush color "ffcc00bb"
# kernel_size = kernel_size*2+1
mask[mask >= 127] = 255
mask[mask < 127] = 0
if operate == "reverse":
mask = 255 - mask
else:
kernel = cv2.getStructuringElement(
cv2.MORPH_ELLIPSE, (2 * kernel_size + 1, 2 * kernel_size + 1)
)
if operate == "expand":
mask = cv2.dilate(
mask,
kernel,
iterations=1,
)
else:
mask = cv2.erode(
mask,
kernel,
iterations=1,
)
res_mask = np.zeros((mask.shape[0], mask.shape[1], 4), dtype=np.uint8)
res_mask[mask > 128] = [255, 203, 0, int(255 * 0.73)]
res_mask = cv2.cvtColor(res_mask, cv2.COLOR_BGRA2RGBA)
return res_mask
def gen_frontend_mask(bgr_or_gray_mask):
if len(bgr_or_gray_mask.shape) == 3 and bgr_or_gray_mask.shape[2] != 1:
bgr_or_gray_mask = cv2.cvtColor(bgr_or_gray_mask, cv2.COLOR_BGR2GRAY)
# fronted brush color "ffcc00bb"
# TODO: how to set kernel size?
kernel_size = 9
bgr_or_gray_mask = cv2.dilate(
bgr_or_gray_mask,
np.ones((kernel_size, kernel_size), np.uint8),
iterations=1,
)
res_mask = np.zeros(
(bgr_or_gray_mask.shape[0], bgr_or_gray_mask.shape[1], 4), dtype=np.uint8
)
res_mask[bgr_or_gray_mask > 128] = [255, 203, 0, int(255 * 0.73)]
res_mask = cv2.cvtColor(res_mask, cv2.COLOR_BGRA2RGBA)
return res_mask

View File

@ -0,0 +1,11 @@
import subprocess
import sys
def install(package):
subprocess.check_call([sys.executable, "-m", "pip", "install", package])
def install_plugins_package():
install("onnxruntime<=1.19.2")
install("rembg[cpu]")

View File

@ -0,0 +1,38 @@
from .anytext.anytext_model import AnyText
from .controlnet import ControlNet
from .fcf import FcF
from .instruct_pix2pix import InstructPix2Pix
from .kandinsky import Kandinsky22
from .lama import LaMa, AnimeLaMa
from .ldm import LDM
from .manga import Manga
from .mat import MAT
from .mi_gan import MIGAN
from .opencv2 import OpenCV2
from .paint_by_example import PaintByExample
from .power_paint.power_paint import PowerPaint
from .sd import SD15, SD2, Anything4, RealisticVision14, SD
from .sdxl import SDXL
from .zits import ZITS
models = {
LaMa.name: LaMa,
AnimeLaMa.name: AnimeLaMa,
LDM.name: LDM,
ZITS.name: ZITS,
MAT.name: MAT,
FcF.name: FcF,
OpenCV2.name: OpenCV2,
Manga.name: Manga,
MIGAN.name: MIGAN,
SD15.name: SD15,
Anything4.name: Anything4,
RealisticVision14.name: RealisticVision14,
SD2.name: SD2,
PaintByExample.name: PaintByExample,
InstructPix2Pix.name: InstructPix2Pix,
Kandinsky22.name: Kandinsky22,
SDXL.name: SDXL,
PowerPaint.name: PowerPaint,
AnyText.name: AnyText,
}

View File

@ -0,0 +1,73 @@
import torch
from huggingface_hub import hf_hub_download
from iopaint.const import ANYTEXT_NAME
from iopaint.model.anytext.anytext_pipeline import AnyTextPipeline
from iopaint.model.base import DiffusionInpaintModel
from iopaint.model.utils import get_torch_dtype, is_local_files_only
from iopaint.schema import InpaintRequest
class AnyText(DiffusionInpaintModel):
name = ANYTEXT_NAME
pad_mod = 64
is_erase_model = False
@staticmethod
def download(local_files_only=False):
hf_hub_download(
repo_id=ANYTEXT_NAME,
filename="model_index.json",
local_files_only=local_files_only,
)
ckpt_path = hf_hub_download(
repo_id=ANYTEXT_NAME,
filename="pytorch_model.fp16.safetensors",
local_files_only=local_files_only,
)
font_path = hf_hub_download(
repo_id=ANYTEXT_NAME,
filename="SourceHanSansSC-Medium.otf",
local_files_only=local_files_only,
)
return ckpt_path, font_path
def init_model(self, device, **kwargs):
local_files_only = is_local_files_only(**kwargs)
ckpt_path, font_path = self.download(local_files_only)
use_gpu, torch_dtype = get_torch_dtype(device, kwargs.get("no_half", False))
self.model = AnyTextPipeline(
ckpt_path=ckpt_path,
font_path=font_path,
device=device,
use_fp16=torch_dtype == torch.float16,
)
self.callback = kwargs.pop("callback", None)
def forward(self, image, mask, config: InpaintRequest):
"""Input image and output image have same size
image: [H, W, C] RGB
mask: [H, W, 1] 255 means area to inpainting
return: BGR IMAGE
"""
height, width = image.shape[:2]
mask = mask.astype("float32") / 255.0
masked_image = image * (1 - mask)
# list of rgb ndarray
results, rtn_code, rtn_warning = self.model(
image=image,
masked_image=masked_image,
prompt=config.prompt,
negative_prompt=config.negative_prompt,
num_inference_steps=config.sd_steps,
strength=config.sd_strength,
guidance_scale=config.sd_guidance_scale,
height=height,
width=width,
seed=config.sd_seed,
sort_priority="y",
callback=self.callback
)
inpainted_rgb_image = results[0][..., ::-1]
return inpainted_rgb_image

Some files were not shown because too many files have changed in this diff Show More