first commit
|
|
@ -0,0 +1,11 @@
|
||||||
|
lib/
|
||||||
|
include/
|
||||||
|
lib64/
|
||||||
|
bin/
|
||||||
|
share/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.pyw
|
||||||
|
*.pyz
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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}초")
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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 대시보드를 별도로 실행할 필요가 없습니다.
|
||||||
|
|
@ -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,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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 380 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
# iop_tensorrt.py
|
||||||
|
import torch
|
||||||
|
from torch2trt import torch2trt, TRTModule
|
||||||
|
|
||||||
|
# --- 1. 모델 정의 및 로드 (nn.Module 형태) ---
|
||||||
|
# MI‑GAN 예시: 원본 모델 클래스 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 저장됨")
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
from iopaint import entry_point
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
entry_point()
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
from .file_manager import FileManager
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 info,we 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
|
||||||
|
|
@ -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]")
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||