feat: 새로운 LLM 클라이언트와 외부 인페인팅 요청 기능을 추가하고, 이미지 처리 로직을 개선하며 로거 C 확장을 도입하고 의존성을 업데이트했습니다.
This commit is contained in:
parent
26c8ca5551
commit
0e43590ab5
|
|
@ -0,0 +1,2 @@
|
|||
[2025-12-05 21:42:06,155] [MainThread] [DEBUG] [request_inpaint.py:request_external_inpaint:175] 외부 인페인팅 서버 요청: http://192.168.0.146:8008/api/v1/inpaint, model=migan
|
||||
[2025-12-05 21:42:06,814] [MainThread] [DEBUG] [request_inpaint.py:request_external_inpaint:187] 외부 인페인팅 성공
|
||||
|
|
@ -0,0 +1 @@
|
|||
[2025-12-05 21:41:45,490] [MainThread] [WARNING] [request_inpaint.py:request_external_inpaint:106] 외부 인페인팅 서버(http://192.168.0.146:8008)가 응답하지 않습니다.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
182
loggerModule.py
182
loggerModule.py
|
|
@ -48,6 +48,151 @@ def get_level_name(level):
|
|||
}
|
||||
return level_names.get(level, f"Level {level}")
|
||||
|
||||
|
||||
class WindowsSafeRotatingFileHandler(logging.Handler):
|
||||
"""
|
||||
Windows 호환 로테이팅 파일 핸들러
|
||||
|
||||
기존 RotatingFileHandler의 문제점:
|
||||
- Windows에서 파일이 열려있으면 rename 실패 → 롤링 실패 → 로그 유실
|
||||
|
||||
해결책:
|
||||
- 파일 크기 초과 시 기존 파일을 닫고 새 파일명으로 직접 생성
|
||||
- 타임스탬프 기반 파일명으로 충돌 방지
|
||||
"""
|
||||
|
||||
def __init__(self, filename, maxBytes=10*1024*1024, backupCount=50, encoding='utf-8'):
|
||||
super().__init__()
|
||||
self.baseFilename = os.path.abspath(filename)
|
||||
self.maxBytes = maxBytes
|
||||
self.backupCount = backupCount
|
||||
self.encoding = encoding
|
||||
self.stream = None
|
||||
self._lock = threading.Lock()
|
||||
self._open_file()
|
||||
|
||||
def _open_file(self):
|
||||
"""파일 스트림 열기"""
|
||||
try:
|
||||
# 디렉토리 생성
|
||||
os.makedirs(os.path.dirname(self.baseFilename), exist_ok=True)
|
||||
self.stream = open(self.baseFilename, 'a', encoding=self.encoding)
|
||||
except Exception as e:
|
||||
# 파일 열기 실패 시 stderr로 출력
|
||||
import sys
|
||||
print(f"[Logger] 파일 열기 실패: {self.baseFilename}, 오류: {e}", file=sys.stderr)
|
||||
self.stream = None
|
||||
|
||||
def _close_file(self):
|
||||
"""파일 스트림 닫기"""
|
||||
if self.stream:
|
||||
try:
|
||||
self.stream.flush()
|
||||
self.stream.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.stream = None
|
||||
|
||||
def _should_rollover(self):
|
||||
"""롤오버가 필요한지 확인"""
|
||||
if self.stream is None:
|
||||
return False
|
||||
try:
|
||||
# 현재 파일 크기 확인
|
||||
self.stream.seek(0, 2) # 파일 끝으로 이동
|
||||
size = self.stream.tell()
|
||||
return size >= self.maxBytes
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _do_rollover(self):
|
||||
"""롤오버 실행 - Windows 안전 방식"""
|
||||
self._close_file()
|
||||
|
||||
try:
|
||||
# 타임스탬프 기반 백업 파일명 생성
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
base, ext = os.path.splitext(self.baseFilename)
|
||||
backup_name = f"{base}_{timestamp}{ext}"
|
||||
|
||||
# 기존 파일을 백업 파일로 이름 변경
|
||||
if os.path.exists(self.baseFilename):
|
||||
try:
|
||||
os.rename(self.baseFilename, backup_name)
|
||||
except OSError:
|
||||
# rename 실패 시 복사 후 삭제 시도
|
||||
try:
|
||||
import shutil
|
||||
shutil.copy2(self.baseFilename, backup_name)
|
||||
# 원본 파일 내용 비우기 (삭제 대신)
|
||||
with open(self.baseFilename, 'w', encoding=self.encoding) as f:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 오래된 백업 파일 정리
|
||||
self._cleanup_old_backups()
|
||||
|
||||
except Exception as e:
|
||||
import sys
|
||||
print(f"[Logger] 롤오버 실패: {e}", file=sys.stderr)
|
||||
|
||||
# 새 파일 열기
|
||||
self._open_file()
|
||||
|
||||
def _cleanup_old_backups(self):
|
||||
"""오래된 백업 파일 정리"""
|
||||
try:
|
||||
base_dir = os.path.dirname(self.baseFilename)
|
||||
base_name = os.path.basename(self.baseFilename)
|
||||
name_without_ext, ext = os.path.splitext(base_name)
|
||||
|
||||
# 백업 파일 패턴: {name}_{timestamp}.log
|
||||
backup_files = []
|
||||
for f in os.listdir(base_dir):
|
||||
if f.startswith(name_without_ext + '_') and f.endswith(ext):
|
||||
full_path = os.path.join(base_dir, f)
|
||||
try:
|
||||
mtime = os.path.getmtime(full_path)
|
||||
backup_files.append((full_path, mtime))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 최신순 정렬 후 backupCount 초과분 삭제
|
||||
backup_files.sort(key=lambda x: x[1], reverse=True)
|
||||
for file_path, _ in backup_files[self.backupCount:]:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def emit(self, record):
|
||||
"""로그 레코드 출력"""
|
||||
with self._lock:
|
||||
try:
|
||||
# 롤오버 체크
|
||||
if self._should_rollover():
|
||||
self._do_rollover()
|
||||
|
||||
# 스트림이 없으면 다시 열기 시도
|
||||
if self.stream is None:
|
||||
self._open_file()
|
||||
|
||||
if self.stream:
|
||||
msg = self.format(record)
|
||||
self.stream.write(msg + '\n')
|
||||
self.stream.flush()
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
def close(self):
|
||||
"""핸들러 종료"""
|
||||
with self._lock:
|
||||
self._close_file()
|
||||
super().close()
|
||||
|
||||
class Logger:
|
||||
|
||||
def __init__(self, gui_logger=None, log_file="Edit_PartTimer_log.log", logger_name="Edit_PartTimer_log",
|
||||
|
|
@ -74,8 +219,30 @@ class Logger:
|
|||
# 로그 디렉토리 생성
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 멀티프로세스 환경에서의 로그 파일명 충돌 방지를 위해 PID 추가
|
||||
# 윈도우에서는 실행 중인 파일의 이름 변경(롤링) 시 PermissionError가 발생할 수 있으므로
|
||||
# 프로세스별로 별도의 로그 파일을 사용하도록 함.
|
||||
pid = os.getpid()
|
||||
|
||||
# 원본 로그 파일명에서 확장자 분리
|
||||
if log_file.endswith('.log'):
|
||||
base_name = log_file[:-4]
|
||||
ext = '.log'
|
||||
else:
|
||||
base_name = log_file
|
||||
ext = '.log'
|
||||
|
||||
# PID가 포함된 실제 로그 파일명 생성 (예: app_1234.log)
|
||||
# 단, 메인 프로세스라고 판단되거나 특정 조건에서는 원본 이름을 유지할 수도 있으나
|
||||
# 교착 상태 해결을 위해 무조건 PID를 붙이는 것이 안전함.
|
||||
self.actual_log_file = self.log_dir / f"{Path(base_name).stem}_{pid}{ext}"
|
||||
|
||||
# cleanup을 위한 기본 이름 패턴 설정 (PID 부분 제외하고 매칭)
|
||||
# self.log_base_name은 원본 파일의 stem을 유지하여 모든 PID 로그를 관리 대상으로 함
|
||||
self.log_base_name = Path(log_file).stem
|
||||
|
||||
# 로그 설정
|
||||
self.logger = logging.getLogger(logger_name)
|
||||
self.logger = logging.getLogger(f"{logger_name}_{pid}") # 로거 이름도 유니크하게
|
||||
self.logger.setLevel(file_log_level)
|
||||
# 상위 로거로의 전파 방지(중복 출력 방지)
|
||||
self.logger.propagate = False
|
||||
|
|
@ -95,7 +262,8 @@ class Logger:
|
|||
|
||||
# 핸들러 추가
|
||||
self._add_console_handler(file_log_level)
|
||||
self._add_file_handler(log_file, file_log_level)
|
||||
# 실제 파일명(PID 포함)으로 핸들러 생성
|
||||
self._add_file_handler(str(self.actual_log_file), file_log_level)
|
||||
|
||||
# 자동 정리 스레드 시작
|
||||
self._start_cleanup_thread()
|
||||
|
|
@ -111,17 +279,17 @@ class Logger:
|
|||
self.logger.addHandler(console_handler)
|
||||
|
||||
def _add_file_handler(self, log_file, level):
|
||||
"""파일 크기 기반 로테이팅 + 수명 관리"""
|
||||
from logging.handlers import RotatingFileHandler
|
||||
"""파일 크기 기반 로테이팅 + 수명 관리 (Windows 호환)"""
|
||||
# 확장자가 .log가 아니면 .log로 변경
|
||||
if not log_file.endswith('.log'):
|
||||
base_name, _ = os.path.splitext(log_file)
|
||||
log_file = base_name + '.log'
|
||||
|
||||
# 크기 기반 로테이션: 10MB, 최대 50개(정리 스레드가 3일/일5개로 실제 보존 제한)
|
||||
file_handler = RotatingFileHandler(
|
||||
# Windows 호환 커스텀 핸들러 사용
|
||||
# RotatingFileHandler는 Windows에서 파일 잠금 문제로 롤링 실패 가능
|
||||
file_handler = WindowsSafeRotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=10 * 1024 * 1024,
|
||||
maxBytes=10 * 1024 * 1024, # 10MB
|
||||
backupCount=50,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -230,24 +230,162 @@ class GemmaTranslator:
|
|||
out.extend(resp.get("result", []))
|
||||
return out
|
||||
|
||||
# ---- D) (선택) 카피 다듬기 ---- (이 메서드는 새 스펙에서 OCR가 단일 단계이므로 제거 또는 주석 처리)
|
||||
# def polish_translations(
|
||||
# self,
|
||||
# product_name: str,
|
||||
# category: str,
|
||||
# id_text_pairs: List[Dict[str, Any]],
|
||||
# batch_size: int = 16,
|
||||
# ) -> List[Dict[str, Any]]:
|
||||
# """
|
||||
# 입력: [{"id":1,"translation":"..."}]
|
||||
# 반환: [{"id":1,"result":"..."}]
|
||||
# """
|
||||
# if not id_text_pairs:
|
||||
# return []
|
||||
# out: List[Dict[str, Any]] = []
|
||||
# for i in range(0, len(id_text_pairs), batch_size):
|
||||
# chunk = id_text_pairs[i : i + batch_size]
|
||||
# payload = {"product_name": product_name, "category": category, "items": chunk}
|
||||
# resp = self._post("/translate_ocr_step2", payload)
|
||||
# out.extend(resp.get("result", []))
|
||||
# return out
|
||||
# ---- E) LLM API Translate (/api/v1/llm/run) ----
|
||||
def run_llm_translation(
|
||||
self,
|
||||
product_name: str,
|
||||
category: str,
|
||||
ocr_results: List[Dict[str, Any]],
|
||||
job_type: str = "ocr_translator_step1",
|
||||
prompt_name: str = "ocr_translator_step1",
|
||||
retry_count: int = 3,
|
||||
steps: int = 1
|
||||
) -> List[str]:
|
||||
"""
|
||||
새로운 LLM 번역 API (/api/v1/llm/run) 사용
|
||||
입력: OCR 결과 리스트 [{'text': '...'}, ...]
|
||||
출력: 번역된 문자열 리스트 (원래 순서 유지)
|
||||
|
||||
Args:
|
||||
product_name: 상품명
|
||||
category: 카테고리
|
||||
ocr_results: OCR 결과 리스트
|
||||
job_type: 작업 타입
|
||||
prompt_name: 프롬프트 이름
|
||||
retry_count: 재시도 횟수 (1-5)
|
||||
steps: 번역 단계 (1=직역만, 2=직역+마케팅톤 변환)
|
||||
"""
|
||||
if not ocr_results:
|
||||
return []
|
||||
|
||||
# 1. items 구성
|
||||
items = []
|
||||
source_to_orig_idx = {}
|
||||
for i, res in enumerate(ocr_results):
|
||||
text = (res.get("text") or "").strip()
|
||||
if text:
|
||||
item_id = len(items) + 1
|
||||
items.append({"id": item_id, "source": text})
|
||||
source_to_orig_idx[item_id] = i
|
||||
|
||||
if not items:
|
||||
return [""] * len(ocr_results)
|
||||
|
||||
# 2. API 요청 payload 구성
|
||||
payload = {
|
||||
"category": category,
|
||||
"items": items,
|
||||
"job_type": job_type,
|
||||
"product_name": product_name,
|
||||
"prompt_name": prompt_name
|
||||
}
|
||||
|
||||
# Query parameters: retry, steps
|
||||
# steps 값 검증 (1 또는 2만 허용)
|
||||
steps = max(1, min(2, steps))
|
||||
path = f"/api/v1/llm/run?retry={retry_count}&steps={steps}"
|
||||
|
||||
try:
|
||||
# 3. 요청 전송
|
||||
# _post 메서드는 base_url + path로 요청하므로, base_url 설정이 중요함.
|
||||
# 사용자 제공 URL이 /api/v1/llm/run 이므로, base_url이 호스트 루트여야 함.
|
||||
# 만약 base_url이 /api 등을 포함하고 있다면 조정 필요.
|
||||
# 여기서는 _post가 path를 그대로 붙인다고 가정.
|
||||
|
||||
resp = self._post(path, payload)
|
||||
|
||||
# 4. 응답 처리
|
||||
# 응답 스키마: { "success": bool, "results": "string", "error": "string", ... }
|
||||
if not resp.get("success"):
|
||||
error_msg = resp.get("error", "Unknown error")
|
||||
self.log.error(f"[GemmaTranslator] LLM API Error: {error_msg}")
|
||||
# 실패 시 빈 문자열 또는 원본 반환? 여기선 빈 문자열 리스트로 둠 (혹은 예외 발생)
|
||||
raise GemmaTranslatorError(f"LLM API returned success=False: {error_msg}")
|
||||
|
||||
results_data = resp.get("results")
|
||||
|
||||
# 결과가 없거나 비어있으면 처리
|
||||
if not results_data:
|
||||
# results가 None이거나 빈 리스트/문자열인 경우
|
||||
return [""] * len(ocr_results)
|
||||
|
||||
translated_items = []
|
||||
|
||||
# 1. 리스트인 경우 (표준 서버 응답: List[Dict])
|
||||
if isinstance(results_data, list):
|
||||
translated_items = results_data
|
||||
|
||||
# 2. 딕셔너리인 경우 (단일 객체로 온 경우 대비)
|
||||
elif isinstance(results_data, dict):
|
||||
translated_items = [results_data]
|
||||
|
||||
# 3. 문자열인 경우 (JSON 텍스트로 온 경우 - 유연한 대응)
|
||||
elif isinstance(results_data, str):
|
||||
try:
|
||||
translated_items = json.loads(results_data)
|
||||
except json.JSONDecodeError:
|
||||
# JSON 파싱 실패 시 Markdown 코드 블록 제거 시도
|
||||
clean_str = results_data.strip()
|
||||
|
||||
# ```json 또는 ``` 제거
|
||||
if clean_str.startswith("```"):
|
||||
# 첫 줄 제거 (```json ... 또는 ``` ...)
|
||||
parts = clean_str.split('\n', 1)
|
||||
if len(parts) > 1:
|
||||
clean_str = parts[1]
|
||||
else:
|
||||
clean_str = clean_str.replace("```", "")
|
||||
|
||||
if clean_str.endswith("```"):
|
||||
clean_str = clean_str[:-3]
|
||||
|
||||
try:
|
||||
translated_items = json.loads(clean_str.strip())
|
||||
except json.JSONDecodeError:
|
||||
self.log.error(f"[GemmaTranslator] LLM results is not valid JSON: {results_data[:100]}...")
|
||||
raise GemmaTranslatorError("LLM API results parsing failed")
|
||||
|
||||
else:
|
||||
self.log.error(f"[GemmaTranslator] Unexpected results type: {type(results_data)}")
|
||||
raise GemmaTranslatorError(f"Unexpected results type: {type(results_data)}")
|
||||
|
||||
# 5. 결과 매핑
|
||||
|
||||
out_ko = [""] * len(ocr_results)
|
||||
# translated_items가 리스트인지 확인
|
||||
if isinstance(translated_items, list):
|
||||
for item in translated_items:
|
||||
# item 구조 확인: {"id": 1, "translation": "..."} (서버 로그 기반)
|
||||
item_id = int(item.get("id", 0))
|
||||
|
||||
# 결과 필드 찾기 (translation > result > translated > source)
|
||||
res_text = ""
|
||||
if "translation" in item:
|
||||
res_text = item["translation"]
|
||||
elif "result" in item:
|
||||
res_text = item["result"]
|
||||
elif "translated" in item:
|
||||
res_text = item["translated"]
|
||||
elif "source" in item:
|
||||
# source만 있고 번역이 없는 경우? (거의 없겠지만)
|
||||
res_text = item["source"]
|
||||
|
||||
# None 체크
|
||||
if res_text is None:
|
||||
res_text = ""
|
||||
|
||||
orig_idx = source_to_orig_idx.get(item_id)
|
||||
if orig_idx is not None:
|
||||
out_ko[orig_idx] = str(res_text).strip()
|
||||
else:
|
||||
self.log.warning(f"[GemmaTranslator] Unexpected translated_items type: {type(translated_items)}")
|
||||
|
||||
return out_ko
|
||||
|
||||
except Exception as e:
|
||||
self.log.error(f"[GemmaTranslator] run_llm_translation failed: {e}")
|
||||
# Fallback behavior or re-raise?
|
||||
# User wants to replace google translate. If this fails, maybe we should return original texts or empty?
|
||||
# Or re-raise to let caller handle (e.g. fallback to Google).
|
||||
raise e
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import asyncio
|
|||
import requests
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
from urllib.parse import urlparse
|
||||
import sys
|
||||
import re
|
||||
|
|
@ -33,6 +34,15 @@ except Exception:
|
|||
except Exception:
|
||||
GemmaTranslator = None # 사용 시 체크 후 동작
|
||||
|
||||
# OpenRouter 번역 클라이언트(옵셔널): 다양한 LLM 모델 지원
|
||||
try:
|
||||
from modules.openrouter_client import OpenRouterTranslator
|
||||
except Exception:
|
||||
try:
|
||||
from openrouter_client import OpenRouterTranslator
|
||||
except Exception:
|
||||
OpenRouterTranslator = None
|
||||
|
||||
# from modules.background_removal_module import BackgroundRemovalModule
|
||||
# from modules.background_removal_module_pp import PPMattingBackgroundRemovalModule # (변경)
|
||||
|
||||
|
|
@ -143,16 +153,44 @@ class ImageProcessor3:
|
|||
self.ocr_module = None # 명시적으로 None 설정
|
||||
|
||||
|
||||
# try:
|
||||
# self.ai_translator = GemmaTranslator(
|
||||
# base_url=self.toggle_states.get("gemma_api_base_url", "http://192.168.0.146:8000"),
|
||||
# timeout=int(self.toggle_states.get("gemma_api_timeout", 120)),
|
||||
# )
|
||||
# self.logger.log(f"gemma_api_base_url: {self.ai_translator.base_url}", level=logging.DEBUG)
|
||||
# self.logger.log(f"GemmaTranslator 연결: base={self.ai_translator.base_url}", level=logging.INFO)
|
||||
# except Exception as e:
|
||||
# self.logger.log(f"GemmaTranslator 연결 실패: {e}", level=logging.ERROR, exc_info=True)
|
||||
# self.ai_translator = None
|
||||
# AI 번역기 초기화 (OpenRouter 또는 Gemma 선택)
|
||||
# toggle_states에서 llm_provider 확인: "openrouter" 또는 "gemma"
|
||||
llm_provider = self.toggle_states.get("llm_provider", "gemma")
|
||||
self.ai_translator = None
|
||||
|
||||
if llm_provider == "openrouter" and OpenRouterTranslator is not None:
|
||||
try:
|
||||
openrouter_api_key = self.toggle_states.get("openrouter_api_key", "")
|
||||
openrouter_model_id = self.toggle_states.get("openrouter_model_id", "xiaomi/mimo-v2-flash:free")
|
||||
|
||||
if openrouter_api_key:
|
||||
self.ai_translator = OpenRouterTranslator(
|
||||
api_key=openrouter_api_key,
|
||||
model_id=openrouter_model_id,
|
||||
timeout=int(self.toggle_states.get("llm_api_timeout", 120)),
|
||||
logger=self.logger
|
||||
)
|
||||
self.logger.log(f"✅ OpenRouterTranslator 초기화 성공: 모델={openrouter_model_id}", level=logging.INFO)
|
||||
else:
|
||||
self.logger.log("⚠️ OpenRouter API 키가 설정되지 않음. Gemma로 폴백.", level=logging.WARNING)
|
||||
llm_provider = "gemma" # 폴백
|
||||
except Exception as e:
|
||||
self.logger.log(f"❌ OpenRouterTranslator 초기화 실패: {e}. Gemma로 폴백.", level=logging.ERROR, exc_info=True)
|
||||
llm_provider = "gemma" # 폴백
|
||||
|
||||
# Gemma 초기화 (기본 또는 폴백)
|
||||
if self.ai_translator is None and GemmaTranslator is not None:
|
||||
try:
|
||||
self.ai_translator = GemmaTranslator(
|
||||
base_url=self.toggle_states.get("gemma_api_base_url", "http://192.168.0.146:8008"),
|
||||
timeout=int(self.toggle_states.get("gemma_api_timeout", 120)),
|
||||
logger=self.logger
|
||||
)
|
||||
self.logger.log(f"gemma_api_base_url: {self.ai_translator.base_url}", level=logging.DEBUG)
|
||||
self.logger.log(f"✅ GemmaTranslator 연결: base={self.ai_translator.base_url}", level=logging.INFO)
|
||||
except Exception as e:
|
||||
self.logger.log(f"❌ GemmaTranslator 연결 실패: {e}", level=logging.ERROR, exc_info=True)
|
||||
self.ai_translator = None
|
||||
|
||||
|
||||
# try:
|
||||
|
|
@ -226,21 +264,42 @@ class ImageProcessor3:
|
|||
except Exception as e:
|
||||
self.logger.log(f"GoogleTranslate 초기화 실패: {e}", level=logging.ERROR, exc_info=True)
|
||||
|
||||
# MIGAN ONNX 파이프라인 준비(옵션 토글 기반)
|
||||
# MIGAN ONNX 파이프라인 준비 (base_dir 기반 고정 경로 사용)
|
||||
# ※ toggle_states['migan_onnx_path']는 더 이상 사용하지 않음
|
||||
try:
|
||||
from modules.migan_module import build_migan_from_toggle
|
||||
# MIGAN이 실제로 사용될 때만 초기화 (inpaint_method이 'migan'이거나 로컬 inpaint_method가 'migan'일 때)
|
||||
|
||||
# base_dir 기반 MIGAN 모델 경로 (고정)
|
||||
migan_model_candidates = [
|
||||
os.path.join(self.base_dir, "migan_onnx", "migan_pipeline_v2_simplified.onnx"),
|
||||
os.path.join(self.base_dir, "migan_onnx", "migan_pipeline_v2.onnx"),
|
||||
]
|
||||
|
||||
migan_onnx_path = ""
|
||||
for candidate in migan_model_candidates:
|
||||
if os.path.exists(candidate):
|
||||
migan_onnx_path = candidate
|
||||
self.logger.log(f"[MIGAN] 모델 경로 확인: {migan_onnx_path}", level=logging.DEBUG)
|
||||
break
|
||||
|
||||
if not migan_onnx_path:
|
||||
self.logger.log(f"[MIGAN] 모델 파일을 찾을 수 없음 (base_dir: {self.base_dir})", level=logging.WARNING)
|
||||
self.logger.log(f"[MIGAN] 시도한 경로: {migan_model_candidates}", level=logging.DEBUG)
|
||||
|
||||
# MIGAN 초기화 조건
|
||||
inpaint_method = self.toggle_states.get("inpaint_method", "request")
|
||||
local_inpaint_method = self.toggle_states.get("local_inpaint_method", "migan")
|
||||
|
||||
should_init_migan = (
|
||||
self.toggle_states.get("migan_onnx_path") and
|
||||
migan_onnx_path and
|
||||
(inpaint_method == "migan" or local_inpaint_method == "migan")
|
||||
)
|
||||
|
||||
if should_init_migan:
|
||||
# GPU 상태에 따라 CUDA 사용 여부 결정
|
||||
enhanced_toggle_states = self.toggle_states.copy()
|
||||
# migan_onnx_path를 내부 고정 경로로 덮어쓰기
|
||||
enhanced_toggle_states["migan_onnx_path"] = migan_onnx_path
|
||||
|
||||
if self.gpu_manager and self.gpu_manager.can_use_cuda:
|
||||
enhanced_toggle_states["migan_use_cuda"] = enhanced_toggle_states.get("migan_use_cuda", False)
|
||||
self.logger.log(f"MIGAN CUDA 사용 설정: {enhanced_toggle_states['migan_use_cuda']}", level=logging.DEBUG)
|
||||
|
|
@ -253,10 +312,15 @@ class ImageProcessor3:
|
|||
self.logger.log(f"[MIGAN] 초기화 완료: gpu_manager 속성={hasattr(self.migan, 'gpu_manager')}, 값={getattr(self.migan, 'gpu_manager', None)}", level=logging.DEBUG)
|
||||
else:
|
||||
self.migan = None
|
||||
self.logger.log(f"MIGAN 초기화 건너뜀: inpaint_method={inpaint_method}, local_inpaint_method={local_inpaint_method}, migan_onnx_path={bool(self.toggle_states.get('migan_onnx_path'))}", level=logging.DEBUG)
|
||||
self.logger.log(f"MIGAN 초기화 건너뜀: inpaint_method={inpaint_method}, local_inpaint_method={local_inpaint_method}, migan_onnx_path={bool(migan_onnx_path)}", level=logging.DEBUG)
|
||||
# MIGAN 모델이 없으면 inpaint_method를 cv로 강제 설정
|
||||
if not migan_onnx_path:
|
||||
self.inpaint_method = 'cv'
|
||||
self.logger.log(f"[MIGAN] 모델 없음 → inpaint_method를 'cv'로 강제 설정", level=logging.WARNING)
|
||||
except Exception as e:
|
||||
self.migan = None
|
||||
self.logger.log(f"MIGAN 초기화 실패: {e}", level=logging.ERROR, exc_info=True)
|
||||
self.inpaint_method = 'cv' # 초기화 실패 시 cv로 폴백
|
||||
self.logger.log(f"MIGAN 초기화 실패 → inpaint_method를 'cv'로 강제 설정: {e}", level=logging.ERROR, exc_info=True)
|
||||
|
||||
# 인페인팅 실행 정보(마지막 사용 방식/장치) 추적용 내부 상태
|
||||
self._last_inpaint_used = None
|
||||
|
|
@ -264,6 +328,16 @@ class ImageProcessor3:
|
|||
|
||||
# 외부 서버 헬스 체크 플래그
|
||||
self.is_external_server_alive = False
|
||||
# 초기 헬스체크 실행 (비동기로 실행하거나, 여기서 한 번 체크)
|
||||
try:
|
||||
if self.check_external_server_availability():
|
||||
self.is_external_server_alive = True
|
||||
self.logger.log(f"외부 인페인팅 서버 활성화 확인됨: {self.toggle_states.get('request_inpainting_server_url')}", level=logging.INFO)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# MIGAN 동시 접근 방지용 락
|
||||
self._migan_lock = threading.Lock()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.log(f"ImageProcessor3 초기화 중 치명적 오류 발생: {e}", level=logging.ERROR, exc_info=True)
|
||||
|
|
@ -378,7 +452,14 @@ class ImageProcessor3:
|
|||
self.is_member_valid = self.toggle_states.get('membership_level', 'basic') == 'vip' or self.authenticated_by_admin
|
||||
|
||||
# 2. 인페인팅 설정 업데이트
|
||||
self.inpaint_method = self.toggle_states.get("inpaint_method", "migan")
|
||||
# MIGAN이 초기화되지 않은 경우 inpaint_method를 'cv'로 강제 유지
|
||||
requested_inpaint_method = self.toggle_states.get("inpaint_method", "migan")
|
||||
if getattr(self, 'migan', None) is None and requested_inpaint_method == 'migan':
|
||||
self.inpaint_method = 'cv'
|
||||
self.logger.log(f"[UpdateToggle] MIGAN 미초기화 상태 → inpaint_method를 'cv'로 강제 유지 (요청: {requested_inpaint_method})", level=logging.WARNING)
|
||||
else:
|
||||
self.inpaint_method = requested_inpaint_method
|
||||
|
||||
self.use_local_rembg = self.toggle_states.get("use_local_rembg", False)
|
||||
self.local_model_name = self.toggle_states.get("local_model_name", 'birefnet-general-lite')
|
||||
|
||||
|
|
@ -544,7 +625,7 @@ class ImageProcessor3:
|
|||
return True
|
||||
return False
|
||||
|
||||
async def process_single_image(self, original_image_url, index, delay=1.0, file_prefix=""):
|
||||
async def process_single_image(self, original_image_url, index, delay=1.0, file_prefix="", model_name: str = "migan"):
|
||||
"""
|
||||
단일 이미지를 처리합니다 (다운로드 -> OCR -> 인페인팅)
|
||||
|
||||
|
|
@ -577,7 +658,7 @@ class ImageProcessor3:
|
|||
# self.logger.log(f"unwanted_texts: {self.unwanted_texts}", level=logging.DEBUG)
|
||||
self.logger.log(f"이미지 번역시작", level=logging.DEBUG)
|
||||
|
||||
self.logger.log(f"toggle_states: {self.toggle_states}", level=logging.DEBUG)
|
||||
# self.logger.log(f"toggle_states: {self.toggle_states}", level=logging.DEBUG)
|
||||
try:
|
||||
# 0. 이미지 URL 유효성 체크 (http/https & 이미지 확장자)
|
||||
if not original_image_url or not isinstance(original_image_url, str):
|
||||
|
|
@ -698,27 +779,41 @@ class ImageProcessor3:
|
|||
blur_size = 15
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
_t_trans = _time.time()
|
||||
_t_mask = _time.time()
|
||||
_t_parallel_start = _time.time()
|
||||
|
||||
translate_future = loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.batch_google_translate_texts(filter_ocr_results)
|
||||
)
|
||||
mask_future = loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.mask_module.create_masks(
|
||||
image_path=local_image_path,
|
||||
ocr_results=filter_ocr_results,
|
||||
mask_option="basic",
|
||||
expansion_size=expansion_size,
|
||||
blur_size=blur_size
|
||||
# 개별 태스크 완료 시간 기록용 변수
|
||||
_translate_done_time = None
|
||||
_mask_done_time = None
|
||||
|
||||
async def timed_translate():
|
||||
nonlocal _translate_done_time
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.translate_ocr_results(filter_ocr_results)
|
||||
)
|
||||
)
|
||||
_translate_done_time = _time.time()
|
||||
return result
|
||||
|
||||
translated_texts, masks = await asyncio.gather(translate_future, mask_future)
|
||||
_timings_ms["translate"] = (_time.time() - _t_trans) * 1000.0
|
||||
_timings_ms["mask"] = (_time.time() - _t_mask) * 1000.0
|
||||
async def timed_mask():
|
||||
nonlocal _mask_done_time
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.mask_module.create_masks(
|
||||
image_path=local_image_path,
|
||||
ocr_results=filter_ocr_results,
|
||||
mask_option="basic",
|
||||
expansion_size=expansion_size,
|
||||
blur_size=blur_size
|
||||
)
|
||||
)
|
||||
_mask_done_time = _time.time()
|
||||
return result
|
||||
|
||||
translated_texts, masks = await asyncio.gather(timed_translate(), timed_mask())
|
||||
|
||||
# 개별 태스크 시간 계산 (병렬 시작 시점 기준)
|
||||
_timings_ms["translate"] = (_translate_done_time - _t_parallel_start) * 1000.0 if _translate_done_time else 0
|
||||
_timings_ms["mask"] = (_mask_done_time - _t_parallel_start) * 1000.0 if _mask_done_time else 0
|
||||
self.logger.log(f"translated_texts: {translated_texts}", level=logging.DEBUG)
|
||||
self.logger.log(f"마스크 생성 완료", level=logging.DEBUG)
|
||||
|
||||
|
|
@ -783,13 +878,19 @@ class ImageProcessor3:
|
|||
self.logger.log(f"is_member_valid: {self.is_member_valid}", level=logging.DEBUG)
|
||||
|
||||
# 인페인팅 방법 설정
|
||||
self.set_inpaint_method(file_prefix)
|
||||
self.logger.log(f"최종 inpaint_method: {self.inpaint_method}", level=logging.DEBUG)
|
||||
preferred_method = self.set_inpaint_method(file_prefix)
|
||||
self.logger.log(f"최종 inpaint_method: {preferred_method}", level=logging.DEBUG)
|
||||
# self.inpaint_method = 'migan'
|
||||
|
||||
# 인페인팅 실행 (폴백 순서: 자체서버 > GPU > CPU)
|
||||
_t = _time.time()
|
||||
inpainted_image = self.execute_inpaint_with_fallback(local_image_path, masks, ocr_count)
|
||||
|
||||
# 동기 함수인 execute_inpaint_with_fallback을 스레드 풀에서 실행
|
||||
inpainted_image = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.execute_inpaint_with_fallback(local_image_path, masks, ocr_count, preferred_method, model_name=model_name)
|
||||
)
|
||||
|
||||
_timings_ms["inpaint"] = (_time.time() - _t) * 1000.0
|
||||
self.logger.log(f"인페인팅 완료", level=logging.DEBUG)
|
||||
# # 개발환경에서 인페인트 결과 디버깅 저장
|
||||
|
|
@ -881,33 +982,33 @@ class ImageProcessor3:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
def set_inpaint_method(self, file_prefix: str) -> None:
|
||||
"""인페인팅 방법 설정 (CPU=cv, GPU=migan, 자체서버=request, 기타=cv)"""
|
||||
# file_prefix → toggle_states 키 매핑
|
||||
key_by_prefix = {
|
||||
"thumb": "thumb_trans_type",
|
||||
"detail": "detail_IMGTrans_type",
|
||||
"option": "optionIMGTrans_type", # 수정: option_IMGTrans_type → optionIMGTrans_type
|
||||
}
|
||||
|
||||
# 해당 키가 없으면 기본 'CPU'
|
||||
target_key = key_by_prefix.get(file_prefix, "")
|
||||
trans_type = self.toggle_states.get(target_key, "CPU")
|
||||
|
||||
# 변환 타입 → 실제 메서드 매핑
|
||||
method_map = {
|
||||
"CPU": "cv", # CPU 선택 시 OpenCV 인페인팅
|
||||
"GPU": "migan", # GPU 선택 시 MIGAN 인페인팅
|
||||
"자체서버": "external_request", # 자체서버 선택 시 Request 인페인팅
|
||||
}
|
||||
|
||||
self.inpaint_method = method_map.get(trans_type, "cv") # 기타는 cv로 폴백
|
||||
|
||||
self.logger.log(f"[set_inpaint_method] prefix={file_prefix}, target_key={target_key}, trans_type={trans_type} → inpaint_method={self.inpaint_method}", level=logging.DEBUG)
|
||||
|
||||
def execute_inpaint_with_fallback(self, local_image_path: str, masks, ocr_count: int):
|
||||
def set_inpaint_method(self, file_prefix: str) -> str:
|
||||
"""인페인팅 방법 설정
|
||||
|
||||
우선순위:
|
||||
1. AUTO 모드에서 이미 external_request가 선택된 경우 → external_request
|
||||
2. self.inpaint_method가 설정된 경우 → 해당 값 사용
|
||||
3. 기본값 → migan
|
||||
"""
|
||||
인페인팅 실행 - toggle_states['inpaint_method'] 설정에 따라 분기.
|
||||
|
||||
# 1. AUTO 모드에서 이미 external_request가 선택된 경우 우선 사용
|
||||
current_method = getattr(self, 'inpaint_method', None)
|
||||
if current_method == 'external_request':
|
||||
self.logger.log(f"[set_inpaint_method] AUTO 모드에서 external_request 선택됨 → 유지", level=logging.DEBUG)
|
||||
return "external_request"
|
||||
|
||||
# 2. self.inpaint_method가 설정된 경우 해당 값 사용
|
||||
if current_method and current_method in ('migan', 'cv', 'external_request'):
|
||||
self.logger.log(f"[set_inpaint_method] 기존 설정 사용 → {current_method}", level=logging.DEBUG)
|
||||
return current_method
|
||||
|
||||
# 3. 기본값: migan
|
||||
self.logger.log(f"[set_inpaint_method] 기본값 사용 → migan", level=logging.DEBUG)
|
||||
return "migan"
|
||||
|
||||
def execute_inpaint_with_fallback(self, local_image_path: str, masks, ocr_count: int, preferred_method: str = None, model_name: str = "migan"):
|
||||
"""
|
||||
인페인팅 실행 - preferred_method 설정에 따라 분기.
|
||||
'external_request'인 경우 VIP 체크 후 외부 서버 시도, 실패 시 MIGAN 폴백.
|
||||
그 외의 경우(또는 폴백 시) MIGAN 사용.
|
||||
|
||||
|
|
@ -915,6 +1016,7 @@ class ImageProcessor3:
|
|||
local_image_path: 이미지 파일 경로
|
||||
masks: 마스크 데이터
|
||||
ocr_count: OCR 결과 개수
|
||||
preferred_method: 선호하는 인페인팅 방식
|
||||
|
||||
Returns:
|
||||
인페인팅된 이미지 또는 None
|
||||
|
|
@ -924,24 +1026,27 @@ class ImageProcessor3:
|
|||
inpaint_before_mb = inpaint_before_mem.used / 1024 / 1024
|
||||
|
||||
inpainted_image = None
|
||||
current_method = preferred_method
|
||||
|
||||
# 1. 사용자 설정 확인 (self.inpaint_method가 설정되어 있으면 최우선, 없으면 토글값)
|
||||
# set_inpaint_method() 또는 자동 로직에 의해 설정된 값이 있으면 그것을 따름
|
||||
preferred_method = getattr(self, 'inpaint_method', None)
|
||||
if not preferred_method:
|
||||
preferred_method = self.toggle_states.get("inpaint_method", "migan")
|
||||
# 1. 사용자 설정 확인 (preferred_method가 설정되어 있으면 최우선, 없으면 토글값)
|
||||
if not current_method:
|
||||
current_method = self.toggle_states.get("inpaint_method", "migan")
|
||||
|
||||
server_url = self.toggle_states.get("request_inpainting_server_url", "")
|
||||
|
||||
# 2. External Request 모드일 때 처리
|
||||
if preferred_method == "external_request":
|
||||
if current_method == "external_request":
|
||||
if self.is_member_valid:
|
||||
if not self.is_external_server_alive:
|
||||
# 실시간 헬스 체크 수행 (캐시된 값 대신)
|
||||
is_server_alive = self.check_external_server_availability()
|
||||
self.is_external_server_alive = is_server_alive # 캐시 업데이트
|
||||
|
||||
if not is_server_alive:
|
||||
self.logger.log("외부 서버 상태 비정상(헬스 체크 실패) -> 로컬 MIGAN으로 폴백", level=logging.WARNING)
|
||||
elif server_url and str(server_url).strip().startswith("http"):
|
||||
# 외부 서버 시도
|
||||
self.inpaint_method = 'external_request'
|
||||
inpainted_image = self._try_external_inpaint(local_image_path, masks, str(server_url).strip())
|
||||
current_method = 'external_request'
|
||||
inpainted_image = self._try_external_inpaint(local_image_path, masks, str(server_url).strip(), model_name=model_name)
|
||||
|
||||
if inpainted_image is not None:
|
||||
self._last_inpaint_used = "external_request"
|
||||
|
|
@ -954,11 +1059,80 @@ class ImageProcessor3:
|
|||
self.logger.log("VIP 회원이 아님 -> 로컬 MIGAN으로 폴백", level=logging.WARNING)
|
||||
|
||||
# 3. 로컬 MIGAN 인페인팅 (기본값, 또는 외부 요청 실패/조건 미충족 시)
|
||||
if inpainted_image is None:
|
||||
self.inpaint_method = 'migan'
|
||||
if inpainted_image is None and current_method != 'cv':
|
||||
current_method = 'migan'
|
||||
inpainted_image = self._try_migan_inpaint(local_image_path, masks)
|
||||
# _try_migan_inpaint 내부에서 _last_inpaint_used 설정함
|
||||
|
||||
# 4. CV 인페인팅 폴백 (MIGAN 실패, 미초기화 또는 애초에 cv 선택 시)
|
||||
if inpainted_image is None:
|
||||
# 명시적으로 cv를 선택했거나, 위 단계들에서 모두 실패하여 최종 폴백이 필요한 경우
|
||||
current_method = 'cv'
|
||||
|
||||
# MIGAN/Server 실패 로그가 이미 출력되었을 수 있으므로, 여기서 CV 시도 로그 출력
|
||||
self.logger.log("인페인팅 폴백: OpenCV(Telea) 방식 시도", level=logging.INFO)
|
||||
|
||||
try:
|
||||
# 원본 이미지 로드
|
||||
img = cv2.imread(local_image_path)
|
||||
if img is None:
|
||||
raise Exception(f"이미지 로드 실패: {local_image_path}")
|
||||
|
||||
# 마스크 병합 (다양한 형태 지원)
|
||||
combined_mask = np.zeros(img.shape[:2], dtype=np.uint8)
|
||||
mask_count = 0
|
||||
|
||||
if isinstance(masks, np.ndarray):
|
||||
# 단일 numpy 배열인 경우
|
||||
if masks.ndim == 2:
|
||||
if masks.shape != combined_mask.shape:
|
||||
masks = cv2.resize(masks, (combined_mask.shape[1], combined_mask.shape[0]))
|
||||
combined_mask = masks.astype(np.uint8)
|
||||
mask_count = 1
|
||||
self.logger.log(f"[CV Inpaint] numpy 마스크 사용: shape={masks.shape}", level=logging.DEBUG)
|
||||
elif isinstance(masks, list):
|
||||
for m_item in masks:
|
||||
if isinstance(m_item, str) and os.path.exists(m_item):
|
||||
m_img = cv2.imread(m_item, cv2.IMREAD_GRAYSCALE)
|
||||
if m_img is not None:
|
||||
# 크기 맞추기
|
||||
if m_img.shape != combined_mask.shape:
|
||||
m_img = cv2.resize(m_img, (combined_mask.shape[1], combined_mask.shape[0]))
|
||||
combined_mask = cv2.bitwise_or(combined_mask, m_img)
|
||||
mask_count += 1
|
||||
elif isinstance(m_item, np.ndarray) and m_item.ndim == 2:
|
||||
# 리스트 내 numpy 배열
|
||||
if m_item.shape != combined_mask.shape:
|
||||
m_item = cv2.resize(m_item, (combined_mask.shape[1], combined_mask.shape[0]))
|
||||
combined_mask = cv2.bitwise_or(combined_mask, m_item.astype(np.uint8))
|
||||
mask_count += 1
|
||||
self.logger.log(f"[CV Inpaint] 리스트 마스크 병합: {mask_count}개", level=logging.DEBUG)
|
||||
elif isinstance(masks, str) and os.path.exists(masks):
|
||||
# 단일 파일 경로
|
||||
m_img = cv2.imread(masks, cv2.IMREAD_GRAYSCALE)
|
||||
if m_img is not None:
|
||||
if m_img.shape != combined_mask.shape:
|
||||
m_img = cv2.resize(m_img, (combined_mask.shape[1], combined_mask.shape[0]))
|
||||
combined_mask = m_img
|
||||
mask_count = 1
|
||||
self.logger.log(f"[CV Inpaint] 단일 파일 마스크 사용: {masks}", level=logging.DEBUG)
|
||||
|
||||
# 마스크가 비어있으면 경고
|
||||
if np.sum(combined_mask) == 0:
|
||||
self.logger.log(f"[CV Inpaint] 경고: 마스크가 비어있음 (mask_count={mask_count}, masks type={type(masks).__name__})", level=logging.WARNING)
|
||||
|
||||
# 인페인팅 실행
|
||||
inpainted_image = cv2.inpaint(img, combined_mask, 3, cv2.INPAINT_TELEA)
|
||||
|
||||
if inpainted_image is not None:
|
||||
self.logger.log("OpenCV 인페인팅 성공", level=logging.DEBUG)
|
||||
self._last_inpaint_used = "cv"
|
||||
self._last_inpaint_device = "CPU"
|
||||
except Exception as e:
|
||||
self.logger.log(f"OpenCV 인페인팅 실패: {e}", level=logging.WARNING)
|
||||
inpainted_image = None
|
||||
|
||||
|
||||
# 메모리 추적: 인페인팅 완료 후
|
||||
inpaint_after_mem = psutil.virtual_memory()
|
||||
inpaint_after_mb = inpaint_after_mem.used / 1024 / 1024
|
||||
|
|
@ -966,7 +1140,7 @@ class ImageProcessor3:
|
|||
inpaint_change_percent = (inpaint_change_mb / inpaint_before_mb) * 100 if inpaint_before_mb > 0 else 0
|
||||
self.logger.log(
|
||||
f"메모리 변화 [인페인팅]: {inpaint_before_mb:.1f}MB -> {inpaint_after_mb:.1f}MB "
|
||||
f"({inpaint_change_mb:+.1f}MB, {inpaint_change_percent:+.1f}%) - 방법: {self.inpaint_method}",
|
||||
f"({inpaint_change_mb:+.1f}MB, {inpaint_change_percent:+.1f}%) - 방법: {current_method}",
|
||||
level=logging.DEBUG if abs(inpaint_change_mb) < 10 else logging.INFO
|
||||
)
|
||||
|
||||
|
|
@ -1004,8 +1178,6 @@ class ImageProcessor3:
|
|||
self.logger.log(f"자체서버 인페인팅 중 오류: {e}", level=logging.WARNING, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def check_external_server_availability(self):
|
||||
"""외부 인페인팅 서버 유효성 체크"""
|
||||
try:
|
||||
|
|
@ -1019,7 +1191,7 @@ class ImageProcessor3:
|
|||
except Exception:
|
||||
return False
|
||||
|
||||
def _try_external_inpaint(self, local_image_path: str, masks, server_url: str):
|
||||
def _try_external_inpaint(self, local_image_path: str, masks, server_url: str, model_name: str = "migan"):
|
||||
"""외부 서버 인페인팅 시도"""
|
||||
try:
|
||||
if self.request_ai_server is None:
|
||||
|
|
@ -1027,7 +1199,7 @@ class ImageProcessor3:
|
|||
|
||||
self.logger.log(f"외부 인페인팅 시도: {server_url}", level=logging.DEBUG)
|
||||
# 모델명은 필요하면 토글에서 가져올 수 있음, 현재는 기본값
|
||||
result = self.request_ai_server.request_external_inpaint(local_image_path, masks, server_url)
|
||||
result = self.request_ai_server.request_external_inpaint(local_image_path, masks, server_url, model_name=model_name)
|
||||
return result
|
||||
except Exception as e:
|
||||
self.logger.log(f"외부 인페인팅 중 오류: {e}", level=logging.WARNING, exc_info=True)
|
||||
|
|
@ -1040,8 +1212,31 @@ class ImageProcessor3:
|
|||
self.logger.log("MIGAN 모듈이 초기화되지 않아 건너뜀", level=logging.DEBUG)
|
||||
return None
|
||||
|
||||
self.logger.log("MIGAN 인페인팅 시도", level=logging.DEBUG)
|
||||
result = self.migan.inpaint(local_image_path, masks)
|
||||
# MIGAN 실행 시 락 처리
|
||||
# - DirectML/CUDA: 동시성 이슈(드라이버 행) 방지를 위해 락 사용 권장
|
||||
# - CPU: 동시 실행해도 안전하므로 락 없이 병렬 처리 가능 (CPU 점유율은 올라감)
|
||||
is_cpu_mode = False
|
||||
try:
|
||||
# 현재 세션의 provider 확인
|
||||
if hasattr(self.migan, "session") and hasattr(self.migan.session, "get_providers"):
|
||||
providers = self.migan.session.get_providers()
|
||||
if providers and "CPUExecutionProvider" in providers[0]: # CPU가 최우선 순위인 경우
|
||||
is_cpu_mode = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if is_cpu_mode:
|
||||
# CPU 모드: 락 없이 병렬 실행하되, 과도한 점유 방지를 위해 약간의 슬립(양보)
|
||||
# (OS 스케줄러가 알아서 분배하겠지만, 명시적 양보로 UI 프리징 등 방지)
|
||||
time.sleep(0.01)
|
||||
self.logger.log("MIGAN 인페인팅 시도 (CPU Mode - No Lock)", level=logging.DEBUG)
|
||||
result = self.migan.inpaint(local_image_path, masks)
|
||||
else:
|
||||
# GPU(DirectML/CUDA) 모드: 락 사용
|
||||
with self._migan_lock:
|
||||
self.logger.log("MIGAN 인페인팅 시도 (GPU Mode - Lock Acquired)", level=logging.DEBUG)
|
||||
result = self.migan.inpaint(local_image_path, masks)
|
||||
|
||||
if result is not None:
|
||||
self.logger.log("MIGAN 인페인팅 성공", level=logging.DEBUG)
|
||||
# 사용 장치 기록
|
||||
|
|
@ -1468,6 +1663,58 @@ class ImageProcessor3:
|
|||
return texts
|
||||
|
||||
|
||||
def batch_llm_translate_texts(self, ocr_results, delimiter='\n'):
|
||||
"""
|
||||
LLM API를 이용한 번역 메서드.
|
||||
batch_google_translate_texts 대체용.
|
||||
"""
|
||||
# 1. LLM 클라이언트 확인
|
||||
if not self.ai_translator:
|
||||
# self.logger.log("LLM Translator(ai_translator)가 초기화되지 않음. 구글 번역으로 폴백.", level=logging.WARNING)
|
||||
return self.batch_google_translate_texts(ocr_results, delimiter)
|
||||
|
||||
# 2. 필요한 파라미터 준비 (toggle_states 등에서 가져오기)
|
||||
# product_name과 category가 필수이나, 현재 문맥에서 명확하지 않으면 기본값 사용
|
||||
product_name = self.toggle_states.get("product_name", "Unknown Product")
|
||||
category = self.toggle_states.get("category", "General")
|
||||
|
||||
# job_type, prompt_name (기본값 설정)
|
||||
job_type = "ocr_translator_step1"
|
||||
prompt_name = "ocr_translator_step1"
|
||||
|
||||
# steps: 번역 단계 (1=직역만, 2=직역+마케팅톤 변환)
|
||||
# toggle_states에서 가져오거나 기본값 1 사용
|
||||
steps = int(self.toggle_states.get("llm_translation_steps", 1))
|
||||
|
||||
try:
|
||||
# 3. LLM 번역 요청
|
||||
return self.ai_translator.run_llm_translation(
|
||||
product_name=product_name,
|
||||
category=category,
|
||||
ocr_results=ocr_results,
|
||||
job_type=job_type,
|
||||
prompt_name=prompt_name,
|
||||
retry_count=3,
|
||||
steps=steps
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.log(f"LLM 번역 실패: {e}. 구글 번역으로 폴백.", level=logging.ERROR, exc_info=True)
|
||||
# 실패 시 구글 번역으로 폴백
|
||||
return self.batch_google_translate_texts(ocr_results, delimiter)
|
||||
|
||||
|
||||
def translate_ocr_results(self, ocr_results):
|
||||
"""
|
||||
사용자 설정(toggle_states)에 따라 번역 방식을 선택하여 실행
|
||||
"""
|
||||
method = self.toggle_states.get("translation_method", "google")
|
||||
|
||||
if method == "llm":
|
||||
return self.batch_llm_translate_texts(ocr_results)
|
||||
else:
|
||||
return self.batch_google_translate_texts(ocr_results)
|
||||
|
||||
|
||||
def opencv_inpaint(self, image_path, mask, method='telea', radius=3):
|
||||
"""MIGAN 통일 이후 비활성화(호환용). 항상 None 반환"""
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -282,85 +282,34 @@ def worker_main(
|
|||
except Exception as e:
|
||||
logger.error(f"추가 READY 신호 전송 실패: {e}")
|
||||
|
||||
idle_log_last = 0.0
|
||||
while True:
|
||||
# ── 작업 루프 (비동기) ────────────────────────────────────────
|
||||
|
||||
async def process_task_async(task):
|
||||
uid = task.get("id")
|
||||
cmd = task.get("cmd")
|
||||
kwargs = task.get("kwargs", {})
|
||||
|
||||
try:
|
||||
# 주기적 상태 출력을 위해 타임아웃 설정
|
||||
logger.debug(f"큐에서 작업 대기 중... (PID: {os.getpid()})")
|
||||
task = task_q.get(timeout=60) # 60초 타임아웃
|
||||
#logger.info(f"🔥 작업 수신 성공: {task}")
|
||||
logger.info(f"🔥 작업 수신 성공")
|
||||
except queue.Empty:
|
||||
# 유휴 로그는 10분에 한 번만 기록해 로그 스팸을 줄인다.
|
||||
now_ts = time.time()
|
||||
if now_ts - idle_log_last >= 600:
|
||||
idle_log_last = now_ts
|
||||
logger.info("대기 중(유휴)")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"작업 수신 중 오류: {e}", exc_info=True)
|
||||
continue
|
||||
|
||||
if task is None:
|
||||
logger.info("Shutdown signal 수신 → 종료")
|
||||
try:
|
||||
# 종료 시 임시 폴더 정리(5분 경과 파일만)
|
||||
base_program = os.environ.get("PROGRAMDATA", r"C:\\ProgramData")
|
||||
app_data_dir = os.path.join(base_program, "ImgWorker")
|
||||
def _cleanup_dir(dp: str, older_than_sec: int = 300):
|
||||
try:
|
||||
now_ts = time.time()
|
||||
thr = max(0, int(older_than_sec or 0))
|
||||
for root, dirs, files in os.walk(dp, topdown=False):
|
||||
for fn in files:
|
||||
fp = os.path.join(root, fn)
|
||||
try:
|
||||
if thr <= 0:
|
||||
os.remove(fp)
|
||||
else:
|
||||
mt = os.path.getmtime(fp)
|
||||
if (now_ts - mt) >= thr:
|
||||
os.remove(fp)
|
||||
except Exception:
|
||||
pass
|
||||
for dn in dirs:
|
||||
full = os.path.join(root, dn)
|
||||
try:
|
||||
if not os.listdir(full):
|
||||
os.rmdir(full)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
ttl = int(os.environ.get("IMGWK_CLEAN_OLDER_THAN_SEC", "300"))
|
||||
for d in (os.path.join(app_data_dir, "incoming"), os.path.join(app_data_dir, "work"), os.path.join(app_data_dir, "output"), os.path.join(app_data_dir, "outputs")):
|
||||
_cleanup_dir(d, older_than_sec=ttl)
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
logger.info(f"🚀 작업 처리 시작: cmd={cmd}, uid={uid}")
|
||||
|
||||
uid = task["id"]
|
||||
cmd = task["cmd"]
|
||||
kwargs = task["kwargs"]
|
||||
logger.info(f"🚀 작업 처리 시작: cmd={cmd}, uid={uid}")
|
||||
# 메타 파라미터 제거 및 실시간 값 반영
|
||||
new_toggle = kwargs.pop("_toggle_states", None)
|
||||
if new_toggle and processor:
|
||||
processor.update_toggle_states(new_toggle)
|
||||
|
||||
# 메타 파라미터 제거 및 실시간 값 반영
|
||||
new_toggle = kwargs.pop("_toggle_states", None)
|
||||
if new_toggle and processor:
|
||||
processor.update_toggle_states(new_toggle)
|
||||
_ = kwargs.pop("_base_dir", None)
|
||||
upd_unwanted = kwargs.pop("_update_unwanted_texts", None)
|
||||
if upd_unwanted and processor:
|
||||
processor.update_unwanted_texts(upd_unwanted)
|
||||
|
||||
_ = kwargs.pop("_base_dir", None) # 필요 없으므로 버림
|
||||
upd_unwanted = kwargs.pop("_update_unwanted_texts", None)
|
||||
if upd_unwanted and processor:
|
||||
processor.update_unwanted_texts(upd_unwanted)
|
||||
|
||||
# 실제 작업 실행
|
||||
try:
|
||||
logger.debug(f"작업 실행 직전: cmd={cmd}")
|
||||
|
||||
data = None
|
||||
if cmd == "process_single_image":
|
||||
logger.debug("process_single_image 호출 직전")
|
||||
data = asyncio.run(processor.process_single_image(**kwargs))
|
||||
# 성능 지표 포함
|
||||
# asyncio.run 제거 -> await 직접 호출
|
||||
data = await processor.process_single_image(**kwargs)
|
||||
|
||||
try:
|
||||
timings = getattr(processor, '_last_timings', None)
|
||||
if isinstance(data, dict) and timings:
|
||||
|
|
@ -368,26 +317,26 @@ def worker_main(
|
|||
except Exception:
|
||||
pass
|
||||
logger.debug("process_single_image 호출 완료")
|
||||
|
||||
elif cmd == "remove_background":
|
||||
logger.debug("remove_background 호출 직전")
|
||||
data = asyncio.run(processor.remove_background(**kwargs))
|
||||
data = await processor.remove_background(**kwargs)
|
||||
logger.debug("remove_background 호출 완료")
|
||||
|
||||
elif cmd == "reinit_ocr":
|
||||
# 토글 반영 후 OCR 재초기화(프로바이더 캐시 고려)
|
||||
# Blocking 작업이므로 run_in_executor 권장되나, 짧으면 그냥 실행
|
||||
ok = processor.reset_ocr_module()
|
||||
data = {"ok": bool(ok)}
|
||||
|
||||
elif cmd == "reinit_rembg":
|
||||
# REMBG(배경제거) 모듈 재준비: 현재 Bria 모듈 사용시 세션/프로바이더를 재평가하도록 None 초기화
|
||||
try:
|
||||
# toggle_states의 provider override는 상위에서 반영되어 내려옴
|
||||
# BriaBackgroundRemovalModule은 lazy-load 방식 → 재생성 유도
|
||||
if hasattr(processor, 'background_removal_module'):
|
||||
try:
|
||||
del processor.background_removal_module
|
||||
except Exception:
|
||||
processor.background_removal_module = None
|
||||
|
||||
from modules.bria_background_removal_module import BriaBackgroundRemovalModule
|
||||
# 경로/매개변수는 processor.toggle_states에서 유추(존재 시)
|
||||
model_path = processor.toggle_states.get('local_rembg_model_path')
|
||||
processor.background_removal_module = BriaBackgroundRemovalModule(
|
||||
logger=logger,
|
||||
|
|
@ -399,37 +348,174 @@ def worker_main(
|
|||
except Exception as e:
|
||||
logger.error(f"REMBG 재초기화 실패: {e}")
|
||||
data = {"ok": False, "error": str(e)}
|
||||
|
||||
elif cmd == "reset_migan":
|
||||
# MIGAN 재구성(토글상 migan_use_cuda 등 변경 반영)
|
||||
try:
|
||||
from modules.migan_module import build_migan_from_toggle
|
||||
enhanced_toggle_states = processor.toggle_states.copy()
|
||||
# 가속 사용 플래그 명칭 정리(호환): migan_use_cuda -> migan_use_accel
|
||||
if 'migan_use_cuda' in enhanced_toggle_states and 'migan_use_accel' not in enhanced_toggle_states:
|
||||
enhanced_toggle_states['migan_use_accel'] = enhanced_toggle_states['migan_use_cuda']
|
||||
# provider override가 들어왔으면 반영(auto|dml|cpu)
|
||||
prov = kwargs.get('provider')
|
||||
if prov:
|
||||
enhanced_toggle_states['migan_provider_override'] = prov
|
||||
# gpu_manager 상태와 무관하게 토글을 그대로 전달(직접 폴백은 모듈 내부)
|
||||
|
||||
processor.migan = build_migan_from_toggle(enhanced_toggle_states, logger=logger, gpu_manager=getattr(processor, 'gpu_manager', None))
|
||||
data = {"ok": bool(processor.migan is not None)}
|
||||
except Exception as mm_err:
|
||||
logger.error(f"MIGAN 재설정 실패: {mm_err}")
|
||||
data = {"ok": False, "error": str(mm_err)}
|
||||
|
||||
elif cmd == "__PING__":
|
||||
# 하트비트 응답
|
||||
data = "__PONG__"
|
||||
|
||||
elif cmd == "update_toggle_states":
|
||||
# 토글 업데이트는 위쪽 공통 로직에서 이미 수행됨
|
||||
data = {"ok": True}
|
||||
|
||||
else:
|
||||
raise ValueError(f"unknown cmd: {cmd}")
|
||||
|
||||
logger.debug(f"작업 결과 반환 중: uid={uid}")
|
||||
result_q.put({"id": uid, "data": data})
|
||||
logger.debug(f"작업 결과 반환 완료: uid={uid}")
|
||||
|
||||
except Exception:
|
||||
logger.error(f"작업 처리 중 오류: cmd={cmd}, uid={uid}")
|
||||
logger.error("작업 처리 중 오류", exc_info=True)
|
||||
result_q.put({"id": uid, "error": traceback.format_exc()})
|
||||
|
||||
async def main_loop():
|
||||
active_tasks = set()
|
||||
idle_log_last = 0.0
|
||||
|
||||
logger.info("🚀 Async Worker Loop 시작")
|
||||
|
||||
last_status_log = 0.0
|
||||
|
||||
while True:
|
||||
now_ts = time.time()
|
||||
|
||||
# 1. 완료된 태스크 정리
|
||||
if active_tasks:
|
||||
done, active_tasks = await asyncio.wait(active_tasks, timeout=0.01)
|
||||
# 예외 처리는 process_task_async 내부에서 수행하므로 여기선 done 확인만 함
|
||||
|
||||
# --- [Watchdog & Status Logging] ---
|
||||
if active_tasks:
|
||||
# 30초마다 상태 로그
|
||||
if now_ts - last_status_log >= 30.0:
|
||||
last_status_log = now_ts
|
||||
try:
|
||||
waiting_info = []
|
||||
for t in active_tasks:
|
||||
t_info = getattr(t, "_task_info", {})
|
||||
t_uid = t_info.get("id", "unknown")
|
||||
start_t = t_info.get("_started_at", now_ts)
|
||||
elapsed = now_ts - start_t
|
||||
waiting_info.append(f"{t_uid}({elapsed:.1f}s)")
|
||||
|
||||
# 200초 이상 경과 시 경고 (DirectML 등 행 의심)
|
||||
if elapsed > 200:
|
||||
logger.warning(f"⚠️ 태스크 {t_uid}가 {elapsed:.1f}초째 실행 중입니다. (행 의심)")
|
||||
|
||||
logger.info(f"⚡ 실행 중인 작업({len(active_tasks)}): {', '.join(waiting_info)}")
|
||||
except Exception:
|
||||
pass
|
||||
# -----------------------------------
|
||||
|
||||
# 2. 동시성 제한 확인
|
||||
limit = 1
|
||||
if processor and processor.toggle_states:
|
||||
limit = int(processor.toggle_states.get("detail_concurrency_limit", 1))
|
||||
if limit < 1: limit = 1
|
||||
|
||||
# 실행 중인 태스크가 제한보다 많으면 대기 (단, 즉시 처리해야 할 시스템 태스크 고려 필요? -> 큐에서 꺼내봐야 아므로 일단 대기)
|
||||
if len(active_tasks) >= limit:
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
|
||||
# 3. 큐 폴링 (Non-blocking)
|
||||
try:
|
||||
task = task_q.get_nowait()
|
||||
# 유휴 로그 리셋
|
||||
# logger.info(f"🔥 작업 수신 성공")
|
||||
except queue.Empty:
|
||||
now_ts = time.time()
|
||||
if now_ts - idle_log_last >= 600:
|
||||
idle_log_last = now_ts
|
||||
logger.info("대기 중(유휴)")
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"작업 수신 중 오류: {e}", exc_info=True)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if task is None:
|
||||
logger.info("Shutdown signal 수신 → 종료 대기")
|
||||
if active_tasks:
|
||||
await asyncio.wait(active_tasks)
|
||||
break
|
||||
|
||||
# 4. 태스크 실행
|
||||
cmd = task.get("cmd")
|
||||
# 병렬 처리 허용 커맨드
|
||||
if cmd in ("process_single_image", "remove_background"):
|
||||
# 태스크 시작 시간 기록 (Watchdog용)
|
||||
task["_started_at"] = time.time()
|
||||
t = asyncio.create_task(process_task_async(task))
|
||||
# 태스크 객체에 메타데이터 저장 (파이썬 3.8+ name 속성 활용 또는 커스텀 속성)
|
||||
setattr(t, "_task_info", task)
|
||||
active_tasks.add(t)
|
||||
else:
|
||||
# 설정 변경 등은 안전을 위해 기존 작업 완료 후 실행
|
||||
if active_tasks:
|
||||
logger.info(f"설정 변경 명령({cmd}) 감지 - 기존 작업({len(active_tasks)}) 완료 대기...")
|
||||
await asyncio.wait(active_tasks)
|
||||
active_tasks.clear()
|
||||
|
||||
# 동기 실행 (await)
|
||||
await process_task_async(task)
|
||||
|
||||
# 종료 처리 (임시 파일 정리 등)
|
||||
logger.info("Worker Loop 종료 및 정리")
|
||||
try:
|
||||
base_program = os.environ.get("PROGRAMDATA", r"C:\\ProgramData")
|
||||
app_data_dir = os.path.join(base_program, "ImgWorker")
|
||||
def _cleanup_dir(dp: str, older_than_sec: int = 300):
|
||||
try:
|
||||
now_ts = time.time()
|
||||
thr = max(0, int(older_than_sec or 0))
|
||||
for root, dirs, files in os.walk(dp, topdown=False):
|
||||
for fn in files:
|
||||
fp = os.path.join(root, fn)
|
||||
try:
|
||||
if thr <= 0:
|
||||
os.remove(fp)
|
||||
else:
|
||||
mt = os.path.getmtime(fp)
|
||||
if (now_ts - mt) >= thr:
|
||||
os.remove(fp)
|
||||
except Exception:
|
||||
pass
|
||||
for dn in dirs:
|
||||
full = os.path.join(root, dn)
|
||||
try:
|
||||
if not os.listdir(full):
|
||||
os.rmdir(full)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
ttl = int(os.environ.get("IMGWK_CLEAN_OLDER_THAN_SEC", "300"))
|
||||
for d in (os.path.join(app_data_dir, "incoming"), os.path.join(app_data_dir, "work"), os.path.join(app_data_dir, "output"), os.path.join(app_data_dir, "outputs")):
|
||||
_cleanup_dir(d, older_than_sec=ttl)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Async Loop 실행
|
||||
try:
|
||||
asyncio.run(main_loop())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Main Loop 치명적 오류: {e}", exc_info=True)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,709 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
OpenRouter Translation API Python Client
|
||||
- OpenRouter API를 통한 다양한 LLM 모델 번역 지원
|
||||
- gemma_client.py와 동일한 인터페이스 제공
|
||||
- OpenRouter 모델 ID를 직접 사용
|
||||
|
||||
사용 예:
|
||||
from openrouter_client import OpenRouterTranslator
|
||||
|
||||
# 모델 ID 직접 지정
|
||||
ort = OpenRouterTranslator(
|
||||
api_key="sk-or-v1-xxx",
|
||||
model_id="xiaomi/mimo-v2-flash:free"
|
||||
)
|
||||
|
||||
# OCR 결과 번역
|
||||
ko_list = ort.translate_ocr_texts(
|
||||
product_name="휴대용 선풍기",
|
||||
category="가전/계절가전",
|
||||
ocr_results=[{"text":"强力送风"}, {"text":"USB-C 快速充电"}]
|
||||
)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import random
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
_JSON = Dict[str, Any]
|
||||
|
||||
|
||||
class OpenRouterTranslatorError(RuntimeError):
|
||||
"""OpenRouter 번역 클라이언트 예외"""
|
||||
pass
|
||||
|
||||
|
||||
class OpenRouterTranslator:
|
||||
"""
|
||||
OpenRouter API를 통한 번역 클라이언트.
|
||||
|
||||
GemmaTranslator와 동일한 인터페이스를 제공하여 호환성 유지.
|
||||
|
||||
Params
|
||||
------
|
||||
api_key : str
|
||||
OpenRouter API 키 (sk-or-v1-xxx)
|
||||
model_id : str
|
||||
OpenRouter 모델 ID (예: "xiaomi/mimo-v2-flash:free", "deepseek/deepseek-r1-0528:free")
|
||||
base_url : str
|
||||
OpenRouter API 베이스 URL
|
||||
timeout : int
|
||||
요청 타임아웃(초)
|
||||
max_retries : int
|
||||
요청 재시도 횟수
|
||||
backoff : float
|
||||
재시도 backoff base (지수)
|
||||
session : requests.Session | None
|
||||
세션 주입 가능
|
||||
logger : logging.Logger | None
|
||||
로거 주입 가능
|
||||
site_url : str | None
|
||||
OpenRouter 대시보드에 표시될 사이트 URL (선택)
|
||||
site_name : str | None
|
||||
OpenRouter 대시보드에 표시될 사이트 이름 (선택)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
model_id: str = "xiaomi/mimo-v2-flash:free",
|
||||
base_url: str = "https://openrouter.ai/api/v1",
|
||||
timeout: int = 120,
|
||||
max_retries: int = 2,
|
||||
backoff: float = 0.6,
|
||||
session: Optional[requests.Session] = None,
|
||||
logger: Optional[logging.Logger] = None,
|
||||
site_url: Optional[str] = None,
|
||||
site_name: Optional[str] = None,
|
||||
) -> None:
|
||||
self.api_key = api_key or os.getenv("OPENROUTER_API_KEY")
|
||||
if not self.api_key:
|
||||
raise OpenRouterTranslatorError("OpenRouter API 키가 필요합니다. api_key 파라미터 또는 OPENROUTER_API_KEY 환경변수를 설정하세요.")
|
||||
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.backoff = backoff
|
||||
self.sess = session or requests.Session()
|
||||
self.log = logger or logging.getLogger(__name__)
|
||||
self.site_url = site_url
|
||||
self.site_name = site_name
|
||||
|
||||
# 모델 설정
|
||||
self.model_id = model_id
|
||||
self.log.info(f"[OpenRouterTranslator] 모델 설정: {self.model_id}")
|
||||
|
||||
# -----------------------------
|
||||
# 모델 관리
|
||||
# -----------------------------
|
||||
def set_model(self, model_id: str) -> None:
|
||||
"""
|
||||
사용할 모델 변경
|
||||
|
||||
Args:
|
||||
model_id: OpenRouter 모델 ID (예: "deepseek/deepseek-r1-0528:free")
|
||||
"""
|
||||
self.model_id = model_id
|
||||
self.log.info(f"[OpenRouterTranslator] 모델 변경: {self.model_id}")
|
||||
|
||||
def get_current_model(self) -> Dict[str, Any]:
|
||||
"""현재 설정된 모델 정보 반환"""
|
||||
return {
|
||||
"id": self.model_id,
|
||||
"name": self.model_id,
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# 내부 HTTP 헬퍼
|
||||
# -----------------------------
|
||||
def _build_headers(self) -> Dict[str, str]:
|
||||
"""API 요청 헤더 생성"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if self.site_url:
|
||||
headers["HTTP-Referer"] = self.site_url
|
||||
if self.site_name:
|
||||
headers["X-Title"] = self.site_name
|
||||
return headers
|
||||
|
||||
def _post_chat(self, messages: List[Dict[str, str]], temperature: float = 0.3) -> str:
|
||||
"""
|
||||
OpenRouter Chat Completions API 호출
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
모델 응답 텍스트
|
||||
"""
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
payload = {
|
||||
"model": self.model_id,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
|
||||
last_err: Optional[Exception] = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
r = self.sess.post(
|
||||
url,
|
||||
headers=self._build_headers(),
|
||||
json=payload,
|
||||
timeout=self.timeout
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
# 응답 파싱
|
||||
choices = data.get("choices", [])
|
||||
if not choices:
|
||||
raise OpenRouterTranslatorError("API 응답에 choices가 없습니다.")
|
||||
|
||||
content = choices[0].get("message", {}).get("content", "")
|
||||
return content.strip()
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
last_err = e
|
||||
# 429 Rate Limit, 5xx 서버 에러 시 재시도
|
||||
if e.response is not None and e.response.status_code in (429, 500, 502, 503, 504):
|
||||
if attempt < self.max_retries:
|
||||
sleep_s = (self.backoff ** attempt) + random.uniform(0, 0.3)
|
||||
self.log.warning(f"[OpenRouterTranslator] POST {url} 실패({e}), 재시도 {attempt+1}/{self.max_retries} 대기 {sleep_s:.2f}s")
|
||||
time.sleep(sleep_s)
|
||||
continue
|
||||
raise OpenRouterTranslatorError(f"HTTP 에러: {e}")
|
||||
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < self.max_retries:
|
||||
sleep_s = (self.backoff ** attempt) + random.uniform(0, 0.2)
|
||||
self.log.warning(f"[OpenRouterTranslator] POST {url} 실패({e}), 재시도 {attempt+1}/{self.max_retries} 대기 {sleep_s:.2f}s")
|
||||
time.sleep(sleep_s)
|
||||
else:
|
||||
break
|
||||
|
||||
raise OpenRouterTranslatorError(f"POST {url} 실패: {last_err}")
|
||||
|
||||
# -----------------------------
|
||||
# 번역 프롬프트 생성
|
||||
# -----------------------------
|
||||
def _build_translation_prompt(
|
||||
self,
|
||||
product_name: str,
|
||||
category: str,
|
||||
items: List[Dict[str, Any]],
|
||||
steps: int = 1
|
||||
) -> str:
|
||||
"""번역 요청 프롬프트 생성"""
|
||||
|
||||
items_json = json.dumps(items, ensure_ascii=False, indent=2)
|
||||
|
||||
if steps == 1:
|
||||
# 직역 모드
|
||||
prompt = f"""당신은 중국어-한국어 전문 온라인 쇼핑마케팅 번역가입니다.
|
||||
|
||||
상품 정보:
|
||||
- 상품명: {product_name}
|
||||
- 카테고리: {category}
|
||||
|
||||
아래 JSON 배열의 각 항목에서 "source" 필드의 중국어 텍스트를 한국어로 번역해주세요.
|
||||
|
||||
번역 규칙:
|
||||
1. 정확하고 자연스러운 한국어로 번역
|
||||
2. 상품 문맥에 맞게 번역
|
||||
3. 브랜드명, 고유명사는 그대로 유지
|
||||
4. 숫자와 단위는 유지
|
||||
|
||||
입력:
|
||||
{items_json}
|
||||
|
||||
출력 형식 (JSON 배열만 출력, 다른 텍스트 없이):
|
||||
[
|
||||
{{"id": 1, "translation": "번역된 텍스트"}},
|
||||
{{"id": 2, "translation": "번역된 텍스트"}}
|
||||
]"""
|
||||
else:
|
||||
# 마케팅 톤 변환 모드 (steps == 2)
|
||||
# Step 2는 이미 한국어로 번역된 텍스트를 입력으로 받음
|
||||
prompt = f"""당신은 마케팅 카피라이터입니다.
|
||||
|
||||
상품 정보:
|
||||
- 상품명: {product_name}
|
||||
- 카테고리: {category}
|
||||
|
||||
아래 JSON 배열의 각 항목에서 "source" 필드의 한국어 텍스트를
|
||||
한국 소비자에게 매력적으로 느껴지도록 마케팅 톤으로 다듬어주세요.
|
||||
|
||||
변환 규칙:
|
||||
1. 원본의 의미는 정확히 유지
|
||||
2. 한국 소비자에게 친숙하고 자연스러운 표현으로 변경
|
||||
3. 상품의 장점을 부각하는 표현 사용
|
||||
4. 브랜드명, 고유명사는 그대로 유지
|
||||
5. 숫자와 단위는 유지
|
||||
6. 너무 과장되지 않게, 자연스럽게
|
||||
|
||||
입력:
|
||||
{items_json}
|
||||
|
||||
출력 형식 (JSON 배열만 출력, 다른 텍스트 없이):
|
||||
[
|
||||
{{"id": 1, "translation": "마케팅톤으로 다듬은 텍스트"}},
|
||||
{{"id": 2, "translation": "마케팅톤으로 다듬은 텍스트"}}
|
||||
]"""
|
||||
|
||||
return prompt
|
||||
|
||||
def _build_combined_translation_prompt(
|
||||
self,
|
||||
product_name: str,
|
||||
category: str,
|
||||
items: List[Dict[str, Any]]
|
||||
) -> str:
|
||||
"""
|
||||
1단계(직역)와 2단계(마케팅톤 변환)를 합친 프롬프트 생성
|
||||
|
||||
Args:
|
||||
product_name: 상품명
|
||||
category: 카테고리
|
||||
items: 번역할 항목 리스트 [{"id": 1, "source": "중국어 텍스트"}, ...]
|
||||
|
||||
Returns:
|
||||
통합 프롬프트 문자열
|
||||
"""
|
||||
items_json = json.dumps(items, ensure_ascii=False, indent=2)
|
||||
|
||||
prompt = f"""당신은 중국어-한국어 전문 번역가이자 마케팅 카피라이터입니다.
|
||||
|
||||
상품 정보:
|
||||
- 상품명: {product_name}
|
||||
- 카테고리: {category}
|
||||
|
||||
아래 JSON 배열의 각 항목에서 "source" 필드의 중국어 텍스트를 다음 두 단계로 처리해주세요:
|
||||
|
||||
1단계: 정확하고 자연스러운 한국어로 번역
|
||||
2단계: 한국 소비자에게 매력적으로 느껴지도록 마케팅 톤으로 다듬기
|
||||
|
||||
처리 규칙:
|
||||
1. 정확한 의미 전달 (1단계)
|
||||
2. 상품 문맥에 맞게 번역 (1단계)
|
||||
3. 브랜드명, 고유명사는 그대로 유지
|
||||
4. 숫자와 단위는 유지
|
||||
5. 한국 소비자에게 친숙하고 자연스러운 표현 사용 (2단계)
|
||||
6. 상품의 장점을 부각하는 표현 (2단계)
|
||||
7. 너무 과장되지 않게, 자연스럽게 (2단계)
|
||||
|
||||
입력:
|
||||
{items_json}
|
||||
|
||||
출력 형식 (JSON 배열만 출력, 다른 텍스트 없이):
|
||||
[
|
||||
{{"id": 1, "translation": "마케팅톤으로 다듬은 한국어 번역"}},
|
||||
{{"id": 2, "translation": "마케팅톤으로 다듬은 한국어 번역"}}
|
||||
]"""
|
||||
|
||||
return prompt
|
||||
|
||||
# -----------------------------
|
||||
# 응답 파싱
|
||||
# -----------------------------
|
||||
def _parse_translation_response(self, response_text: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
모델 응답에서 번역 결과 JSON 파싱
|
||||
|
||||
Returns
|
||||
-------
|
||||
List[Dict]
|
||||
[{"id": 1, "translation": "..."}, ...]
|
||||
"""
|
||||
clean_str = response_text.strip()
|
||||
|
||||
# Markdown 코드 블록 제거
|
||||
if clean_str.startswith("```"):
|
||||
# 첫 줄 제거 (```json 등)
|
||||
parts = clean_str.split('\n', 1)
|
||||
if len(parts) > 1:
|
||||
clean_str = parts[1]
|
||||
else:
|
||||
clean_str = clean_str.replace("```", "")
|
||||
|
||||
if clean_str.endswith("```"):
|
||||
clean_str = clean_str[:-3]
|
||||
|
||||
clean_str = clean_str.strip()
|
||||
|
||||
try:
|
||||
result = json.loads(clean_str)
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
elif isinstance(result, dict):
|
||||
return [result]
|
||||
else:
|
||||
raise OpenRouterTranslatorError(f"예상치 못한 응답 타입: {type(result)}")
|
||||
except json.JSONDecodeError as e:
|
||||
self.log.error(f"[OpenRouterTranslator] JSON 파싱 실패: {e}\n응답: {response_text[:500]}...")
|
||||
raise OpenRouterTranslatorError(f"JSON 파싱 실패: {e}")
|
||||
|
||||
# -----------------------------
|
||||
# 공개 API (GemmaTranslator 호환)
|
||||
# -----------------------------
|
||||
def translate_ocr_texts(
|
||||
self,
|
||||
product_name: str,
|
||||
category: str,
|
||||
ocr_results: List[Dict[str, Any]],
|
||||
batch_size: int = 16,
|
||||
) -> List[str]:
|
||||
"""
|
||||
OCR 결과 번역 (GemmaTranslator.translate_ocr_texts 호환)
|
||||
|
||||
입력: OCR 결과 리스트(각 항목에 최소 'text' 키 필요)
|
||||
출력: 원래 순서 유지한 ko 문자열 리스트
|
||||
"""
|
||||
if not ocr_results:
|
||||
return []
|
||||
|
||||
# 유효성 검증
|
||||
if not product_name or len(product_name.strip()) < 1:
|
||||
raise OpenRouterTranslatorError("product_name은 1자 이상이어야 합니다.")
|
||||
if not category or len(category.strip()) < 1:
|
||||
raise OpenRouterTranslatorError("category는 1자 이상이어야 합니다.")
|
||||
|
||||
# id 부여 및 source 필터링
|
||||
items = []
|
||||
source_to_orig_idx = {}
|
||||
for i, d in enumerate(ocr_results):
|
||||
source = (d.get("text") or "").strip()
|
||||
if len(source) >= 1:
|
||||
item_id = len(items) + 1
|
||||
items.append({"id": item_id, "source": source})
|
||||
source_to_orig_idx[item_id] = i
|
||||
|
||||
if not items:
|
||||
return [""] * len(ocr_results)
|
||||
|
||||
# 배치 처리
|
||||
out_ko = [""] * len(ocr_results)
|
||||
for i in range(0, len(items), batch_size):
|
||||
chunk = items[i : i + batch_size]
|
||||
|
||||
prompt = self._build_translation_prompt(product_name, category, chunk, steps=1)
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
|
||||
try:
|
||||
response_text = self._post_chat(messages)
|
||||
translated_items = self._parse_translation_response(response_text)
|
||||
|
||||
for item in translated_items:
|
||||
item_id = int(item.get("id", 0))
|
||||
translation = item.get("translation", "")
|
||||
|
||||
orig_idx = source_to_orig_idx.get(item_id)
|
||||
if orig_idx is not None:
|
||||
out_ko[orig_idx] = str(translation).strip() if translation else ""
|
||||
|
||||
except Exception as e:
|
||||
self.log.error(f"[OpenRouterTranslator] 배치 번역 실패: {e}")
|
||||
# 실패한 배치는 빈 문자열로 유지
|
||||
continue
|
||||
|
||||
return out_ko
|
||||
|
||||
def run_llm_translation(
|
||||
self,
|
||||
product_name: str,
|
||||
category: str,
|
||||
ocr_results: List[Dict[str, Any]],
|
||||
job_type: str = "ocr_translator_step1",
|
||||
prompt_name: str = "ocr_translator_step1",
|
||||
retry_count: int = 3,
|
||||
steps: int = 1
|
||||
) -> List[str]:
|
||||
"""
|
||||
LLM 번역 (GemmaTranslator.run_llm_translation 호환)
|
||||
|
||||
Args:
|
||||
product_name: 상품명
|
||||
category: 카테고리
|
||||
ocr_results: OCR 결과 리스트
|
||||
job_type: 작업 타입 (호환성용, 사용 안함)
|
||||
prompt_name: 프롬프트 이름 (호환성용, 사용 안함)
|
||||
retry_count: 재시도 횟수
|
||||
steps: 번역 단계
|
||||
1=직역만 (1회 API 호출)
|
||||
2=직역 후 마케팅톤 변환 (2회 API 호출: step1 직역 → step2 마케팅톤 변환)
|
||||
"""
|
||||
if not ocr_results:
|
||||
return []
|
||||
|
||||
# items 구성
|
||||
items = []
|
||||
source_to_orig_idx = {}
|
||||
for i, res in enumerate(ocr_results):
|
||||
text = (res.get("text") or "").strip()
|
||||
if text:
|
||||
item_id = len(items) + 1
|
||||
items.append({"id": item_id, "source": text})
|
||||
source_to_orig_idx[item_id] = i
|
||||
|
||||
if not items:
|
||||
return [""] * len(ocr_results)
|
||||
|
||||
# steps 검증
|
||||
steps = max(1, min(2, steps))
|
||||
|
||||
# 임시로 max_retries 조정
|
||||
original_retries = self.max_retries
|
||||
self.max_retries = max(retry_count - 1, 0)
|
||||
|
||||
try:
|
||||
# Step 1: 직역
|
||||
prompt_step1 = self._build_translation_prompt(product_name, category, items, steps=1)
|
||||
messages_step1 = [{"role": "user", "content": prompt_step1}]
|
||||
|
||||
self.log.info(f"[OpenRouterTranslator] Step 1: 직역 시작")
|
||||
response_text_step1 = self._post_chat(messages_step1)
|
||||
translated_items_step1 = self._parse_translation_response(response_text_step1)
|
||||
|
||||
# Step 1 결과를 리스트로 변환
|
||||
step1_results = [""] * len(ocr_results)
|
||||
for item in translated_items_step1:
|
||||
item_id = int(item.get("id", 0))
|
||||
|
||||
# 결과 필드 찾기 (translation > result > translated)
|
||||
res_text = ""
|
||||
if "translation" in item:
|
||||
res_text = item["translation"]
|
||||
elif "result" in item:
|
||||
res_text = item["result"]
|
||||
elif "translated" in item:
|
||||
res_text = item["translated"]
|
||||
|
||||
if res_text is None:
|
||||
res_text = ""
|
||||
|
||||
orig_idx = source_to_orig_idx.get(item_id)
|
||||
if orig_idx is not None:
|
||||
step1_results[orig_idx] = str(res_text).strip()
|
||||
|
||||
# Step 1만 필요한 경우
|
||||
if steps == 1:
|
||||
return step1_results
|
||||
|
||||
# Step 2: 마케팅톤 변환 (Step 1 결과를 입력으로 사용)
|
||||
self.log.info(f"[OpenRouterTranslator] Step 2: 마케팅톤 변환 시작")
|
||||
|
||||
# Step 1 결과를 items 형태로 변환
|
||||
items_step2 = []
|
||||
for i, translated_text in enumerate(step1_results, 1):
|
||||
if translated_text: # 빈 문자열이 아닌 경우만
|
||||
items_step2.append({"id": i, "source": translated_text})
|
||||
|
||||
if not items_step2:
|
||||
return step1_results
|
||||
|
||||
prompt_step2 = self._build_translation_prompt(product_name, category, items_step2, steps=2)
|
||||
messages_step2 = [{"role": "user", "content": prompt_step2}]
|
||||
|
||||
response_text_step2 = self._post_chat(messages_step2)
|
||||
translated_items_step2 = self._parse_translation_response(response_text_step2)
|
||||
|
||||
# Step 2 결과를 최종 결과로 변환
|
||||
out_ko = [""] * len(ocr_results)
|
||||
for item in translated_items_step2:
|
||||
item_id = int(item.get("id", 0))
|
||||
|
||||
# 결과 필드 찾기 (translation > result > translated)
|
||||
res_text = ""
|
||||
if "translation" in item:
|
||||
res_text = item["translation"]
|
||||
elif "result" in item:
|
||||
res_text = item["result"]
|
||||
elif "translated" in item:
|
||||
res_text = item["translated"]
|
||||
|
||||
if res_text is None:
|
||||
res_text = ""
|
||||
|
||||
# item_id는 step2의 items 인덱스이므로, 원본 인덱스로 매핑
|
||||
# step2 items는 step1 결과의 순서를 유지하므로, item_id - 1이 원본 인덱스
|
||||
if 1 <= item_id <= len(step1_results):
|
||||
orig_idx = item_id - 1
|
||||
# 원본에서 실제로 번역된 항목인지 확인
|
||||
if orig_idx < len(ocr_results):
|
||||
out_ko[orig_idx] = str(res_text).strip()
|
||||
|
||||
return out_ko
|
||||
|
||||
except Exception as e:
|
||||
self.log.error(f"[OpenRouterTranslator] run_llm_translation 실패: {e}")
|
||||
raise e
|
||||
finally:
|
||||
self.max_retries = original_retries
|
||||
|
||||
def run_combined_llm_translation(
|
||||
self,
|
||||
product_name: str,
|
||||
category: str,
|
||||
ocr_results: List[Dict[str, Any]],
|
||||
retry_count: int = 3
|
||||
) -> List[str]:
|
||||
"""
|
||||
통합 프롬프트를 사용한 LLM 번역 (직역 + 마케팅톤 변환을 한 번에 수행)
|
||||
|
||||
Args:
|
||||
product_name: 상품명
|
||||
category: 카테고리
|
||||
ocr_results: OCR 결과 리스트
|
||||
retry_count: 재시도 횟수
|
||||
|
||||
Returns:
|
||||
번역된 문자열 리스트 (원래 순서 유지)
|
||||
|
||||
Note:
|
||||
이 메서드는 1단계(직역)와 2단계(마케팅톤 변환)를 한 번의 API 호출로 수행합니다.
|
||||
run_llm_translation(steps=2)와 달리 2회의 API 호출이 아닌 1회만 호출합니다.
|
||||
"""
|
||||
if not ocr_results:
|
||||
return []
|
||||
|
||||
# items 구성
|
||||
items = []
|
||||
source_to_orig_idx = {}
|
||||
for i, res in enumerate(ocr_results):
|
||||
text = (res.get("text") or "").strip()
|
||||
if text:
|
||||
item_id = len(items) + 1
|
||||
items.append({"id": item_id, "source": text})
|
||||
source_to_orig_idx[item_id] = i
|
||||
|
||||
if not items:
|
||||
return [""] * len(ocr_results)
|
||||
|
||||
# 임시로 max_retries 조정
|
||||
original_retries = self.max_retries
|
||||
self.max_retries = max(retry_count - 1, 0)
|
||||
|
||||
try:
|
||||
# 통합 프롬프트 사용 (직역 + 마케팅톤 변환)
|
||||
prompt = self._build_combined_translation_prompt(product_name, category, items)
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
|
||||
self.log.info(f"[OpenRouterTranslator] 통합 번역 시작 (직역 + 마케팅톤 변환)")
|
||||
response_text = self._post_chat(messages)
|
||||
translated_items = self._parse_translation_response(response_text)
|
||||
|
||||
# 결과를 리스트로 변환
|
||||
out_ko = [""] * len(ocr_results)
|
||||
for item in translated_items:
|
||||
item_id = int(item.get("id", 0))
|
||||
|
||||
# 결과 필드 찾기 (translation > result > translated)
|
||||
res_text = ""
|
||||
if "translation" in item:
|
||||
res_text = item["translation"]
|
||||
elif "result" in item:
|
||||
res_text = item["result"]
|
||||
elif "translated" in item:
|
||||
res_text = item["translated"]
|
||||
|
||||
if res_text is None:
|
||||
res_text = ""
|
||||
|
||||
orig_idx = source_to_orig_idx.get(item_id)
|
||||
if orig_idx is not None:
|
||||
out_ko[orig_idx] = str(res_text).strip()
|
||||
|
||||
return out_ko
|
||||
|
||||
except Exception as e:
|
||||
self.log.error(f"[OpenRouterTranslator] run_combined_llm_translation 실패: {e}")
|
||||
raise e
|
||||
finally:
|
||||
self.max_retries = original_retries
|
||||
|
||||
def batch_translate_texts(
|
||||
self,
|
||||
product_name: str,
|
||||
category: str,
|
||||
text_list: List[str],
|
||||
delimiter: str = " / ",
|
||||
batch_size: int = 8,
|
||||
) -> List[str]:
|
||||
"""
|
||||
순수 텍스트 리스트 번역 (GemmaTranslator.batch_translate_texts 호환)
|
||||
"""
|
||||
if not text_list:
|
||||
return []
|
||||
|
||||
# text_list를 ocr_results 형태로 변환
|
||||
ocr_results = [{"text": t} for t in text_list]
|
||||
return self.translate_ocr_texts(product_name, category, ocr_results, batch_size)
|
||||
|
||||
def translate_option_groups(
|
||||
self,
|
||||
product_name: str,
|
||||
category: str,
|
||||
option_groups: List[Dict[str, Any]],
|
||||
batch_size: int = 8,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
옵션 그룹 번역 (GemmaTranslator.translate_option_groups 호환)
|
||||
|
||||
option_groups 예:
|
||||
[{"id": 1, "source": ["红色","蓝色"]}, {"id": 2, "source": ["小号","大号"]}]
|
||||
반환:
|
||||
[{"id": 1, "translations": ["빨강","파랑"]}, {"id": 2, "translations": ["소형","대형"]}]
|
||||
"""
|
||||
if not option_groups:
|
||||
return []
|
||||
|
||||
results = []
|
||||
for group in option_groups:
|
||||
group_id = group.get("id")
|
||||
sources = group.get("source", [])
|
||||
|
||||
if not sources:
|
||||
results.append({"id": group_id, "translations": []})
|
||||
continue
|
||||
|
||||
# 각 옵션을 번역
|
||||
ocr_results = [{"text": s} for s in sources]
|
||||
translations = self.translate_ocr_texts(product_name, category, ocr_results, batch_size)
|
||||
|
||||
results.append({"id": group_id, "translations": translations})
|
||||
|
||||
return results
|
||||
|
||||
# -----------------------------
|
||||
# 유틸리티
|
||||
# -----------------------------
|
||||
def health(self) -> _JSON:
|
||||
"""API 상태 확인 (간단한 테스트 요청)"""
|
||||
try:
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
self._post_chat(messages)
|
||||
return {"status": "ok", "model": self.model_id}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
def get_credits(self) -> _JSON:
|
||||
"""OpenRouter 크레딧 잔액 조회"""
|
||||
url = f"{self.base_url}/credits"
|
||||
try:
|
||||
r = self.sess.get(url, headers=self._build_headers(), timeout=10)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
self.log.error(f"[OpenRouterTranslator] 크레딧 조회 실패: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
|
|
@ -83,7 +83,7 @@ class Request_AI_Server:
|
|||
|
||||
return None
|
||||
|
||||
def request_external_inpaint(self, image: np.ndarray, mask: np.ndarray, server_url: str, invert_mask: bool = False, model_name: str = "simple-lama") -> np.ndarray:
|
||||
def request_external_inpaint(self, image: np.ndarray, mask: np.ndarray, server_url: str, invert_mask: bool = False, model_name: str = "migan") -> np.ndarray:
|
||||
"""외부 IOPaint 서버를 이용한 인페인팅 요청 (VIP용)
|
||||
|
||||
Args:
|
||||
|
|
@ -169,7 +169,7 @@ class Request_AI_Server:
|
|||
# 요청 파라미터 명시: 이 서버는 Accept 에 따라 응답이 달라질 수 있으므로 쿼리로 고정
|
||||
params = {
|
||||
"response_format": "binary",
|
||||
"image_format": "png",
|
||||
"image_format": "webp", # webp 사용 (png 대비 ~90% 용량 절약)
|
||||
}
|
||||
|
||||
self.logger.log(f"외부 인페인팅 서버 요청: {api_url}, model={model_name}", level=logging.DEBUG)
|
||||
|
|
@ -179,7 +179,7 @@ class Request_AI_Server:
|
|||
self.logger.log(f"외부 인페인팅 서버 에러: {response.status_code} {response.text}", level=logging.WARNING)
|
||||
return None
|
||||
|
||||
# 응답이 바이너리 PNG 이미지이므로 바로 디코딩
|
||||
# 응답이 바이너리 webp 이미지이므로 바로 디코딩 (cv2.imdecode는 webp도 지원)
|
||||
nparr = np.frombuffer(response.content, np.uint8)
|
||||
result = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||
|
||||
|
|
@ -822,12 +822,12 @@ class Request_AI_Server:
|
|||
return None
|
||||
|
||||
def is_server_alive(self, base_url: str, timeout: int = 3) -> bool:
|
||||
"""서버 헬스체크(현재는 사용 안 함). base_url이 비어있으면 False."""
|
||||
"""서버 헬스체크. base_url이 비어있으면 False."""
|
||||
try:
|
||||
if not base_url:
|
||||
return False
|
||||
model_url = base_url.rstrip('/') + '/api/v1/model'
|
||||
response = requests.get(model_url, timeout=timeout)
|
||||
health_url = base_url.rstrip('/') + '/health'
|
||||
response = requests.get(health_url, timeout=timeout)
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
self.logger.log(f"서버 상태 확인 실패 ({base_url}): {e}", level=logging.WARNING)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,36 @@
|
|||
[2025-12-01 23:38:43,542] [MainThread] [DEBUG] [image_processor3.py:__init__:58] tracemalloc 메모리 추적 시작
|
||||
[2025-12-01 23:38:43,549] [MainThread] [DEBUG] [gpu_utils.py:_setup_directml_environment:55] ✅ DirectML 환경 준비 완료 (Windows DirectX 12 기반)
|
||||
[2025-12-01 23:38:43,549] [MainThread] [DEBUG] [gpu_utils.py:initialize_gpu_state:79] === 🚀 DirectML GPU 상태 초기화 시작 🚀 ===
|
||||
[2025-12-01 23:38:43,550] [MainThread] [DEBUG] [gpu_utils.py:initialize_gpu_state:80] 🎯 사용자 GPU 가속 요청: False
|
||||
[2025-12-01 23:38:43,550] [MainThread] [DEBUG] [gpu_utils.py:initialize_gpu_state:81] 💻 현재 운영체제: Windows
|
||||
[2025-12-01 23:38:43,550] [MainThread] [DEBUG] [gpu_utils.py:initialize_gpu_state:84] GPU 가속이 비활성화됨 (toggle_states['use_cuda'] = False)
|
||||
[2025-12-01 23:38:43,550] [MainThread] [INFO] [gpu_utils.py:_set_safe_cpu_mode:157] 🔒 안전한 CPU 모드로 모든 GPU 설정 강제 비활성화
|
||||
[2025-12-01 23:38:43,550] [MainThread] [DEBUG] [image_processor3.py:__init__:80] 🔧 ImageProcessor3 GPU 상태 요약:
|
||||
[2025-12-01 23:38:43,551] [MainThread] [DEBUG] [image_processor3.py:__init__:81] - CUDA 사용 가능: False
|
||||
[2025-12-01 23:38:43,551] [MainThread] [DEBUG] [image_processor3.py:__init__:82] - toggle_states['use_cuda']: False
|
||||
[2025-12-01 23:38:43,551] [MainThread] [DEBUG] [image_processor3.py:__init__:83] - GPU 하드웨어 정보: {}
|
||||
[2025-12-01 23:38:43,551] [MainThread] [DEBUG] [image_processor3.py:__init__:85] ImageProcessor3 Init toggle_states: {'font_type': '폰트8', 'image_font_path': 'D:\\py\\img_worker\\modules\\fonts\\Pretendard-Regular.ttf', 'ocr': True, 'force_cpu_ocr': True, 'use_cuda': False, 'inpaint_model': 'migan', 'local_inpaint_method': 'migan', 'migan_onnx_path': 'D:\\py\\img_worker\\modules\\migan_onnx\\migan_pipeline_v2.onnx', 'migan_use_accel': False, 'migan_provider_override': 'cpu', 'detail_IMGTrans_type': 'CPU', 'optionIMGTrans_type': 'CPU', 'thumb_trans_type': 'CPU', 'TEMP_IMAGE_DIR': 'D:\\py\\img_worker\\modules\\test', 'output_image_format': 'jpg', 'watermark_toggle': False, 'store_ocr_data_to_db': False, 'ocr_engine': 'onnx'}
|
||||
[2025-12-01 23:38:43,551] [MainThread] [DEBUG] [image_processor3.py:__init__:88] is_member_valid: True
|
||||
[2025-12-01 23:38:43,552] [MainThread] [DEBUG] [image_processor3.py:__init__:110] debug_images 디렉토리 이미 존재: D:\py\img_worker\modules\debug_images
|
||||
[2025-12-01 23:38:43,552] [MainThread] [DEBUG] [image_processor3.py:__init__:118] self.font_path: D:\py\img_worker\modules\fonts\Pretendard-Regular.ttf
|
||||
[2025-12-01 23:38:43,552] [MainThread] [DEBUG] [image_processor3.py:__init__:120] toggle_states font_path: D:\py\img_worker\modules\fonts\Pretendard-Regular.ttf
|
||||
[2025-12-01 23:38:43,552] [MainThread] [DEBUG] [image_processor3.py:__init__:122] self.TEMP_IMAGE_DIR: D:\py\img_worker\modules\test
|
||||
[2025-12-01 23:38:43,552] [MainThread] [DEBUG] [image_processor3.py:__init__:124] self.debugging_save_Dir: D:\py\img_worker\modules\debug_images
|
||||
[2025-12-01 23:38:43,553] [MainThread] [DEBUG] [image_processor3.py:__init__:126] self.unwanted_texts: {}
|
||||
[2025-12-01 23:38:43,553] [MainThread] [DEBUG] [image_processor3.py:__init__:128] self.inpaint_method: migan
|
||||
[2025-12-01 23:38:43,553] [MainThread] [DEBUG] [image_processor3.py:__init__:134] Image.MAX_IMAGE_PIXELS set to 20000000
|
||||
[2025-12-01 23:38:43,553] [MainThread] [DEBUG] [onnx_ocr_wrapper.py:_determine_model_type:447] ONNX 모델 타입 설정값: 자동 선택, GPU 정보: {}
|
||||
[2025-12-01 23:38:43,554] [MainThread] [INFO] [onnx_ocr_wrapper.py:_determine_model_type:453] 자동 선택 모드: GPU 추천 모델 simp 사용
|
||||
[2025-12-01 23:38:43,554] [MainThread] [INFO] [onnx_ocr_wrapper.py:__init__:324] ONNX OCR 모델 타입 결정: simp (GPU: False)
|
||||
[2025-12-01 23:38:43,554] [MainThread] [INFO] [onnx_ocr_wrapper.py:__init__:333] ONNX OCR 모듈 CPU 모드로 설정
|
||||
[2025-12-01 23:38:43,554] [MainThread] [INFO] [onnx_ocr_wrapper.py:__init__:356] 🚀 ONNX TextSystem 초기화 시작 (CPU 모드)
|
||||
[2025-12-01 23:38:43,554] [MainThread] [DEBUG] [onnx_ocr_wrapper.py:_initialize_onnx_system:539] 🚀 ONNX TextSystem 초기화 시작 (CPU 모드)
|
||||
[2025-12-01 23:38:44,754] [MainThread] [DEBUG] [image_processor3.py:cleanup:450] OCR 모듈 정리 완료
|
||||
[2025-12-01 23:38:44,754] [MainThread] [DEBUG] [image_processor3.py:cleanup:458] 마스크 모듈 정리 완료
|
||||
[2025-12-01 23:38:44,760] [MainThread] [ERROR] [image_processor3.py:cleanup:496] 리소스 정리 중 오류: sys.meta_path is None, Python is likely shutting down
|
||||
Traceback (most recent call last):
|
||||
File "D:\py\img_worker\modules\image_processor3.py", line 481, in cleanup
|
||||
import gc
|
||||
ImportError: sys.meta_path is None, Python is likely shutting down
|
||||
|
||||
[2025-12-01 23:38:44,760] [MainThread] [DEBUG] [image_processor3.py:__del__:441] 이미지 프로세서 소멸
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -32,7 +32,8 @@ async def main():
|
|||
|
||||
font_file_name = "Pretendard-Regular.ttf"
|
||||
font_path = os.path.join(base_dir, "fonts", font_file_name)
|
||||
original_image_path = os.path.join(base_dir, "fonts", "ori.jpg")
|
||||
# original_image_path = os.path.join(base_dir, "fonts", "ori.jpg")
|
||||
original_image_path = os.path.join(base_dir, "test", "2.jpg")
|
||||
|
||||
# 결과 저장 경로: modules/test
|
||||
preview_output_dir = os.path.join(base_dir, "test")
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 455 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 455 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 455 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
|
|
@ -0,0 +1,169 @@
|
|||
"""외부 인페인팅 서버 테스트 스크립트"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import requests
|
||||
import base64
|
||||
|
||||
# 테스트 서버 URL
|
||||
SERVER_URL = "http://192.168.0.146:8008"
|
||||
|
||||
def test_health_check():
|
||||
"""서버 상태 확인"""
|
||||
print("=" * 50)
|
||||
print("1. 서버 상태 확인 (Health Check)")
|
||||
print("=" * 50)
|
||||
try:
|
||||
response = requests.get(f"{SERVER_URL}/health", timeout=5)
|
||||
print(f"Status: {response.status_code}")
|
||||
print(f"Response: {response.json()}")
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return False
|
||||
|
||||
def test_inpaint_api(model_name="migan"):
|
||||
"""인페인팅 API 테스트"""
|
||||
print("=" * 50)
|
||||
print(f"2. 인페인팅 API 테스트 (model: {model_name})")
|
||||
print("=" * 50)
|
||||
|
||||
# 테스트 이미지 로드
|
||||
test_image_path = os.path.join(os.path.dirname(__file__), "1.jpg")
|
||||
if not os.path.exists(test_image_path):
|
||||
print(f"테스트 이미지 없음: {test_image_path}")
|
||||
return False
|
||||
|
||||
image = cv2.imread(test_image_path)
|
||||
print(f"이미지 크기: {image.shape}")
|
||||
|
||||
# 간단한 테스트 마스크 생성 (중앙에 사각형)
|
||||
h, w = image.shape[:2]
|
||||
mask = np.zeros((h, w), dtype=np.uint8)
|
||||
# 중앙에 100x100 마스크 영역
|
||||
cx, cy = w // 2, h // 2
|
||||
mask[cy-50:cy+50, cx-50:cx+50] = 255
|
||||
print(f"마스크 크기: {mask.shape}, 마스크 영역: 100x100 중앙")
|
||||
|
||||
# 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')
|
||||
|
||||
# API 요청
|
||||
api_url = f"{SERVER_URL}/api/v1/inpaint"
|
||||
params = {
|
||||
"response_format": "binary",
|
||||
"image_format": "webp",
|
||||
}
|
||||
payload = {
|
||||
"image": img_b64,
|
||||
"mask": mask_b64,
|
||||
"model_name": model_name
|
||||
}
|
||||
|
||||
print(f"요청 URL: {api_url}")
|
||||
print(f"파라미터: {params}")
|
||||
print(f"모델: {model_name}")
|
||||
|
||||
try:
|
||||
response = requests.post(api_url, params=params, json=payload, timeout=(5, 60))
|
||||
print(f"Status: {response.status_code}")
|
||||
print(f"Content-Type: {response.headers.get('content-type', 'N/A')}")
|
||||
print(f"응답 크기: {len(response.content)} bytes")
|
||||
|
||||
if response.status_code == 200:
|
||||
# 이미지 디코딩 테스트
|
||||
nparr = np.frombuffer(response.content, np.uint8)
|
||||
result = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||
|
||||
if result is not None:
|
||||
print(f"✅ 성공! 결과 이미지 크기: {result.shape}")
|
||||
|
||||
# 결과 저장
|
||||
output_path = os.path.join(os.path.dirname(__file__), f"outputs_test/inpaint_result_{model_name}.webp")
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
cv2.imwrite(output_path, result)
|
||||
print(f"결과 저장: {output_path}")
|
||||
return True
|
||||
else:
|
||||
print("❌ 이미지 디코딩 실패")
|
||||
return False
|
||||
else:
|
||||
print(f"❌ 서버 에러: {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 요청 실패: {e}")
|
||||
return False
|
||||
|
||||
def test_with_request_ai_server():
|
||||
"""Request_AI_Server 클래스를 통한 테스트"""
|
||||
print("=" * 50)
|
||||
print("3. Request_AI_Server 클래스 테스트")
|
||||
print("=" * 50)
|
||||
|
||||
from loggerModule import Logger
|
||||
from modules.request_inpaint import Request_AI_Server
|
||||
|
||||
logger = Logger()
|
||||
server = Request_AI_Server(logger)
|
||||
|
||||
# 테스트 이미지 로드
|
||||
test_image_path = os.path.join(os.path.dirname(__file__), "1.jpg")
|
||||
image = cv2.imread(test_image_path)
|
||||
|
||||
# 마스크 생성
|
||||
h, w = image.shape[:2]
|
||||
mask = np.zeros((h, w), dtype=np.uint8)
|
||||
cx, cy = w // 2, h // 2
|
||||
mask[cy-50:cy+50, cx-50:cx+50] = 255
|
||||
|
||||
# 외부 인페인팅 요청
|
||||
print(f"서버 URL: {SERVER_URL}")
|
||||
result = server.request_external_inpaint(
|
||||
image=image,
|
||||
mask=mask,
|
||||
server_url=SERVER_URL,
|
||||
model_name="migan"
|
||||
)
|
||||
|
||||
if result is not None:
|
||||
print(f"✅ 성공! 결과 이미지 크기: {result.shape}")
|
||||
output_path = os.path.join(os.path.dirname(__file__), "outputs_test/inpaint_result_class_test.webp")
|
||||
cv2.imwrite(output_path, result)
|
||||
print(f"결과 저장: {output_path}")
|
||||
return True
|
||||
else:
|
||||
print("❌ 인페인팅 실패")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("\n🔧 외부 인페인팅 서버 테스트 시작\n")
|
||||
|
||||
# 1. 서버 상태 확인
|
||||
if not test_health_check():
|
||||
print("\n❌ 서버에 연결할 수 없습니다. 테스트 중단.")
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
|
||||
# 2. migan 모델 테스트
|
||||
test_inpaint_api("migan")
|
||||
|
||||
print()
|
||||
|
||||
# 3. simple-lama 모델 테스트
|
||||
test_inpaint_api("simple-lama")
|
||||
|
||||
print()
|
||||
|
||||
# 4. Request_AI_Server 클래스 테스트
|
||||
test_with_request_ai_server()
|
||||
|
||||
print("\n🎉 테스트 완료!")
|
||||
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import sys
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
import shutil
|
||||
|
||||
# 루트 디렉토리 설정 (img_worker/)
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
# modules/test/.. -> modules/.. -> img_worker/
|
||||
root_dir = os.path.abspath(os.path.join(current_dir, "../../"))
|
||||
sys.path.append(root_dir)
|
||||
|
||||
from modules.image_processor3 import ImageProcessor3
|
||||
from loggerModule import Logger
|
||||
|
||||
# 더미 클래스 정의
|
||||
class MockPage:
|
||||
pass
|
||||
|
||||
class MockTranslator:
|
||||
async def translate(self, text, source_lang, target_lang):
|
||||
return text
|
||||
|
||||
async def main():
|
||||
# 1. 로거 초기화
|
||||
logger = Logger()
|
||||
|
||||
# 2. 경로 설정
|
||||
base_dir = os.path.join(root_dir, "modules")
|
||||
|
||||
# 샘플 이미지 및 폰트 경로
|
||||
sample_image_name = "2.jpg"
|
||||
sample_image_path = os.path.join(current_dir, sample_image_name)
|
||||
font_path = os.path.join(base_dir, "fonts", "HakgyoansimDunggeunmisoTTFB.ttf")
|
||||
|
||||
if not os.path.exists(sample_image_path):
|
||||
logger.log(f"샘플 이미지가 없습니다: {sample_image_path}", level=logging.ERROR)
|
||||
# 1.jpg가 없다면 fonts 폴더의 ori.jpg를 복사해서 사용하거나 에러 처리
|
||||
ori_path = os.path.join(base_dir, "fonts", "ori.jpg")
|
||||
if os.path.exists(ori_path):
|
||||
logger.log(f"대체 이미지 사용: {ori_path}", level=logging.INFO)
|
||||
sample_image_path = ori_path
|
||||
else:
|
||||
return
|
||||
|
||||
# 3. Toggle States 설정
|
||||
toggle_states = {
|
||||
# LLM 번역 설정
|
||||
"translation_method": "llm", # LLM 번역 사용 설정
|
||||
"gemma_api_base_url": "https://inpaint.m1tcloud.cc",
|
||||
"gemma_api_timeout": 30,
|
||||
"request_inpainting_server_url": "https://inpaint.m1tcloud.cc",
|
||||
"product_name": "Test Product",
|
||||
"category": "Test Category",
|
||||
|
||||
# 기본 설정
|
||||
"font_type": "폰트1",
|
||||
"image_font_path": font_path,
|
||||
"ocr": True,
|
||||
"use_cuda": False,
|
||||
"force_cpu_ocr": True,
|
||||
"inpaint_model": "request", # 테스트 속도를 위해 가벼운 CV 모델 사용 (또는 'request'로 서버 사용 가능)
|
||||
"local_inpaint_method": "request",
|
||||
"TEMP_IMAGE_DIR": os.path.join(base_dir, "test", "temp"),
|
||||
"output_image_format": "jpg",
|
||||
"watermark_toggle": False,
|
||||
"store_ocr_data_to_db": False,
|
||||
"ocr_engine": "onnx",
|
||||
}
|
||||
|
||||
# 임시 디렉토리 생성
|
||||
if not os.path.exists(toggle_states["TEMP_IMAGE_DIR"]):
|
||||
os.makedirs(toggle_states["TEMP_IMAGE_DIR"], exist_ok=True)
|
||||
|
||||
# 4. ImageProcessor3 초기화
|
||||
print(">>> ImageProcessor3 초기화 중...")
|
||||
processor = None
|
||||
try:
|
||||
processor = ImageProcessor3(
|
||||
logger=logger,
|
||||
page=MockPage(),
|
||||
toggle_states=toggle_states,
|
||||
unwanted_words={},
|
||||
authenticated_by_admin=True,
|
||||
base_dir=base_dir,
|
||||
papago_translator=MockTranslator()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.log(f"프로세서 초기화 실패: {e}", level=logging.ERROR)
|
||||
return
|
||||
|
||||
# 5. 실제 이미지 처리 테스트 (OCR -> LLM 번역 -> 인페인팅 -> 텍스트 렌더링)
|
||||
print("\n>>> 실제 이미지 처리 및 LLM 번역 테스트 시작")
|
||||
print(f"대상 이미지: {sample_image_path}")
|
||||
print(f"설정된 번역 방식: {processor.toggle_states.get('translation_method')}")
|
||||
print(f"LLM API URL: {processor.toggle_states.get('gemma_api_base_url')}")
|
||||
|
||||
try:
|
||||
# process_single_image 호출
|
||||
file_prefix = "llm_test_result"
|
||||
|
||||
# 실제 처리 실행
|
||||
result = await processor.process_single_image(
|
||||
original_image_url=sample_image_path,
|
||||
index=0,
|
||||
delay=0,
|
||||
file_prefix=file_prefix
|
||||
)
|
||||
|
||||
if result['status'] in ['translated', 'inpainted', 'success']:
|
||||
print("\n[테스트 성공]")
|
||||
print(f"처리된 이미지 경로: {result['path']}")
|
||||
|
||||
# 결과 이미지가 실제로 존재하는지 확인
|
||||
if os.path.exists(result['path']):
|
||||
print(f"파일 확인됨: {result['path']}")
|
||||
else:
|
||||
print(f"경고: 결과 파일이 반환되었으나 디스크에 없음: {result['path']}")
|
||||
|
||||
else:
|
||||
print("\n[테스트 실패]")
|
||||
print(f"상태: {result.get('status')}")
|
||||
print(f"메시지: {result.get('error') or result.get('message')}")
|
||||
|
||||
except Exception as e:
|
||||
logger.log(f"테스트 중 치명적 오류 발생: {e}", level=logging.ERROR)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
# 리소스 정리
|
||||
if processor:
|
||||
processor.cleanup()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 159 KiB |
|
|
@ -0,0 +1,92 @@
|
|||
import os
|
||||
import json
|
||||
from groq import Groq
|
||||
|
||||
# 1. API 키 설정 (직접 문자열로 넣거나 환경변수 사용)
|
||||
# 발급받은 키를 "gsk_..." 부분에 넣으세요.
|
||||
client = Groq(
|
||||
api_key="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"
|
||||
)
|
||||
|
||||
# 2. 테스트할 OCR 결과 데이터 (샘플)
|
||||
SAMPLE_1 = [
|
||||
{'text': '高密【拉毛布】', 'confidence': 0.8697245717048645},
|
||||
{'text': '柔中带韧不易坏', 'confidence': 0.9874316453933716},
|
||||
{'text': '安静无声助力深度睡眠', 'confidence': 0.9960897564888},
|
||||
{'text': 'newpet家的', 'confidence': 0.9988763928413391},
|
||||
{'text': '别人家的', 'confidence': 0.9997605085372925},
|
||||
{'text': '密织拉毛布保暖还结实', 'confidence': 0.9967950582504272},
|
||||
{'text': '劣质无纺布一拉就烂一洗就散', 'confidence': 0.9917935729026794},
|
||||
]
|
||||
|
||||
# OCR 결과를 번역용 items 형태로 변환
|
||||
items = []
|
||||
for i, ocr_item in enumerate(SAMPLE_1, 1):
|
||||
text = (ocr_item.get("text") or "").strip()
|
||||
if text:
|
||||
items.append({"id": i, "source": text})
|
||||
|
||||
items_json = json.dumps(items, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"--- [모델: llama-3.1-8b-instant] 테스트 시작 ---")
|
||||
print(f"입력 항목 수: {len(items)}")
|
||||
|
||||
try:
|
||||
completion = client.chat.completions.create(
|
||||
# 스크린샷에 있던 그 모델입니다. (속도 매우 빠름)
|
||||
model="llama-3.1-8b-instant",
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"당신은 중국어-한국어 전문 온라인 쇼핑마케팅 번역가입니다.\n\n"
|
||||
"아래 JSON 배열의 각 항목에서 \"source\" 필드의 중국어 텍스트를 한국어로 번역해주세요.\n\n"
|
||||
"번역 규칙:\n"
|
||||
"1. 정확하고 자연스러운 한국어로 번역\n"
|
||||
"2. 상품 문맥에 맞게 번역\n"
|
||||
"3. 브랜드명, 고유명사는 그대로 유지\n"
|
||||
"4. 숫자와 단위는 유지\n\n"
|
||||
"반드시 입력된 순서와 개수를 지켜서 JSON 배열만 출력하세요. 다른 설명 없이 JSON 배열만 출력합니다."
|
||||
)
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"입력:\n{items_json}\n\n출력 형식 (JSON 배열만 출력, 다른 텍스트 없이):\n[\n{{\"id\": 1, \"translation\": \"번역된 텍스트\"}},\n{{\"id\": 2, \"translation\": \"번역된 텍스트\"}}\n]"
|
||||
}
|
||||
],
|
||||
temperature=0.5, # 창의성 조절
|
||||
max_tokens=2048, # 출력 길이 제한 증가
|
||||
)
|
||||
|
||||
# 결과 출력
|
||||
response_content = completion.choices[0].message.content
|
||||
print("\n--- 번역 결과 ---")
|
||||
print(response_content)
|
||||
|
||||
# JSON 파싱 시도
|
||||
try:
|
||||
# JSON 코드 블록 제거 (```json ... ``` 형태)
|
||||
if "```" in response_content:
|
||||
lines = response_content.split("\n")
|
||||
json_lines = []
|
||||
in_json_block = False
|
||||
for line in lines:
|
||||
if line.strip().startswith("```"):
|
||||
in_json_block = not in_json_block
|
||||
continue
|
||||
if in_json_block or (not in_json_block and line.strip()):
|
||||
json_lines.append(line)
|
||||
response_content = "\n".join(json_lines)
|
||||
|
||||
translated_items = json.loads(response_content.strip())
|
||||
print(f"\n--- 파싱된 결과 ({len(translated_items)}개) ---")
|
||||
for item in translated_items:
|
||||
item_id = item.get("id", "N/A")
|
||||
translation = item.get("translation", item.get("result", item.get("translated", "")))
|
||||
print(f"ID {item_id}: {translation}")
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"\nJSON 파싱 실패: {e}")
|
||||
print("원본 응답을 확인하세요.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"에러 발생: {e}")
|
||||
|
|
@ -0,0 +1,542 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
OpenRouter 클라이언트 테스트 스크립트
|
||||
|
||||
사용법:
|
||||
python modules/test_openrouter_client.py
|
||||
|
||||
환경변수 설정:
|
||||
export OPENROUTER_API_KEY="sk-or-v1-xxxxx"
|
||||
또는 코드에서 직접 설정
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='[%(asctime)s] [%(levelname)s] %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 모듈 경로 추가
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from modules.openrouter_client import OpenRouterTranslator
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 로그에서 추출한 샘플 OCR 데이터
|
||||
# =============================================================================
|
||||
|
||||
# 샘플 1: 이미지 35 (7개 텍스트, 모두 중국어)
|
||||
SAMPLE_1: List[Dict[str, Any]] = [
|
||||
{'text': '高密【拉毛布】', 'confidence': 0.8697245717048645},
|
||||
{'text': '柔中带韧不易坏', 'confidence': 0.9874316453933716},
|
||||
{'text': '安静无声助力深度睡眠', 'confidence': 0.9960897564888},
|
||||
{'text': 'newpet家的', 'confidence': 0.9988763928413391},
|
||||
{'text': '别人家的', 'confidence': 0.9997605085372925},
|
||||
{'text': '密织拉毛布保暖还结实', 'confidence': 0.9967950582504272},
|
||||
{'text': '劣质无纺布一拉就烂一洗就散', 'confidence': 0.9917935729026794},
|
||||
]
|
||||
|
||||
# 샘플 2: 이미지 37 (3개 텍스트, 모두 중국어)
|
||||
SAMPLE_2: List[Dict[str, Any]] = [
|
||||
{'text': '隐藏拉链不易咬', 'confidence': 0.9800551533699036},
|
||||
{'text': '宠物更安全', 'confidence': 0.9966630935668945},
|
||||
{'text': '隐藏式拉链+顺滑手感告别突元五金', 'confidence': 0.9670320749282837},
|
||||
]
|
||||
|
||||
# 샘플 3: 이미지 38 (12개 중 11개 중국어, Productparameters 제외됨)
|
||||
SAMPLE_3: List[Dict[str, Any]] = [
|
||||
{'text': '产品参数', 'confidence': 0.995430052280426},
|
||||
{'text': '品牌', 'confidence': 0.9999157786369324},
|
||||
{'text': '名称', 'confidence': 0.9999277591705322},
|
||||
{'text': 'NewPet妞派特', 'confidence': 0.9976658225059509},
|
||||
{'text': '加厚防风圆弧房子', 'confidence': 0.9947051405906677},
|
||||
{'text': '适用对象', 'confidence': 0.9994360208511353},
|
||||
{'text': '产品材质', 'confidence': 0.9989043474197388},
|
||||
{'text': '猫犬通用', 'confidence': 0.9970399141311646},
|
||||
{'text': '高密度海绵、牛津布', 'confidence': 0.995124101638794},
|
||||
{'text': '颜色', 'confidence': 0.9999042749404907},
|
||||
{'text': '多色可选', 'confidence': 0.9995521903038025},
|
||||
]
|
||||
|
||||
# 샘플 4: 이미지 4 (9개 중 8개 중국어, newpet 제외됨)
|
||||
SAMPLE_4: List[Dict[str, Any]] = [
|
||||
{'text': '工厂直销', 'confidence': 0.9989292621612549},
|
||||
{'text': '品质稳定有保证', 'confidence': 0.9961501359939575},
|
||||
{'text': '实力源头厂家,13年畅销好产品', 'confidence': 0.9883608222007751},
|
||||
{'text': 'M稳定货源', 'confidence': 0.8720976114273071},
|
||||
{'text': '诚实守信', 'confidence': 0.9912816882133484},
|
||||
{'text': 'M品质放心', 'confidence': 0.9013170003890991},
|
||||
{'text': '德国品质·13年畅销', 'confidence': 0.9565986394882202},
|
||||
{'text': '连续登顶天猫宠物窝TOP榜', 'confidence': 0.9966885447502136},
|
||||
]
|
||||
|
||||
# 샘플 5: 이미지 1 (8개 텍스트, 모두 중국어)
|
||||
SAMPLE_5: List[Dict[str, Any]] = [
|
||||
{'text': '二', 'confidence': 0.9579324722290039},
|
||||
{'text': '暖意不容等待', 'confidence': 0.9924479126930237},
|
||||
{'text': '趁"冷"下单,立享温暖', 'confidence': 0.9866428971290588},
|
||||
{'text': '降温预警', 'confidence': 0.9913228750228882},
|
||||
{'text': '广州', 'confidence': 0.9998736381530762},
|
||||
{'text': '低至10°℃-9℃', 'confidence': 0.9398331046104431},
|
||||
{'text': '部分城市', 'confidence': 0.9955496191978455},
|
||||
{'text': '未来7天降温情况', 'confidence': 0.9742034077644348},
|
||||
]
|
||||
|
||||
# 필터링 전 원본 데이터 (중국어 없는 텍스트 포함)
|
||||
SAMPLE_3_WITH_ENGLISH: List[Dict[str, Any]] = [
|
||||
{'text': 'Productparameters', 'confidence': 0.9952481389045715}, # 영어 (필터링됨)
|
||||
{'text': '产品参数', 'confidence': 0.995430052280426},
|
||||
{'text': '品牌', 'confidence': 0.9999157786369324},
|
||||
{'text': '名称', 'confidence': 0.9999277591705322},
|
||||
{'text': 'NewPet妞派特', 'confidence': 0.9976658225059509},
|
||||
{'text': '加厚防风圆弧房子', 'confidence': 0.9947051405906677},
|
||||
{'text': '适用对象', 'confidence': 0.9994360208511353},
|
||||
{'text': '产品材质', 'confidence': 0.9989043474197388},
|
||||
{'text': '猫犬通用', 'confidence': 0.9970399141311646},
|
||||
{'text': '高密度海绵、牛津布', 'confidence': 0.995124101638794},
|
||||
{'text': '颜色', 'confidence': 0.9999042749404907},
|
||||
{'text': '多色可选', 'confidence': 0.9995521903038025},
|
||||
]
|
||||
|
||||
SAMPLE_4_WITH_ENGLISH: List[Dict[str, Any]] = [
|
||||
{'text': '工厂直销', 'confidence': 0.9989292621612549},
|
||||
{'text': '品质稳定有保证', 'confidence': 0.9961501359939575},
|
||||
{'text': '实力源头厂家,13年畅销好产品', 'confidence': 0.9883608222007751},
|
||||
{'text': 'M稳定货源', 'confidence': 0.8720976114273071},
|
||||
{'text': '诚实守信', 'confidence': 0.9912816882133484},
|
||||
{'text': 'M品质放心', 'confidence': 0.9013170003890991},
|
||||
{'text': 'newpet', 'confidence': 0.9935563206672668}, # 영어 (필터링됨)
|
||||
{'text': '德国品质·13年畅销', 'confidence': 0.9565986394882202},
|
||||
{'text': '连续登顶天猫宠物窝TOP榜', 'confidence': 0.9966885447502136},
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 테스트 함수들
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class BenchmarkResult:
|
||||
"""벤치마크 결과 데이터 클래스"""
|
||||
model_id: str
|
||||
elapsed_time: float
|
||||
success: bool
|
||||
results: Optional[List[str]] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
def print_separator(title: str = ""):
|
||||
"""구분선 출력"""
|
||||
print("\n" + "=" * 80)
|
||||
if title:
|
||||
print(f" {title}")
|
||||
print("=" * 80)
|
||||
print()
|
||||
|
||||
|
||||
def test_translate_ocr_texts(
|
||||
translator: OpenRouterTranslator,
|
||||
ocr_results: List[Dict[str, Any]],
|
||||
product_name: str = "테스트 상품",
|
||||
category: str = "테스트 카테고리",
|
||||
sample_name: str = "샘플"
|
||||
):
|
||||
"""translate_ocr_texts 메서드 테스트"""
|
||||
print_separator(f"{sample_name} - translate_ocr_texts 테스트")
|
||||
|
||||
print(f"입력 텍스트 ({len(ocr_results)}개):")
|
||||
for i, item in enumerate(ocr_results, 1):
|
||||
print(f" {i}. {item['text']}")
|
||||
|
||||
print(f"\n상품명: {product_name}")
|
||||
print(f"카테고리: {category}")
|
||||
print(f"모델: {translator.get_current_model()['name']}")
|
||||
print("\n번역 중...")
|
||||
|
||||
try:
|
||||
results = translator.translate_ocr_texts(
|
||||
product_name=product_name,
|
||||
category=category,
|
||||
ocr_results=ocr_results
|
||||
)
|
||||
|
||||
print(f"\n번역 결과 ({len(results)}개):")
|
||||
for i, (original, translated) in enumerate(zip(ocr_results, results), 1):
|
||||
print(f" {i}. {original['text']} → {translated}")
|
||||
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"\n❌ 오류 발생: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
|
||||
def test_run_llm_translation(
|
||||
translator: OpenRouterTranslator,
|
||||
ocr_results: List[Dict[str, Any]],
|
||||
product_name: str = "테스트 상품",
|
||||
category: str = "테스트 카테고리",
|
||||
steps: int = 2,
|
||||
sample_name: str = "샘플"
|
||||
):
|
||||
"""run_llm_translation 메서드 테스트 (steps 지원)"""
|
||||
print_separator(f"{sample_name} - run_llm_translation 테스트 (steps={steps})")
|
||||
|
||||
print(f"입력 텍스트 ({len(ocr_results)}개):")
|
||||
for i, item in enumerate(ocr_results, 1):
|
||||
print(f" {i}. {item['text']}")
|
||||
|
||||
print(f"\n상품명: {product_name}")
|
||||
print(f"카테고리: {category}")
|
||||
print(f"모델: {translator.get_current_model()['name']}")
|
||||
print(f"번역 단계: {steps} ({'직역만' if steps == 1 else '직역+마케팅톤 변환'})")
|
||||
print("\n번역 중...")
|
||||
|
||||
try:
|
||||
results = translator.run_llm_translation(
|
||||
product_name=product_name,
|
||||
category=category,
|
||||
ocr_results=ocr_results,
|
||||
steps=steps
|
||||
)
|
||||
|
||||
print(f"\n번역 결과 ({len(results)}개):")
|
||||
for i, (original, translated) in enumerate(zip(ocr_results, results), 1):
|
||||
print(f" {i}. {original['text']} → {translated}")
|
||||
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"\n❌ 오류 발생: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
|
||||
def test_filtering_comparison(
|
||||
translator: OpenRouterTranslator,
|
||||
filtered_results: List[Dict[str, Any]],
|
||||
unfiltered_results: List[Dict[str, Any]],
|
||||
product_name: str = "테스트 상품",
|
||||
category: str = "테스트 카테고리"
|
||||
):
|
||||
"""필터링 전/후 비교 테스트"""
|
||||
print_separator("필터링 전/후 비교 테스트")
|
||||
|
||||
print("=" * 80)
|
||||
print(" 중국어가 없는 텍스트를 포함한 경우 (필터링 전)")
|
||||
print("=" * 80)
|
||||
|
||||
print(f"\n입력 텍스트 ({len(unfiltered_results)}개):")
|
||||
for i, item in enumerate(unfiltered_results, 1):
|
||||
has_chinese = any('\u4e00' <= char <= '\u9fff' for char in item['text'])
|
||||
marker = "✓" if has_chinese else "✗ (필터링됨)"
|
||||
print(f" {i}. {item['text']} {marker}")
|
||||
|
||||
print("\n번역 중...")
|
||||
try:
|
||||
results_unfiltered = translator.translate_ocr_texts(
|
||||
product_name=product_name,
|
||||
category=category,
|
||||
ocr_results=unfiltered_results
|
||||
)
|
||||
|
||||
print("\n번역 결과:")
|
||||
for i, (original, translated) in enumerate(zip(unfiltered_results, results_unfiltered), 1):
|
||||
has_chinese = any('\u4e00' <= char <= '\u9fff' for char in original['text'])
|
||||
marker = "" if has_chinese else " (원본 유지)"
|
||||
print(f" {i}. {original['text']} → {translated}{marker}")
|
||||
except Exception as e:
|
||||
print(f"\n❌ 오류 발생: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print(" 중국어만 포함한 경우 (필터링 후)")
|
||||
print("=" * 80)
|
||||
|
||||
print(f"\n입력 텍스트 ({len(filtered_results)}개):")
|
||||
for i, item in enumerate(filtered_results, 1):
|
||||
print(f" {i}. {item['text']}")
|
||||
|
||||
print("\n번역 중...")
|
||||
try:
|
||||
results_filtered = translator.translate_ocr_texts(
|
||||
product_name=product_name,
|
||||
category=category,
|
||||
ocr_results=filtered_results
|
||||
)
|
||||
|
||||
print("\n번역 결과:")
|
||||
for i, (original, translated) in enumerate(zip(filtered_results, results_filtered), 1):
|
||||
print(f" {i}. {original['text']} → {translated}")
|
||||
except Exception as e:
|
||||
print(f"\n❌ 오류 발생: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print(" 분석 및 권장사항")
|
||||
print("=" * 80)
|
||||
print("""
|
||||
중국어가 없는 텍스트를 필터링하는 것에 대한 분석:
|
||||
|
||||
1. 맥락 이해 측면:
|
||||
- 중국어가 없는 텍스트(영어, 숫자 등)는 보통 브랜드명, 모델명,
|
||||
또는 이미 번역된 텍스트일 가능성이 높음
|
||||
- 이러한 텍스트를 포함하면 LLM이 전체 맥락을 더 잘 이해할 수 있음
|
||||
- 예: "newpet" 브랜드명이 있으면 "newpet家的"의 번역 품질이 향상될 수 있음
|
||||
|
||||
2. 토큰 사용 측면:
|
||||
- 필터링하지 않으면 입력 토큰이 증가함
|
||||
- 하지만 맥락 정보가 추가되어 번역 품질이 향상될 수 있음
|
||||
- 비용과 품질의 트레이드오프
|
||||
|
||||
3. 권장사항:
|
||||
- 브랜드명, 모델명 등은 포함하는 것이 좋음 (맥락 이해 향상)
|
||||
- 순수 영어 설명문은 제외해도 무방 (중국어 번역 대상이 아님)
|
||||
- 숫자, 기호는 포함 (상품 정보의 일부)
|
||||
- 최종 결정은 번역 품질과 비용을 고려하여 선택
|
||||
""")
|
||||
|
||||
|
||||
def benchmark_models(
|
||||
api_key: str,
|
||||
model_ids: List[str],
|
||||
ocr_results: List[Dict[str, Any]],
|
||||
product_name: str = "테스트 상품",
|
||||
category: str = "테스트 카테고리",
|
||||
use_llm_translation: bool = False,
|
||||
steps: int = 1
|
||||
) -> List[BenchmarkResult]:
|
||||
"""
|
||||
여러 모델을 벤치마크하여 비교
|
||||
|
||||
Args:
|
||||
api_key: OpenRouter API 키
|
||||
model_ids: 테스트할 모델 ID 리스트
|
||||
ocr_results: OCR 결과 데이터
|
||||
product_name: 상품명
|
||||
category: 카테고리
|
||||
use_llm_translation: True면 run_llm_translation 사용, False면 translate_ocr_texts 사용
|
||||
steps: run_llm_translation 사용 시 번역 단계 (1=직역만, 2=직역+마케팅톤 변환)
|
||||
|
||||
Returns:
|
||||
벤치마크 결과 리스트
|
||||
"""
|
||||
benchmark_results: List[BenchmarkResult] = []
|
||||
|
||||
method_name = "run_llm_translation" if use_llm_translation else "translate_ocr_texts"
|
||||
print_separator("벤치마크 시작")
|
||||
print(f"테스트 모델 수: {len(model_ids)}")
|
||||
print(f"입력 텍스트 수: {len(ocr_results)}")
|
||||
print(f"상품명: {product_name}")
|
||||
print(f"카테고리: {category}")
|
||||
print(f"사용 메서드: {method_name}")
|
||||
if use_llm_translation:
|
||||
print(f"번역 단계: {steps} ({'직역만' if steps == 1 else '직역+마케팅톤 변환'})")
|
||||
print()
|
||||
|
||||
for idx, model_id in enumerate(model_ids, 1):
|
||||
print(f"[{idx}/{len(model_ids)}] 모델: {model_id}")
|
||||
print("-" * 80)
|
||||
|
||||
start_time = time.time()
|
||||
success = False
|
||||
results = None
|
||||
error_msg = None
|
||||
|
||||
try:
|
||||
# 번역기 초기화 (타임아웃 10초)
|
||||
translator = OpenRouterTranslator(
|
||||
api_key=api_key,
|
||||
model_id=model_id,
|
||||
timeout=10,
|
||||
logger=logger
|
||||
)
|
||||
|
||||
# 번역 실행 (플래그에 따라 메서드 선택)
|
||||
if use_llm_translation:
|
||||
results = translator.run_llm_translation(
|
||||
product_name=product_name,
|
||||
category=category,
|
||||
ocr_results=ocr_results,
|
||||
steps=steps
|
||||
)
|
||||
|
||||
|
||||
|
||||
# results = translator.run_combined_llm_translation(
|
||||
# product_name=product_name,
|
||||
# category=category,
|
||||
# ocr_results=ocr_results,
|
||||
# # steps=steps
|
||||
# )
|
||||
|
||||
|
||||
else:
|
||||
results = translator.translate_ocr_texts(
|
||||
product_name=product_name,
|
||||
category=category,
|
||||
ocr_results=ocr_results
|
||||
)
|
||||
|
||||
success = True
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
print(f"✅ 성공 - 소요 시간: {elapsed_time:.2f}초")
|
||||
print(f"번역 결과:")
|
||||
for i, (original, translated) in enumerate(zip(ocr_results, results), 1):
|
||||
print(f" {i}. {original['text']} → {translated}")
|
||||
|
||||
except Exception as e:
|
||||
elapsed_time = time.time() - start_time
|
||||
error_msg = str(e)
|
||||
print(f"❌ 실패 - 소요 시간: {elapsed_time:.2f}초")
|
||||
print(f"오류: {error_msg}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
benchmark_results.append(BenchmarkResult(
|
||||
model_id=model_id,
|
||||
elapsed_time=elapsed_time,
|
||||
success=success,
|
||||
results=results,
|
||||
error=error_msg
|
||||
))
|
||||
|
||||
print()
|
||||
|
||||
return benchmark_results
|
||||
|
||||
|
||||
def print_benchmark_summary(benchmark_results: List[BenchmarkResult]):
|
||||
"""벤치마크 결과 요약 출력"""
|
||||
print_separator("벤치마크 결과 요약")
|
||||
|
||||
# 성공한 결과만 필터링
|
||||
successful_results = [r for r in benchmark_results if r.success]
|
||||
failed_results = [r for r in benchmark_results if not r.success]
|
||||
|
||||
if successful_results:
|
||||
# 실행 시간 기준 정렬
|
||||
sorted_results = sorted(successful_results, key=lambda x: x.elapsed_time)
|
||||
|
||||
print("✅ 성공한 모델:")
|
||||
print("-" * 80)
|
||||
print(f"{'순위':<6} {'모델 ID':<50} {'소요 시간':<15} {'상태'}")
|
||||
print("-" * 80)
|
||||
|
||||
for rank, result in enumerate(sorted_results, 1):
|
||||
status = "✅ 성공"
|
||||
print(f"{rank:<6} {result.model_id:<50} {result.elapsed_time:>10.2f}초 {status}")
|
||||
|
||||
print()
|
||||
print("📊 통계:")
|
||||
times = [r.elapsed_time for r in successful_results]
|
||||
print(f" 평균 시간: {sum(times) / len(times):.2f}초")
|
||||
print(f" 최소 시간: {min(times):.2f}초 ({sorted_results[0].model_id})")
|
||||
print(f" 최대 시간: {max(times):.2f}초 ({sorted_results[-1].model_id})")
|
||||
print()
|
||||
|
||||
if failed_results:
|
||||
print("❌ 실패한 모델:")
|
||||
print("-" * 80)
|
||||
for result in failed_results:
|
||||
print(f" {result.model_id}")
|
||||
print(f" 오류: {result.error}")
|
||||
print(f" 소요 시간: {result.elapsed_time:.2f}초")
|
||||
print()
|
||||
|
||||
# 결과 비교
|
||||
if len(successful_results) > 1:
|
||||
print_separator("번역 결과 비교")
|
||||
|
||||
# 첫 번째 성공한 모델의 결과를 기준으로 비교
|
||||
base_result = successful_results[0]
|
||||
print(f"기준 모델: {base_result.model_id}")
|
||||
print()
|
||||
|
||||
for result in successful_results[1:]:
|
||||
if result.results and base_result.results:
|
||||
print(f"모델: {result.model_id}")
|
||||
print("-" * 80)
|
||||
|
||||
differences = []
|
||||
for i, (base_trans, comp_trans) in enumerate(zip(base_result.results, result.results), 1):
|
||||
if base_trans != comp_trans:
|
||||
differences.append(i)
|
||||
print(f" 차이 {len(differences)}: 항목 {i}")
|
||||
print(f" 기준: {base_trans}")
|
||||
print(f" 비교: {comp_trans}")
|
||||
|
||||
if not differences:
|
||||
print(" 모든 번역 결과가 기준 모델과 동일합니다.")
|
||||
else:
|
||||
print(f" 총 {len(differences)}개 항목에서 차이 발견")
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
"""메인 테스트 함수"""
|
||||
print_separator("OpenRouter 클라이언트 벤치마크 테스트")
|
||||
|
||||
# =============================================================================
|
||||
# 여기서 모델과 API 키를 직접 지정하세요
|
||||
# =============================================================================
|
||||
API_KEY = os.getenv("OPENROUTER_API_KEY", "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")
|
||||
|
||||
|
||||
# 테스트 데이터
|
||||
product_name = "NewPet 반려동물 House"
|
||||
category = "반려동물용품"
|
||||
test_sample = SAMPLE_1 # 또는 SAMPLE_2, SAMPLE_3 등
|
||||
|
||||
# 벤치마크 설정
|
||||
USE_LLM_TRANSLATION = True # True: run_llm_translation 사용, False: translate_ocr_texts 사용
|
||||
STEPS = 2 # run_llm_translation 사용 시 번역 단계 (1=직역만, 2=직역+마케팅톤 변환)
|
||||
|
||||
# 벤치마크할 모델 ID 리스트
|
||||
MODEL_IDS = [
|
||||
# "xiaomi/mimo-v2-flash:free",
|
||||
# "openai/gpt-oss-20b:deepinfra/fp4",
|
||||
# "mistralai/devstral-2512:free",
|
||||
# "mistralai/mistral-7b-instruct:free",
|
||||
# "openai/gpt-oss-20b:gmicloud/fp4",
|
||||
# "z-ai/glm-4.5-air:novita/bf16",
|
||||
# "deepseek/deepseek-v3.2:atlas-cloud/fp8",
|
||||
"openai/gpt-5-nano:azure",
|
||||
# "google/gemma-3n-e4b-it:together",
|
||||
# "google/gemma-3-4b-it:deepinfra/bf16",
|
||||
# 추가 모델 ID를 여기에 추가하세요
|
||||
]
|
||||
|
||||
# 벤치마크 실행
|
||||
benchmark_results = benchmark_models(
|
||||
api_key=API_KEY,
|
||||
model_ids=MODEL_IDS,
|
||||
ocr_results=test_sample,
|
||||
product_name=product_name,
|
||||
category=category,
|
||||
use_llm_translation=USE_LLM_TRANSLATION,
|
||||
steps=STEPS
|
||||
)
|
||||
|
||||
# 결과 요약 출력
|
||||
print_benchmark_summary(benchmark_results)
|
||||
|
||||
print_separator("벤치마크 완료")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
|
@ -0,0 +1,433 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
OpenRouter 클라이언트 이중 모델 테스트 스크립트
|
||||
|
||||
직역과 마케팅 변환에 각각 다른 모델을 사용할 수 있는 테스트 코드
|
||||
|
||||
사용법:
|
||||
python modules/test_openrouter_dual_model.py
|
||||
|
||||
환경변수 설정:
|
||||
export OPENROUTER_API_KEY="sk-or-v1-xxxxx"
|
||||
또는 코드에서 직접 설정
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='[%(asctime)s] [%(levelname)s] %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 모듈 경로 추가
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from modules.openrouter_client import OpenRouterTranslator
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 로그에서 추출한 샘플 OCR 데이터
|
||||
# =============================================================================
|
||||
|
||||
# 샘플 1: 이미지 35 (7개 텍스트, 모두 중국어)
|
||||
SAMPLE_1: List[Dict[str, Any]] = [
|
||||
{'text': '高密【拉毛布】', 'confidence': 0.8697245717048645},
|
||||
{'text': '柔中带韧不易坏', 'confidence': 0.9874316453933716},
|
||||
{'text': '安静无声助力深度睡眠', 'confidence': 0.9960897564888},
|
||||
{'text': 'newpet家的', 'confidence': 0.9988763928413391},
|
||||
{'text': '别人家的', 'confidence': 0.9997605085372925},
|
||||
{'text': '密织拉毛布保暖还结实', 'confidence': 0.9967950582504272},
|
||||
{'text': '劣质无纺布一拉就烂一洗就散', 'confidence': 0.9917935729026794},
|
||||
]
|
||||
|
||||
# 샘플 2: 이미지 37 (3개 텍스트, 모두 중국어)
|
||||
SAMPLE_2: List[Dict[str, Any]] = [
|
||||
{'text': '隐藏拉链不易咬', 'confidence': 0.9800551533699036},
|
||||
{'text': '宠物更安全', 'confidence': 0.9966630935668945},
|
||||
{'text': '隐藏式拉链+顺滑手感告别突元五金', 'confidence': 0.9670320749282837},
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 테스트 함수들
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class DualModelTestResult:
|
||||
"""이중 모델 테스트 결과 데이터 클래스"""
|
||||
step1_model_id: str
|
||||
step2_model_id: Optional[str]
|
||||
elapsed_time: float
|
||||
success: bool
|
||||
step1_results: Optional[List[str]] = None
|
||||
step2_results: Optional[List[str]] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
def print_separator(title: str = ""):
|
||||
"""구분선 출력"""
|
||||
print("\n" + "=" * 80)
|
||||
if title:
|
||||
print(f" {title}")
|
||||
print("=" * 80)
|
||||
print()
|
||||
|
||||
|
||||
def test_dual_model_translation(
|
||||
api_key: str,
|
||||
step1_model_id: str,
|
||||
step2_model_id: Optional[str],
|
||||
ocr_results: List[Dict[str, Any]],
|
||||
product_name: str = "테스트 상품",
|
||||
category: str = "테스트 카테고리",
|
||||
use_llm_translation: bool = True,
|
||||
steps: int = 1
|
||||
) -> DualModelTestResult:
|
||||
"""
|
||||
이중 모델을 사용한 번역 테스트
|
||||
|
||||
Args:
|
||||
api_key: OpenRouter API 키
|
||||
step1_model_id: Step 1 (직역)에 사용할 모델 ID
|
||||
step2_model_id: Step 2 (마케팅톤 변환)에 사용할 모델 ID (None이면 step1과 동일)
|
||||
ocr_results: OCR 결과 데이터
|
||||
product_name: 상품명
|
||||
category: 카테고리
|
||||
use_llm_translation: True면 run_llm_translation 사용, False면 translate_ocr_texts 사용
|
||||
steps: 번역 단계 (1=직역만, 2=직역+마케팅톤 변환)
|
||||
|
||||
Returns:
|
||||
테스트 결과
|
||||
"""
|
||||
print_separator("이중 모델 번역 테스트")
|
||||
print(f"Step 1 모델 (직역): {step1_model_id}")
|
||||
if steps == 2:
|
||||
step2_actual = step2_model_id or step1_model_id
|
||||
print(f"Step 2 모델 (마케팅톤): {step2_actual}")
|
||||
print(f"입력 텍스트 수: {len(ocr_results)}")
|
||||
print(f"상품명: {product_name}")
|
||||
print(f"카테고리: {category}")
|
||||
print(f"사용 메서드: {'run_llm_translation' if use_llm_translation else 'translate_ocr_texts'}")
|
||||
print(f"번역 단계: {steps} ({'직역만' if steps == 1 else '직역+마케팅톤 변환'})")
|
||||
print()
|
||||
|
||||
start_time = time.time()
|
||||
success = False
|
||||
step1_results = None
|
||||
step2_results = None
|
||||
error_msg = None
|
||||
|
||||
try:
|
||||
if not use_llm_translation:
|
||||
# translate_ocr_texts 사용 (단일 모델)
|
||||
translator = OpenRouterTranslator(
|
||||
api_key=api_key,
|
||||
model_id=step1_model_id,
|
||||
timeout=10,
|
||||
logger=logger
|
||||
)
|
||||
|
||||
results = translator.translate_ocr_texts(
|
||||
product_name=product_name,
|
||||
category=category,
|
||||
ocr_results=ocr_results
|
||||
)
|
||||
|
||||
step1_results = results
|
||||
step2_results = None
|
||||
success = True
|
||||
|
||||
elif steps == 1:
|
||||
# run_llm_translation, steps=1 (직역만)
|
||||
translator = OpenRouterTranslator(
|
||||
api_key=api_key,
|
||||
model_id=step1_model_id,
|
||||
timeout=10,
|
||||
logger=logger
|
||||
)
|
||||
|
||||
results = translator.run_llm_translation(
|
||||
product_name=product_name,
|
||||
category=category,
|
||||
ocr_results=ocr_results,
|
||||
steps=1
|
||||
)
|
||||
|
||||
step1_results = results
|
||||
step2_results = None
|
||||
success = True
|
||||
|
||||
else:
|
||||
# run_llm_translation, steps=2 (직역 + 마케팅톤 변환, 다른 모델 사용)
|
||||
step2_actual = step2_model_id or step1_model_id
|
||||
|
||||
# Step 1: 직역
|
||||
translator_step1 = OpenRouterTranslator(
|
||||
api_key=api_key,
|
||||
model_id=step1_model_id,
|
||||
timeout=10,
|
||||
logger=logger
|
||||
)
|
||||
|
||||
print(f"[Step 1] 직역 시작 - 모델: {step1_model_id}")
|
||||
step1_results = translator_step1.run_llm_translation(
|
||||
product_name=product_name,
|
||||
category=category,
|
||||
ocr_results=ocr_results,
|
||||
steps=1
|
||||
)
|
||||
|
||||
print(f"[Step 1] 직역 완료")
|
||||
print("Step 1 결과:")
|
||||
for i, (original, translated) in enumerate(zip(ocr_results, step1_results), 1):
|
||||
print(f" {i}. {original['text']} → {translated}")
|
||||
print()
|
||||
|
||||
# Step 2: 마케팅톤 변환 (Step 1 결과를 입력으로 사용)
|
||||
if step2_actual != step1_model_id:
|
||||
translator_step2 = OpenRouterTranslator(
|
||||
api_key=api_key,
|
||||
model_id=step2_actual,
|
||||
timeout=10,
|
||||
logger=logger
|
||||
)
|
||||
else:
|
||||
translator_step2 = translator_step1
|
||||
|
||||
print(f"[Step 2] 마케팅톤 변환 시작 - 모델: {step2_actual}")
|
||||
|
||||
# Step 1 결과를 OCR 결과 형태로 변환
|
||||
step1_ocr_results = [{"text": text} for text in step1_results]
|
||||
|
||||
step2_results = translator_step2.run_llm_translation(
|
||||
product_name=product_name,
|
||||
category=category,
|
||||
ocr_results=step1_ocr_results,
|
||||
steps=2
|
||||
)
|
||||
|
||||
print(f"[Step 2] 마케팅톤 변환 완료")
|
||||
print("Step 2 결과:")
|
||||
for i, (step1_text, step2_text) in enumerate(zip(step1_results, step2_results), 1):
|
||||
print(f" {i}. {step1_text} → {step2_text}")
|
||||
print()
|
||||
|
||||
success = True
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
print(f"✅ 성공 - 총 소요 시간: {elapsed_time:.2f}초")
|
||||
print()
|
||||
print("최종 번역 결과:")
|
||||
final_results = step2_results if step2_results else step1_results
|
||||
for i, (original, translated) in enumerate(zip(ocr_results, final_results), 1):
|
||||
print(f" {i}. {original['text']} → {translated}")
|
||||
|
||||
except Exception as e:
|
||||
elapsed_time = time.time() - start_time
|
||||
error_msg = str(e)
|
||||
print(f"❌ 실패 - 소요 시간: {elapsed_time:.2f}초")
|
||||
print(f"오류: {error_msg}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
return DualModelTestResult(
|
||||
step1_model_id=step1_model_id,
|
||||
step2_model_id=step2_model_id or step1_model_id if steps == 2 else None,
|
||||
elapsed_time=elapsed_time,
|
||||
success=success,
|
||||
step1_results=step1_results,
|
||||
step2_results=step2_results,
|
||||
error=error_msg
|
||||
)
|
||||
|
||||
|
||||
def test_combined_translation(
|
||||
api_key: str,
|
||||
model_id: str,
|
||||
ocr_results: List[Dict[str, Any]],
|
||||
product_name: str = "테스트 상품",
|
||||
category: str = "테스트 카테고리"
|
||||
) -> DualModelTestResult:
|
||||
"""
|
||||
통합 프롬프트를 사용한 번역 테스트 (한 번의 API 호출로 직역+마케팅톤 변환)
|
||||
|
||||
Args:
|
||||
api_key: OpenRouter API 키
|
||||
model_id: 사용할 모델 ID
|
||||
ocr_results: OCR 결과 데이터
|
||||
product_name: 상품명
|
||||
category: 카테고리
|
||||
|
||||
Returns:
|
||||
테스트 결과
|
||||
"""
|
||||
print_separator("통합 프롬프트 번역 테스트")
|
||||
print(f"모델: {model_id}")
|
||||
print(f"입력 텍스트 수: {len(ocr_results)}")
|
||||
print(f"상품명: {product_name}")
|
||||
print(f"카테고리: {category}")
|
||||
print(f"사용 메서드: run_combined_llm_translation (직역+마케팅톤 변환 통합)")
|
||||
print()
|
||||
|
||||
start_time = time.time()
|
||||
success = False
|
||||
results = None
|
||||
error_msg = None
|
||||
|
||||
try:
|
||||
translator = OpenRouterTranslator(
|
||||
api_key=api_key,
|
||||
model_id=model_id,
|
||||
timeout=10,
|
||||
logger=logger
|
||||
)
|
||||
|
||||
print("번역 중...")
|
||||
results = translator.run_combined_llm_translation(
|
||||
product_name=product_name,
|
||||
category=category,
|
||||
ocr_results=ocr_results
|
||||
)
|
||||
|
||||
success = True
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
print(f"✅ 성공 - 소요 시간: {elapsed_time:.2f}초")
|
||||
print("번역 결과:")
|
||||
for i, (original, translated) in enumerate(zip(ocr_results, results), 1):
|
||||
print(f" {i}. {original['text']} → {translated}")
|
||||
|
||||
except Exception as e:
|
||||
elapsed_time = time.time() - start_time
|
||||
error_msg = str(e)
|
||||
print(f"❌ 실패 - 소요 시간: {elapsed_time:.2f}초")
|
||||
print(f"오류: {error_msg}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
return DualModelTestResult(
|
||||
step1_model_id=model_id,
|
||||
step2_model_id=None,
|
||||
elapsed_time=elapsed_time,
|
||||
success=success,
|
||||
step1_results=results,
|
||||
step2_results=None,
|
||||
error=error_msg
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""메인 테스트 함수"""
|
||||
print_separator("OpenRouter 이중 모델 테스트")
|
||||
|
||||
# =============================================================================
|
||||
# 설정
|
||||
# =============================================================================
|
||||
API_KEY = os.getenv("OPENROUTER_API_KEY", "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")
|
||||
|
||||
# 테스트 데이터
|
||||
product_name = "NewPet 반려동물 House"
|
||||
category = "반려동물용품"
|
||||
test_sample = SAMPLE_1 # 또는 SAMPLE_2 등
|
||||
|
||||
# =============================================================================
|
||||
# 테스트 설정
|
||||
# =============================================================================
|
||||
|
||||
# 방법 선택
|
||||
USE_LLM_TRANSLATION = True # True: run_llm_translation 사용, False: translate_ocr_texts 사용
|
||||
STEPS = 2 # run_llm_translation 사용 시 번역 단계 (1=직역만, 2=직역+마케팅톤 변환)
|
||||
|
||||
|
||||
# "xiaomi/mimo-v2-flash:free",
|
||||
# "openai/gpt-oss-20b:deepinfra/fp4",
|
||||
# "mistralai/devstral-2512:free",
|
||||
# "mistralai/mistral-7b-instruct:free",
|
||||
# "openai/gpt-oss-20b:gmicloud/fp4",
|
||||
# "z-ai/glm-4.5-air:novita/bf16",
|
||||
# "x-ai/grok-4.1-fast:xai",
|
||||
# "deepseek/deepseek-v3.2:atlas-cloud/fp8",
|
||||
# "openai/gpt-5-nano:azure",
|
||||
# "google/gemma-3n-e4b-it:together",
|
||||
# "google/gemma-3-4b-it:deepinfra/bf16",
|
||||
# 추가 모델 ID를 여기에 추가하세요
|
||||
|
||||
|
||||
# 모델 설정
|
||||
STEP1_MODEL_ID = "qwen/qwen3-next-80b-a3b-instruct:gmicloud/fp8" # Step 1 (직역) 모델
|
||||
STEP2_MODEL_ID = "qwen/qwen3-next-80b-a3b-instruct:gmicloud/fp8" # Step 2 (마케팅톤) 모델 (None이면 Step1과 동일)
|
||||
|
||||
|
||||
# 통합 프롬프트 테스트용 모델 (선택사항)
|
||||
USE_COMBINED = False # True면 통합 프롬프트 테스트도 실행
|
||||
COMBINED_MODEL_ID = "google/gemma-3-4b-it:deepinfra/bf16" # 통합 프롬프트 테스트용 모델
|
||||
|
||||
# =============================================================================
|
||||
# 테스트 실행
|
||||
# =============================================================================
|
||||
|
||||
# 이중 모델 테스트
|
||||
result = test_dual_model_translation(
|
||||
api_key=API_KEY,
|
||||
step1_model_id=STEP1_MODEL_ID,
|
||||
step2_model_id=STEP2_MODEL_ID,
|
||||
ocr_results=test_sample,
|
||||
product_name=product_name,
|
||||
category=category,
|
||||
use_llm_translation=USE_LLM_TRANSLATION,
|
||||
steps=STEPS
|
||||
)
|
||||
|
||||
# 통합 프롬프트 테스트 (선택사항)
|
||||
if USE_COMBINED:
|
||||
print_separator("통합 프롬프트 테스트")
|
||||
combined_result = test_combined_translation(
|
||||
api_key=API_KEY,
|
||||
model_id=COMBINED_MODEL_ID,
|
||||
ocr_results=test_sample,
|
||||
product_name=product_name,
|
||||
category=category
|
||||
)
|
||||
|
||||
# 결과 비교
|
||||
if result.success and combined_result.success:
|
||||
print_separator("결과 비교")
|
||||
print("이중 모델 방식 vs 통합 프롬프트 방식")
|
||||
print("-" * 80)
|
||||
|
||||
final_dual = result.step2_results if result.step2_results else result.step1_results
|
||||
final_combined = combined_result.step1_results
|
||||
|
||||
differences = []
|
||||
for i, (dual_text, combined_text) in enumerate(zip(final_dual, final_combined), 1):
|
||||
if dual_text != combined_text:
|
||||
differences.append(i)
|
||||
print(f"차이 {len(differences)}: 항목 {i}")
|
||||
print(f" 이중 모델: {dual_text}")
|
||||
print(f" 통합 프롬프트: {combined_text}")
|
||||
|
||||
if not differences:
|
||||
print("모든 번역 결과가 동일합니다.")
|
||||
else:
|
||||
print(f"총 {len(differences)}개 항목에서 차이 발견")
|
||||
|
||||
print()
|
||||
print(f"이중 모델 소요 시간: {result.elapsed_time:.2f}초")
|
||||
print(f"통합 프롬프트 소요 시간: {combined_result.elapsed_time:.2f}초")
|
||||
|
||||
print_separator("테스트 완료")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,649 @@
|
|||
import sys
|
||||
import os
|
||||
import glob
|
||||
import shutil
|
||||
import subprocess
|
||||
import logging
|
||||
import time
|
||||
from tqdm import tqdm
|
||||
|
||||
import requests
|
||||
|
||||
from cx_Freeze import setup, Executable
|
||||
from setuptools import find_packages
|
||||
from setuptools.command.build_ext import build_ext
|
||||
from cx_Freeze.command.build_exe import build_exe as _build_exe
|
||||
|
||||
from setuptools import setup as cy_setup, Extension
|
||||
from Cython.Build import cythonize
|
||||
|
||||
|
||||
|
||||
# 버전 및 메타데이터 가져오기
|
||||
from updater.__version__ import (
|
||||
__title__, __version__, __description__, __author__,
|
||||
__author_email__, __license__, __install_requires__
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# [Configuration] 0. Gokapi 서버 설정 (업로드를 위해 필수)
|
||||
# ============================================================================
|
||||
GOKAPI_URL = "https://go.wrmc.cc" # 사용자님의 Gokapi 주소로 변경하세요
|
||||
GOKAPI_APIKEY = "Pncy6s7pyi61rtUsV3kUwfPtOvU93I " # Gokapi 관리자 페이지에서 발급받은 API 키
|
||||
|
||||
# ============================================================================
|
||||
# [Configuration] 설정 및 상수 정의
|
||||
# ============================================================================
|
||||
|
||||
# 1. Cython으로 컴파일할 모듈 목록 (최소화하여 테스트)
|
||||
CYTHON_MODULES = [
|
||||
# 최소한의 모듈만 테스트
|
||||
"loggerModule",
|
||||
# "modules/image_worker",
|
||||
# "modules/tray_app",
|
||||
]
|
||||
|
||||
# 2. 빌드 후 제거할 불필요한 파일/폴더 목록
|
||||
CLEANUP_TARGETS = [
|
||||
# 디렉토리
|
||||
"lib/modules/ocr_backends", # OCR 백엔드들 (필요시 유지)
|
||||
"lib/modules/old_modules", # 구 버전 모듈들
|
||||
"lib/modules/test", # 테스트 파일들
|
||||
"lib/modules/debug_images", # 디버그 이미지들
|
||||
"lib/modules/img", # 샘플 이미지들
|
||||
"lib/modules/output", # 출력 샘플들
|
||||
"lib/modules/outputs", # 출력 샘플들
|
||||
"lib/modules/user_data", # 사용자 데이터
|
||||
"lib/modules/PP_Models", # Paddle 모델들 (이미 포함될 수 있음)
|
||||
"lib/paddle", "lib/paddleocr", "lib/onnxruntime", "lib/skimage", "lib/scipy",
|
||||
|
||||
# 파일
|
||||
"lib/modules/migan_traced.pt",
|
||||
"lib/modules/*.pyc",
|
||||
"lib/modules/*/*.pyc",
|
||||
"lib/modules/*/*/*.pyc",
|
||||
|
||||
]
|
||||
|
||||
# 3. 소스 패키징에서 강제로 제외할 패키지들
|
||||
BASE_EXCLUDES = [
|
||||
'tkinter', 'PyQt4', 'PyQt5', 'AppKit', 'Foundation', 'IPython',
|
||||
'OpenSSL', 'curses', 'test', 'matplotlib', 'asyncpg',
|
||||
'importlib._bootstrap', 'importlib.machinery',
|
||||
'pytest', 'hypothesis', 'mypy', 'coverage', 'tox',
|
||||
'sympy', 'mpmath', 'gmpy2',
|
||||
'paddle', 'paddleocr', 'paddlehub', 'onnxruntime', 'onnx',
|
||||
'skimage', 'scikit-image', 'pyclipper', 'shapely',
|
||||
'scipy', 'imgaug', 'albumentations', 'torch', 'tensorflow', 'keras',
|
||||
"PySide6",
|
||||
# 프로젝트 특정 제외
|
||||
# 참고: CYTHON_MODULES에 포함된 모듈들은 get_cython_freeze_config()에서 자동으로 제외됨
|
||||
]
|
||||
|
||||
# 4. 강제 포함 패키지
|
||||
BASE_INCLUDES = [
|
||||
# 정말 최소한의 모듈만 포함하여 테스트
|
||||
'json', 'os', 'sys', 'time', 'threading',
|
||||
]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [Helper Functions] 로직 분리
|
||||
# ============================================================================
|
||||
|
||||
def run_setup_clean():
|
||||
"""
|
||||
기존 빌드찌꺼기 제거
|
||||
"""
|
||||
print("\n>>> [Cleaner] 기존 빌드찌꺼기 제거 시작...")
|
||||
# 자동으로 'y' 입력하여 확인 절차 생략
|
||||
result = subprocess.run(["python", "-c", "import sys; sys.path.insert(0, 'tests'); from setup_clean import clean_files_oswalk; clean_files_oswalk()"])
|
||||
if result.returncode == 0:
|
||||
print(">>> [Cleaner] 기존 빌드찌꺼기 제거 완료!\n")
|
||||
else:
|
||||
print(">>> [Cleaner] 정리 중 오류 발생, 계속 진행합니다.\n")
|
||||
|
||||
|
||||
def run_cython_build():
|
||||
"""
|
||||
setup_cython.py를 별도로 실행하지 않고,
|
||||
여기서 직접 Cython 빌드를 수행합니다. (경로 꼬임 방지)
|
||||
"""
|
||||
print("\n>>> [Cython] 내부 빌드 프로세스 시작...")
|
||||
|
||||
extensions = []
|
||||
|
||||
# 전역변수 CYTHON_MODULES 리스트를 사용
|
||||
for file_path in CYTHON_MODULES:
|
||||
# 파일 확인 (.py가 없는 경우 붙여서 확인)
|
||||
source_file = file_path
|
||||
if not os.path.exists(source_file):
|
||||
if os.path.exists(source_file + ".py"):
|
||||
source_file += ".py"
|
||||
else:
|
||||
print(f" [Warning] 컴파일 대상 파일을 찾을 수 없음: {file_path}")
|
||||
continue
|
||||
|
||||
# 모듈 이름 생성 (경로 -> 점 표기법)
|
||||
# 예: modules/image_worker -> modules.image_worker
|
||||
no_ext = os.path.splitext(source_file)[0]
|
||||
module_name = no_ext.replace("/", ".").replace("\\", ".")
|
||||
|
||||
# Extension 객체 생성
|
||||
ext = Extension(
|
||||
name=module_name,
|
||||
sources=[source_file]
|
||||
)
|
||||
extensions.append(ext)
|
||||
|
||||
if not extensions:
|
||||
print(">>> [Cython] 컴파일할 대상이 없습니다.")
|
||||
return
|
||||
|
||||
# setuptools의 setup을 직접 호출 (인자값으로 build_ext --inplace 전달)
|
||||
try:
|
||||
cy_setup(
|
||||
name="CythonInternalBuild",
|
||||
ext_modules=cythonize(
|
||||
extensions,
|
||||
compiler_directives={'language_level': "3"},
|
||||
quiet=True
|
||||
),
|
||||
script_args=['build_ext', '--inplace']
|
||||
)
|
||||
print(">>> [Cython] 빌드 성공!\n")
|
||||
except Exception as e:
|
||||
print(f"\n>>> [Cython] 빌드 중 오류 발생: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def get_cython_freeze_config(modules_list):
|
||||
"""
|
||||
Cython 모듈 리스트를 기반으로 cx_Freeze용 설정을 생성합니다.
|
||||
Returns:
|
||||
(excludes_list, include_files_list, include_packages_to_remove)
|
||||
"""
|
||||
excludes = [] # .py 소스 제외용
|
||||
include_files = [] # .pyd 파일 포함용
|
||||
module_names = set()
|
||||
|
||||
for module_path in modules_list:
|
||||
# 모듈 이름 변환 (modules/image_worker -> modules.image_worker)
|
||||
clean_path = os.path.splitext(module_path)[0]
|
||||
module_name = clean_path.replace("/", ".").replace("\\", ".")
|
||||
module_names.add(module_name)
|
||||
|
||||
excludes.append(module_name)
|
||||
|
||||
# .pyd 파일 찾기 및 매핑
|
||||
dir_name = os.path.dirname(module_path)
|
||||
base_name = os.path.basename(module_path)
|
||||
pyd_pattern = os.path.join(dir_name, f"{base_name}*.pyd")
|
||||
found_pyds = glob.glob(pyd_pattern)
|
||||
|
||||
if found_pyds:
|
||||
src_pyd = found_pyds[0]
|
||||
# 라이브러리 구조 유지: lib/modules/...
|
||||
dest_pyd = os.path.join("lib", dir_name, f"{base_name}.pyd")
|
||||
include_files.append((src_pyd, dest_pyd))
|
||||
print(f" [Protect] {module_name}: .py 제외, .pyd 포함")
|
||||
else:
|
||||
print(f" [Warning] .pyd 없음: {module_path}. (원본 .py가 포함될 수 있음)")
|
||||
|
||||
return excludes, include_files, module_names
|
||||
|
||||
|
||||
def collect_include_files():
|
||||
"""DLL, 리소스 파일 등 기타 include_files 리스트를 생성합니다."""
|
||||
# 디버그를 위해 최소한의 파일만 포함
|
||||
files = []
|
||||
|
||||
# VC Runtime Files만 포함 (최소한으로)
|
||||
vc_runtimes = [
|
||||
('C:/Windows/System32/vcruntime140.dll', 'vcruntime140.dll'),
|
||||
('C:/Windows/System32/msvcp140.dll', 'msvcp140.dll'),
|
||||
]
|
||||
|
||||
# VC 런타임 파일 존재 여부 확인 후 추가
|
||||
for src, dest in vc_runtimes:
|
||||
if os.path.exists(src):
|
||||
files.append((src, dest))
|
||||
|
||||
print(f"DEBUG: Including {len(files)} files")
|
||||
return files
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [Main Logic] 실행 로직 (빌드 시에만 실행)
|
||||
# ============================================================================
|
||||
|
||||
def prepare_build_options():
|
||||
"""빌드 옵션을 준비하는 함수"""
|
||||
global final_includes, final_excludes, final_include_files
|
||||
|
||||
# 0. 기존 빌드찌꺼기 제거
|
||||
run_setup_clean()
|
||||
|
||||
# 1. Cython 빌드 실행
|
||||
run_cython_build()
|
||||
|
||||
# 2. cx_Freeze 설정 준비
|
||||
cy_excludes, cy_include_files, cy_module_names = get_cython_freeze_config(CYTHON_MODULES)
|
||||
resource_include_files = collect_include_files()
|
||||
|
||||
# 3. 최종 옵션 조합
|
||||
final_includes = [mod for mod in BASE_INCLUDES if mod not in cy_module_names] # 충돌 방지
|
||||
final_excludes = BASE_EXCLUDES + cy_excludes
|
||||
final_include_files = resource_include_files + cy_include_files
|
||||
|
||||
# 디버그: 문제가 되는 모듈들을 임시로 제외
|
||||
if EXCLUDE_PROBLEMATIC_MODULES and DEBUG_MODE:
|
||||
print("=== EXCLUDING PROBLEMATIC MODULES FOR DEBUG ===")
|
||||
|
||||
# 문제가 될 수 있는 모듈들 임시 제외
|
||||
problematic_modules = [
|
||||
'onnx_ocr_module', 'onnx_ocr_wrapper', 'onnx_ocr_module.src.onnx_ocr_wrapper',
|
||||
'modules.onnx_ocr_module', 'modules.onnx_ocr_module.src.onnx_ocr_wrapper',
|
||||
'paddle', 'paddleocr', 'onnxruntime', 'onnx'
|
||||
]
|
||||
|
||||
original_count = len(final_includes)
|
||||
final_includes = [mod for mod in final_includes if not any(pm in mod for pm in problematic_modules)]
|
||||
|
||||
# include_files에서도 제외
|
||||
final_include_files = [f for f in final_include_files if not any(pm in str(f) for pm in problematic_modules)]
|
||||
|
||||
print(f"Excluded problematic modules. Includes: {original_count} -> {len(final_includes)}")
|
||||
print(f"Excluded problematic files. Include files: {len(resource_include_files + cy_include_files)} -> {len(final_include_files)}")
|
||||
print("=" * 50)
|
||||
|
||||
# 디버그 모드 설정
|
||||
DEBUG_MODE = True
|
||||
|
||||
# 문제가 되는 큰 모듈들을 임시로 제외하여 테스트
|
||||
EXCLUDE_PROBLEMATIC_MODULES = True
|
||||
|
||||
# 0. 기존 빌드찌꺼기 제거
|
||||
run_setup_clean()
|
||||
|
||||
# 1. Cython 빌드 실행
|
||||
run_cython_build()
|
||||
|
||||
# 2. cx_Freeze 설정 준비
|
||||
cy_excludes, cy_include_files, cy_module_names = get_cython_freeze_config(CYTHON_MODULES)
|
||||
resource_include_files = collect_include_files()
|
||||
|
||||
# 3. 최종 옵션 조합
|
||||
final_includes = [mod for mod in BASE_INCLUDES if mod not in cy_module_names] # 충돌 방지
|
||||
final_excludes = BASE_EXCLUDES + cy_excludes
|
||||
final_include_files = resource_include_files + cy_include_files
|
||||
|
||||
# 디버그: 문제가 되는 모듈들을 임시로 제외
|
||||
if EXCLUDE_PROBLEMATIC_MODULES and DEBUG_MODE:
|
||||
print("=== EXCLUDING PROBLEMATIC MODULES FOR DEBUG ===")
|
||||
|
||||
# 문제가 될 수 있는 모듈들 임시 제외
|
||||
problematic_modules = [
|
||||
'onnx_ocr_module', 'onnx_ocr_wrapper', 'onnx_ocr_module.src.onnx_ocr_wrapper',
|
||||
'modules.onnx_ocr_module', 'modules.onnx_ocr_module.src.onnx_ocr_wrapper',
|
||||
'paddle', 'paddleocr', 'onnxruntime', 'onnx'
|
||||
]
|
||||
|
||||
original_count = len(final_includes)
|
||||
final_includes = [mod for mod in final_includes if not any(pm in mod for pm in problematic_modules)]
|
||||
|
||||
# include_files에서도 제외
|
||||
final_include_files = [f for f in final_include_files if not any(pm in str(f) for pm in problematic_modules)]
|
||||
|
||||
print(f"Excluded problematic modules. Includes: {original_count} -> {len(final_includes)}")
|
||||
print(f"Excluded problematic files. Include files: {len(resource_include_files + cy_include_files)} -> {len(final_include_files)}")
|
||||
print("=" * 50)
|
||||
|
||||
build_options = {
|
||||
'packages': [
|
||||
'ctypes', 'asyncio', 'subprocess', 'pyperclip', 'numpy',
|
||||
'requests', 'PIL', 'bs4', 'psutil',
|
||||
'openai', 'httpx', 'pydantic', # fastapi, uvicorn 제외 (설치 안됨)
|
||||
'pandas', 'supabase', 'translatepy', 'markdown',
|
||||
'json', 'json.encoder', 'json.decoder', 'json.scanner',
|
||||
'dotenv', 'pathlib', 'logging', 'threading', 'multiprocessing',
|
||||
# 'cv2', # OpenCV 제외 (DLL 문제)
|
||||
],
|
||||
'includes': final_includes,
|
||||
'excludes': final_excludes,
|
||||
'include_files': final_include_files,
|
||||
'zip_include_packages': [],
|
||||
'optimize': 0,
|
||||
'silent': False if DEBUG_MODE else True, # 디버그 모드에서는 silent 해제
|
||||
'include_msvcr': True,
|
||||
}
|
||||
|
||||
# 디버그 모드 추가 설정
|
||||
if DEBUG_MODE:
|
||||
print("=== DEBUG MODE ENABLED ===")
|
||||
print(f"Final includes count: {len(final_includes)}")
|
||||
print(f"Final excludes count: {len(final_excludes)}")
|
||||
print(f"Final include_files count: {len(final_include_files)}")
|
||||
|
||||
# 문제가 될 수 있는 큰 모듈들을 임시로 제외해서 테스트
|
||||
print("Checking for problematic modules...")
|
||||
|
||||
# 큰 모듈들을 확인
|
||||
large_modules = [m for m in final_includes if any(x in m for x in ['onnx', 'paddle', 'torch', 'tensorflow'])]
|
||||
if large_modules:
|
||||
print(f"Large modules detected: {large_modules}")
|
||||
|
||||
# 포함 파일들 확인
|
||||
print("Include files:")
|
||||
for f in final_include_files[:10]: # 처음 10개만
|
||||
print(f" {f}")
|
||||
if len(final_include_files) > 10:
|
||||
print(f" ... and {len(final_include_files) - 10} more")
|
||||
print("=" * 50)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [Custom Build Class] 빌드 프로세스 커스터마이징
|
||||
# ============================================================================
|
||||
|
||||
class CustomBuildExe(_build_exe):
|
||||
def run(self):
|
||||
# 전체 진행률: 100
|
||||
# 초기값 10: Cython 빌드는 이 클래스 실행 전(if __name__...)에 이미 완료됨
|
||||
print("\n" + "="*60)
|
||||
print(" ImgWorker 통합 빌드 시스템")
|
||||
print("="*60 + "\n")
|
||||
try:
|
||||
# 총 6단계로 구성 (Cython은 이미 완료됨 -> 10%)
|
||||
with tqdm(total=100, initial=10, unit="pct",
|
||||
bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt}% [{elapsed}]",
|
||||
ncols=80, colour='green') as pbar:
|
||||
|
||||
# [Step 2] cx_Freeze 빌드 (10 -> 30%)
|
||||
pbar.set_description("Step 2/6: cx_Freeze Packaging")
|
||||
print(f"DEBUG: Starting cx_Freeze build with {len(build_options.get('includes', []))} includes")
|
||||
print(f"DEBUG: {len(build_options.get('excludes', []))} excludes")
|
||||
print(f"DEBUG: {len(build_options.get('include_files', []))} include_files")
|
||||
print(f"DEBUG: {len(build_options.get('packages', []))} packages")
|
||||
|
||||
# cx_Freeze 실행 전 타임스탬프
|
||||
import time
|
||||
start_time = time.time()
|
||||
print(f"DEBUG: Build start time: {start_time}")
|
||||
|
||||
_build_exe.run(self)
|
||||
|
||||
end_time = time.time()
|
||||
print(f"DEBUG: Build end time: {end_time}")
|
||||
print(f"DEBUG: Build duration: {end_time - start_time:.2f} seconds")
|
||||
pbar.update(20)
|
||||
|
||||
|
||||
# [Step 3] 빌드 결과물 정리 (30 -> 35%)
|
||||
pbar.set_description("Step 3/6: Cleaning Build Output")
|
||||
self._cleanup_build_output()
|
||||
pbar.update(5)
|
||||
|
||||
# [Step 4] Inno Setup 컴파일 (35 -> 70%)
|
||||
pbar.set_description("Step 4/6: Inno Setup Compiling")
|
||||
self._run_inno_setup() # .iss 생성
|
||||
self._compile_inno_installer() # .exe 생성
|
||||
pbar.update(35)
|
||||
|
||||
|
||||
# [Step 5] 파일명 변경 (버전 정보 추가) (70 -> 75%)
|
||||
pbar.set_description("Step 5/6: Renaming Installer")
|
||||
final_installer_path = self._rename_installer_with_version()
|
||||
pbar.update(5)
|
||||
|
||||
# [Step 6] Gokapi 업로드 (75 -> 95%)
|
||||
if final_installer_path and GOKAPI_URL != "https://gokapi.your-domain.com":
|
||||
pbar.set_description("Step 6/6: Uploading to Gokapi")
|
||||
self._upload_to_gokapi(final_installer_path)
|
||||
else:
|
||||
print("\n[Skip] Gokapi 설정이 없거나 파일이 없어 업로드를 건너뜁니다.")
|
||||
pbar.update(20)
|
||||
|
||||
# [Final] 소스 정리 (95 -> 100%)
|
||||
pbar.set_description("Finalizing: Cleaning Source")
|
||||
self._cleanup_cython_artifacts()
|
||||
pbar.update(5)
|
||||
|
||||
pbar.set_description("Build Complete!")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n>>> 빌드가 취소되었습니다.")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"\n\n>>> 빌드 중 치명적인 오류 발생: {e}")
|
||||
# 에러 상세 내용을 보기 위해 traceback 출력
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
def _get_dir_size(self, path):
|
||||
total = 0
|
||||
for dirpath, _, filenames in os.walk(path):
|
||||
for f in filenames:
|
||||
total += os.path.getsize(os.path.join(dirpath, f))
|
||||
return total
|
||||
|
||||
def _rename_installer_with_version(self):
|
||||
"""생성된 설치 파일 이름에 버전을 추가합니다."""
|
||||
# Inno Setup의 기본 출력 경로 (dist/installer)
|
||||
dist_dir = os.path.join(os.path.dirname(__file__), "dist", "installer")
|
||||
|
||||
# 원래 생성되는 파일명 (generate_iss.py 설정에 따름, 보통 Setup.exe)
|
||||
# 로그에 찍힌 이름을 기준으로 찾습니다.
|
||||
original_name = "ImgWorker Setup.exe"
|
||||
original_path = os.path.join(dist_dir, original_name)
|
||||
|
||||
if not os.path.exists(original_path):
|
||||
print(f"\n[Error] 원본 설치 파일을 찾을 수 없습니다: {original_path}")
|
||||
# 혹시 다른 이름일 수도 있으니 exe 파일을 검색해봅니다.
|
||||
exe_files = glob.glob(os.path.join(dist_dir, "*.exe"))
|
||||
if exe_files:
|
||||
original_path = max(exe_files, key=os.path.getctime) # 가장 최신 파일
|
||||
else:
|
||||
return None
|
||||
|
||||
# 새 파일명: ImgWorker Setup_V3.12.15.exe
|
||||
new_name = f"ImgWorker Setup_V{__version__}.exe"
|
||||
new_path = os.path.join(dist_dir, new_name)
|
||||
|
||||
try:
|
||||
# 기존에 같은 버전 파일이 있으면 삭제
|
||||
if os.path.exists(new_path):
|
||||
os.remove(new_path)
|
||||
|
||||
os.rename(original_path, new_path)
|
||||
print(f"\n[Rename] 파일명이 변경되었습니다:\n -> {new_name}")
|
||||
return new_path
|
||||
except Exception as e:
|
||||
print(f"\n[Error] 파일명 변경 실패: {e}")
|
||||
return original_path
|
||||
|
||||
def _upload_to_gokapi(self, file_path):
|
||||
"""Gokapi 서버로 파일을 업로드합니다."""
|
||||
print("\n[Upload] Gokapi 서버로 업로드를 시작합니다...")
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
print(" [Error] 업로드할 파일이 없습니다.")
|
||||
return
|
||||
|
||||
try:
|
||||
url = f"{GOKAPI_URL}/api/v1/upload"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {GOKAPI_APIKEY}"
|
||||
}
|
||||
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
# 멀티파트 업로드
|
||||
files = {"file": (os.path.basename(file_path), f)}
|
||||
data = {
|
||||
"expire": 0 # ✅ 영구 저장
|
||||
} # 만료일 설정 (예: 0=무제한, 7d=7일) - 필요시 data={'expiry': '7d'} 추가
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=headers,
|
||||
files=files,
|
||||
data=data,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if response.status_code in (200, 201):
|
||||
data = response.json()
|
||||
|
||||
# Gokapi 응답 구조
|
||||
hotlink = data.get("DownloadUrl") or data.get("File", {}).get("Url")
|
||||
|
||||
print("\n" + "#" * 50)
|
||||
print("업로드 성공!")
|
||||
print(f"다운로드 링크: {hotlink}")
|
||||
print("#" * 50 + "\n")
|
||||
else:
|
||||
print(f"\n[Error] 업로드 실패 (Status: {response.status_code})")
|
||||
print("Server Response:", response.text)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n[Error] 업로드 중 예외 발생: {e}")
|
||||
|
||||
def _cleanup_build_output(self):
|
||||
"""빌드된 exe 폴더 내부의 불필요한 파일 삭제"""
|
||||
print("\n[Cleaner] 빌드 결과물 정리 중...")
|
||||
build_dir = os.path.join(os.path.dirname(__file__), "build")
|
||||
|
||||
if not os.path.exists(build_dir): return
|
||||
|
||||
removed_count = 0
|
||||
total_saved = 0
|
||||
|
||||
for item in os.listdir(build_dir):
|
||||
if not item.startswith("exe."): continue
|
||||
|
||||
exe_dir = os.path.join(build_dir, item)
|
||||
for unnecessary in CLEANUP_TARGETS:
|
||||
target_path = os.path.join(exe_dir, unnecessary)
|
||||
try:
|
||||
if os.path.isdir(target_path):
|
||||
size = self._get_dir_size(target_path)
|
||||
shutil.rmtree(target_path)
|
||||
total_saved += size
|
||||
print(f" ✓ 디렉토리 삭제: {unnecessary}")
|
||||
elif os.path.isfile(target_path):
|
||||
size = os.path.getsize(target_path)
|
||||
os.remove(target_path)
|
||||
total_saved += size
|
||||
print(f" ✓ 파일 삭제: {unnecessary}")
|
||||
removed_count += 1
|
||||
except Exception:
|
||||
pass # 없는 파일은 무시
|
||||
|
||||
print(f"[Cleaner] 정리 완료: {total_saved / (1024*1024):.1f} MB 절약\n")
|
||||
|
||||
def _cleanup_cython_artifacts(self):
|
||||
"""소스 폴더의 .pyd, .c 임시 파일 삭제"""
|
||||
print("[Cleaner] 소스 폴더 Cython 임시 파일 정리 중...")
|
||||
deleted = 0
|
||||
for module_path in CYTHON_MODULES:
|
||||
dir_name = os.path.dirname(module_path)
|
||||
base_name = os.path.basename(module_path)
|
||||
patterns = [
|
||||
os.path.join(dir_name, f"{base_name}*.pyd"),
|
||||
os.path.join(dir_name, f"{base_name}.c")
|
||||
]
|
||||
for pattern in patterns:
|
||||
for f in glob.glob(pattern):
|
||||
try:
|
||||
os.remove(f)
|
||||
deleted += 1
|
||||
except Exception: pass
|
||||
print(f"[Cleaner] 소스 정리 완료: {deleted}개 파일 삭제\n")
|
||||
|
||||
def _run_inno_setup(self):
|
||||
"""Inno Setup 스크립트 생성기 실행 (현재 프로젝트에는 없으므로 생략)"""
|
||||
print("[Inno Setup] 설치 스크립트 생성 생략 (generate_iss.py 없음)...")
|
||||
|
||||
def _compile_inno_installer(self):
|
||||
"""Inno Setup 컴파일 생략 (현재 프로젝트에는 설치 스크립트 없음)"""
|
||||
print("[Inno Setup] 설치 파일 컴파일 생략...")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [Setup] 최종 실행
|
||||
# ============================================================================
|
||||
|
||||
# 디버그용 간단한 테스트 함수
|
||||
def test_build():
|
||||
"""빌드 전 문제점을 미리 테스트"""
|
||||
print("=== BUILD TEST ===")
|
||||
|
||||
# 1. 모듈 import 테스트
|
||||
print("Testing module imports...")
|
||||
test_modules = ['fastapi', 'uvicorn', 'pydantic', 'cv2', 'numpy', 'PIL']
|
||||
for mod in test_modules:
|
||||
try:
|
||||
__import__(mod)
|
||||
print(f" OK {mod}")
|
||||
except ImportError as e:
|
||||
print(f" FAIL {mod}: {e}")
|
||||
|
||||
# 2. 큰 파일들 확인
|
||||
print("\nChecking large files...")
|
||||
large_files = []
|
||||
for root, dirs, dirs[:] in os.walk('.'):
|
||||
for file in dirs:
|
||||
if file in ['onnx_ocr_module', 'PP_Models', 'rembg_models', 'migan_onnx']:
|
||||
path = os.path.join(root, file)
|
||||
try:
|
||||
size = sum(os.path.getsize(os.path.join(dirpath, f))
|
||||
for dirpath, _, files in os.walk(path)
|
||||
for f in files)
|
||||
large_files.append((path, size))
|
||||
except:
|
||||
pass
|
||||
|
||||
for path, size in sorted(large_files, key=lambda x: x[1], reverse=True):
|
||||
print(f" {path}: {size / (1024*1024):.1f} MB")
|
||||
|
||||
print("=" * 50)
|
||||
|
||||
base = 'Win32GUI' if sys.platform == 'win32' else None
|
||||
|
||||
# 명령행 인자 처리
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if len(sys.argv) > 1 and sys.argv[1] == 'test':
|
||||
test_build()
|
||||
sys.exit(0)
|
||||
|
||||
# 빌드 옵션 준비 (빌드 시에만)
|
||||
prepare_build_options()
|
||||
|
||||
setup(
|
||||
name=__title__,
|
||||
version=__version__,
|
||||
description=__description__,
|
||||
author=__author__,
|
||||
author_email=__author_email__,
|
||||
license=__license__,
|
||||
packages=find_packages(),
|
||||
install_requires=__install_requires__,
|
||||
python_requires='>=3.11',
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
options={'build_exe': build_options},
|
||||
executables=[
|
||||
Executable(
|
||||
'main.py',
|
||||
base=base,
|
||||
target_name='ImgWorker.exe'
|
||||
# icon="Edit_PartTimer3.ico" # 아이콘 파일이 없으므로 기본 아이콘 사용
|
||||
)
|
||||
],
|
||||
cmdclass={
|
||||
"build_exe": CustomBuildExe,
|
||||
},
|
||||
)
|
||||
Binary file not shown.
|
|
@ -0,0 +1,148 @@
|
|||
import os
|
||||
import glob
|
||||
import fnmatch
|
||||
|
||||
def clean_files_oswalk():
|
||||
"""os.walk를 사용한 기존 방식 - 더 세밀한 컨트롤 가능"""
|
||||
# 1. 삭제할 확장자 정의
|
||||
target_extensions = ('.c', '.pyd')
|
||||
|
||||
# 2. 제외할 폴더 이름 (어디에 있든 이름이 일치하면 제외)
|
||||
# 대소문자를 정확히 입력해주세요. 필요시 '.git', '__pycache__' 등 추가
|
||||
exclude_folder_names = {
|
||||
'scripts', 'build', 'Lib', '.git', '__pycache__', 'venv',
|
||||
'env', '.env', '.venv', 'virtualenv', '.virtualenv',
|
||||
'conda', 'miniconda', 'anaconda', 'miniforge',
|
||||
'node_modules', 'dist', 'build', 'target',
|
||||
'.vscode', '.idea', '__pycache__', '.pytest_cache',
|
||||
'.mypy_cache', '.tox', '.coverage', '.DS_Store'
|
||||
}
|
||||
|
||||
# 3. 제외할 특정 경로 (현재 위치 기준 상대 경로)
|
||||
# 예: 'src/browsers'는 ./src/browsers 폴더 하위를 모두 제외함
|
||||
exclude_specific_paths = {'src/browsers'}
|
||||
|
||||
# 경로 구분자 통일 (Windows '\', Mac/Linux '/' 호환성 확보)
|
||||
exclude_specific_paths = {os.path.normpath(p) for p in exclude_specific_paths}
|
||||
|
||||
current_dir = os.getcwd() # 현재 실행 위치
|
||||
print(f"작업 시작 위치: {current_dir}")
|
||||
print("-" * 30)
|
||||
|
||||
for root, dirs, files in os.walk(current_dir):
|
||||
# [중요] dirs 리스트를 제자리에서 수정(slice assignment)하여
|
||||
# os.walk가 제외된 폴더로 진입하지 않도록 막습니다.
|
||||
|
||||
# 1차 필터링: 폴더 이름으로 제외 (예: build, Lib)
|
||||
dirs[:] = [d for d in dirs if d not in exclude_folder_names]
|
||||
|
||||
# 2차 필터링: 특정 경로로 제외 (예: src/browsers)
|
||||
allowed_dirs = []
|
||||
for d in dirs:
|
||||
# 현재 탐색 중인 폴더(root) + 하위 폴더(d)의 전체 경로 생성
|
||||
full_path = os.path.join(root, d)
|
||||
# 실행 위치 기준 상대 경로 계산
|
||||
rel_path = os.path.relpath(full_path, current_dir)
|
||||
|
||||
# 제외할 경로 리스트에 포함되어 있는지 확인
|
||||
if rel_path not in exclude_specific_paths:
|
||||
allowed_dirs.append(d)
|
||||
else:
|
||||
print(f"[제외됨] 경로: {rel_path}")
|
||||
|
||||
dirs[:] = allowed_dirs
|
||||
|
||||
# 파일 삭제 로직
|
||||
for file in files:
|
||||
if file.endswith(target_extensions):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
os.remove(file_path)
|
||||
print(f"삭제됨: {file_path}")
|
||||
except Exception as e:
|
||||
print(f"오류 발생 ({file}): {e}")
|
||||
|
||||
|
||||
def clean_files_glob():
|
||||
"""glob을 사용한 방식 - 더 간단하지만 덜 세밀한 컨트롤"""
|
||||
current_dir = os.getcwd()
|
||||
print(f"작업 시작 위치: {current_dir}")
|
||||
print("-" * 30)
|
||||
|
||||
# 제외할 폴더 패턴들
|
||||
exclude_patterns = [
|
||||
'**/venv/**', '**/env/**', '**/.env/**', '**/.venv/**',
|
||||
'**/virtualenv/**', '**/.virtualenv/**', '**/conda/**',
|
||||
'**/miniconda/**', '**/anaconda/**', '**/miniforge/**',
|
||||
'**/node_modules/**', '**/dist/**', '**/build/**', '**/target/**',
|
||||
'**/.git/**', '**/__pycache__/**', '**/.vscode/**', '**/.idea/**',
|
||||
'**/.pytest_cache/**', '**/.mypy_cache/**', '**/.tox/**', '**/.coverage/**'
|
||||
]
|
||||
|
||||
deleted_count = 0
|
||||
total_size = 0
|
||||
|
||||
# .c 파일들 찾기
|
||||
for c_file in glob.glob("**/*.c", recursive=True):
|
||||
if not _is_excluded_by_patterns(c_file, exclude_patterns):
|
||||
try:
|
||||
size = os.path.getsize(c_file)
|
||||
os.remove(c_file)
|
||||
print(f"삭제됨: {c_file} ({size} bytes)")
|
||||
deleted_count += 1
|
||||
total_size += size
|
||||
except Exception as e:
|
||||
print(f"오류 발생 ({c_file}): {e}")
|
||||
|
||||
# .pyd 파일들 찾기
|
||||
for pyd_file in glob.glob("**/*.pyd", recursive=True):
|
||||
if not _is_excluded_by_patterns(pyd_file, exclude_patterns):
|
||||
try:
|
||||
size = os.path.getsize(pyd_file)
|
||||
os.remove(pyd_file)
|
||||
print(f"삭제됨: {pyd_file} ({size} bytes)")
|
||||
deleted_count += 1
|
||||
total_size += size
|
||||
except Exception as e:
|
||||
print(f"오류 발생 ({pyd_file}): {e}")
|
||||
|
||||
print(f"\n총 {deleted_count}개 파일 삭제, {total_size} bytes 정리됨")
|
||||
|
||||
|
||||
def _is_excluded_by_patterns(file_path, exclude_patterns):
|
||||
"""파일 경로가 제외 패턴에 매칭되는지 확인"""
|
||||
for pattern in exclude_patterns:
|
||||
# glob 패턴을 fnmatch 스타일로 변환
|
||||
if fnmatch.fnmatch(file_path, pattern):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def clean_files():
|
||||
"""메인 정리 함수 - 기본적으로 oswalk 방식을 사용 (더 안전하고 세밀한 컨트롤)"""
|
||||
print("=== Cython 빌드 파일 정리기 ===")
|
||||
print("두 가지 정리 방식이 있습니다:")
|
||||
print("1: os.walk 방식 (세밀한 컨트롤, 폴더별 제외, 추천)")
|
||||
print("2: glob 방식 (간단하지만 덜 정밀)")
|
||||
|
||||
try:
|
||||
choice = input("방식을 선택하세요 (1 또는 2, 기본값: 1): ").strip()
|
||||
if choice == "2":
|
||||
print("glob 방식을 선택했습니다.")
|
||||
clean_files_glob()
|
||||
else:
|
||||
print("os.walk 방식을 선택했습니다.")
|
||||
clean_files_oswalk()
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n작업이 취소되었습니다.")
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 실수로 실행하는 것을 방지하기 위해 사용자 확인을 추가할 수 있습니다.
|
||||
confirm = input("현재 폴더 내의 모든 .c, .pyd 파일을 삭제하시겠습니까? (y/n): ")
|
||||
if confirm.lower() == 'y':
|
||||
clean_files()
|
||||
print("\n작업 완료.")
|
||||
else:
|
||||
print("취소되었습니다.")
|
||||
|
|
@ -0,0 +1,704 @@
|
|||
import sys
|
||||
import os
|
||||
import glob
|
||||
import shutil
|
||||
import subprocess
|
||||
import logging
|
||||
import time
|
||||
from tqdm import tqdm
|
||||
|
||||
import requests
|
||||
|
||||
from cx_Freeze import setup, Executable
|
||||
from setuptools import find_packages
|
||||
from setuptools.command.build_ext import build_ext
|
||||
from cx_Freeze.command.build_exe import build_exe as _build_exe
|
||||
|
||||
from setuptools import setup as cy_setup, Extension
|
||||
from Cython.Build import cythonize
|
||||
|
||||
|
||||
|
||||
# 버전 및 메타데이터 가져오기
|
||||
from updateManager.__version__ import (
|
||||
__title__, __version__, __description__, __author__,
|
||||
__author_email__, __license__, __install_requires__
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# [Configuration] 0. Gokapi 서버 설정 (업로드를 위해 필수)
|
||||
# ============================================================================
|
||||
GOKAPI_URL = "https://go.wrmc.cc" # 사용자님의 Gokapi 주소로 변경하세요
|
||||
GOKAPI_APIKEY = "Pncy6s7pyi61rtUsV3kUwfPtOvU93I " # Gokapi 관리자 페이지에서 발급받은 API 키
|
||||
|
||||
# ============================================================================
|
||||
# [Configuration] 설정 및 상수 정의
|
||||
# ============================================================================
|
||||
|
||||
# 1. Cython으로 컴파일할 모듈 목록
|
||||
CYTHON_MODULES = [
|
||||
# "browser_control",
|
||||
"locatorManager_by_SP",
|
||||
"src/contents/details",
|
||||
"src/contents/option",
|
||||
"src/contents/price",
|
||||
"src/contents/tags",
|
||||
"src/contents/thumb",
|
||||
"src/contents/titleGenerator",
|
||||
"src/img_module/image_processor_dialog",
|
||||
"src/img_module/image_processor_manager",
|
||||
"src/modules/image_worker_client",
|
||||
"src/lens/aliprice_lens_client",
|
||||
"src/lens/naver_lens_adapter",
|
||||
"src/lens/naver_lens_client",
|
||||
"src/lens/naver_lens_parser",
|
||||
"src/sp_manager",
|
||||
"src/titleManager/gpt_client",
|
||||
"src/titleManager/grok_client",
|
||||
"src/titleManager/naverAPI",
|
||||
"src/titleManager/naver_parser",
|
||||
"src/titleManager/sp_ForbiddenM",
|
||||
"src/translator/chinese_dict_manager",
|
||||
"src/translator/papago_translator",
|
||||
"src/AI_Module/ai_client",
|
||||
"src/AI_Module/baseAIProvider",
|
||||
"src/AI_Module/gpt_provider",
|
||||
"src/AI_Module/grok_provider",
|
||||
"src/AI_Module/openRouter_provider",
|
||||
"src/AI_Module/gemini_provider",
|
||||
]
|
||||
|
||||
# 2. 빌드 후 제거할 불필요한 파일/폴더 목록 (ImageProcessor3 관련 등)
|
||||
CLEANUP_TARGETS = [
|
||||
# 디렉토리
|
||||
"lib/src/modules/briaaiModel", "lib/src/modules/migan_onnx", "lib/src/modules/modules",
|
||||
"lib/src/modules/ocr_backends", "lib/src/modules/onnx_ocr_module", "lib/src/modules/output",
|
||||
"lib/src/modules/outputs", "lib/src/modules/PP_Models", "lib/src/modules/rembg_models",
|
||||
"lib/src/PP_Models", "lib/paddle", "lib/paddleocr", "lib/onnxruntime", "lib/skimage", "lib/scipy",
|
||||
# 파일
|
||||
"lib/src/modules/migan_traced.pt",
|
||||
"lib/src/modules/image_processor3.py", "lib/src/modules/image_processor4.py",
|
||||
"lib/src/modules/image_worker.py", "lib/src/modules/image_worker_manager.py",
|
||||
"lib/src/modules/ocr_module.py", "lib/src/modules/mask_module_for_paddle.py",
|
||||
"lib/src/modules/text_rendering_module.py", "lib/src/modules/postImageManager.py",
|
||||
"lib/src/modules/request_inpaint.py", "lib/src/modules/migan_module.py",
|
||||
"lib/src/modules/background_removal_module.py", "lib/src/modules/bria_background_removal_module.py",
|
||||
"lib/src/modules/gemma_client.py",
|
||||
# 컴파일된 잔재 (.pyc)
|
||||
"lib/src/modules/image_processor3.pyc", "lib/src/modules/image_processor4.pyc",
|
||||
"lib/src/modules/image_worker.pyc", "lib/src/modules/image_worker_manager.pyc",
|
||||
"lib/src/modules/ocr_module.pyc", "lib/src/modules/mask_module_for_paddle.pyc",
|
||||
"lib/src/modules/text_rendering_module.pyc", "lib/src/modules/postImageManager.pyc",
|
||||
"lib/src/modules/request_inpaint.pyc", "lib/src/modules/migan_module.pyc",
|
||||
"lib/src/modules/background_removal_module.pyc", "lib/src/modules/bria_background_removal_module.pyc",
|
||||
"lib/src/modules/gemma_client.pyc",
|
||||
|
||||
# [PySide6 다이어트]
|
||||
# 1. 번역 파일 (한국어/영어 외 제거하려면 통째로 지우거나 선별)
|
||||
# 보통 프로그램 내 텍스트가 한글이라면 Qt 자체 번역 파일은 없어도 됨
|
||||
"lib/PySide6/translations",
|
||||
|
||||
# 2. 불필요한 플러그인 (사용 안 하는 기능)
|
||||
# 주의: 'platforms', 'styles', 'imageformats'는 지우면 안 됩니다!
|
||||
"lib/PySide6/plugins/sqldrivers", # DB 안 쓰면 삭제
|
||||
"lib/PySide6/plugins/multimedia", # 오디오/비디오 안 쓰면 삭제
|
||||
"lib/PySide6/plugins/positioning", # GPS 안 쓰면 삭제
|
||||
"lib/PySide6/plugins/sensors", # 센서 안 쓰면 삭제
|
||||
"lib/PySide6/plugins/texttospeech", # TTS 안 쓰면 삭제
|
||||
"lib/PySide6/plugins/webview", # WebView 안 쓰면 삭제 (중요)
|
||||
|
||||
# 3. Qt Quick / QML 관련 (위젯 기반 앱이면 필요 없음)
|
||||
"lib/PySide6/qml",
|
||||
"lib/PySide6/Qt/qml",
|
||||
|
||||
# 4. 개발용/디자이너용 도구 (배포 시 불필요)
|
||||
"lib/PySide6/designer",
|
||||
"lib/PySide6/scripts",
|
||||
"lib/PySide6/examples",
|
||||
"lib/PySide6/glue",
|
||||
"lib/PySide6/include",
|
||||
|
||||
# 5. 대용량 DLL (사용 여부 확인 필요)
|
||||
# OpenGL 소프트웨어 렌더러 (그래픽 카드 없는 PC용, 보통 없어도 됨)
|
||||
"lib/PySide6/opengl32sw.dll",
|
||||
# QtWebEngine (크롬 브라우저 내장). 만약 Selenium/Playwright만 쓰고
|
||||
# Qt 위젯 내에서 브라우저를 띄우지 않는다면 지워도 됨 (엄청 큼!)
|
||||
"lib/PySide6/Qt6WebEngineCore.dll",
|
||||
"lib/PySide6/Qt6WebEngineCore.pyd",
|
||||
"lib/PySide6/Qt6WebEngineWidgets.dll",
|
||||
|
||||
]
|
||||
|
||||
# 3. 소스 패키징에서 강제로 제외할 패키지들
|
||||
BASE_EXCLUDES = [
|
||||
'tkinter', 'PyQt4', 'PyQt5', 'AppKit', 'Foundation', 'IPython',
|
||||
'OpenSSL', 'curses', 'test', 'matplotlib', 'asyncpg',
|
||||
'importlib._bootstrap', 'importlib.machinery',
|
||||
'pytest', 'hypothesis', 'mypy', 'coverage', 'tox',
|
||||
'sympy', 'mpmath', 'gmpy2',
|
||||
'paddle', 'paddleocr', 'paddlehub', 'onnxruntime', 'onnx',
|
||||
'skimage', 'scikit-image', 'pyclipper', 'shapely',
|
||||
'scipy', 'imgaug', 'albumentations', 'torch', 'tensorflow', 'keras',
|
||||
'src.modules.image_processor3', 'src.modules.image_processor4',
|
||||
'src.modules.image_worker', 'src.modules.image_worker_manager',
|
||||
'src.modules.ocr_module', 'src.modules.mask_module_for_paddle',
|
||||
'src.modules.text_rendering_module', 'src.modules.postImageManager',
|
||||
'src.modules.request_inpaint', 'src.modules.migan_module',
|
||||
'src.modules.background_removal_module', 'src.modules.bria_background_removal_module',
|
||||
'src.modules.gemma_client', 'src.modules.onnx_ocr_module',
|
||||
]
|
||||
|
||||
# 4. 강제 포함 패키지
|
||||
BASE_INCLUDES = [
|
||||
'browser_control',
|
||||
'loggerModule', 'toggleSwitch',
|
||||
'src.cmdDiag', 'src.inputDiag', 'src.keyword', 'src.priceSetDiag',
|
||||
# 'src.titleManager',
|
||||
# 'src.titleManager.naver_parser', 'src.titleManager.naverAPI', 'src.titleManager.gpt_client', 'src.titleManager.grok_client',
|
||||
# 'src.translator',
|
||||
# 'src.translator.chinese_dict_manager', 'src.translator.papago_translator',
|
||||
'src.discord_manager', 'src.unwantedDiag', 'src.unwantedDiag.unwanted_words_dialog',
|
||||
'src.logDialog', 'src.logDialog.log_dialog', 'src.logDialog.log_filter',
|
||||
'src.modules.settings_manager',
|
||||
'src.modules.gpu_status_checker', 'src.modules.gpu_utils', 'src.gpuDiag',
|
||||
# 'src.modules.image_worker_client', 'src.img_module.image_processor_manager', 'src.img_module.image_processor_dialog',
|
||||
'src.modules.fonts.fontSelectDialog',
|
||||
# 'src.lens.naver_lens_client', 'src.lens.naver_lens_parser', 'src.lens.naver_lens_adapter', 'src.lens.aliprice_lens_client',
|
||||
'translatepy', 'translatepy.translators', 'translatepy.translators.google',
|
||||
'PIL', 'PIL.Image', 'PIL.ImageOps', 'PIL.ImageEnhance', 'PIL.ImageFilter', 'PIL.features', 'cv2',
|
||||
'numpy', 'numpy.core', 'numpy.random',
|
||||
'supabase', 'gotrue', 'storage3', 'postgrest', 'supafunc', 'realtime',
|
||||
'pydantic', 'pydantic_core',
|
||||
'json', 'json.encoder', 'json.decoder', 'json.scanner',
|
||||
'httpx', 'httpx.__version__', 'httpx._models', 'httpx._client', 'httpx._config',
|
||||
'curl_cffi', 'curl_cffi.requests',
|
||||
'pathlib',
|
||||
'shiboken6',
|
||||
'greenlet',
|
||||
# 'playwright','playwright.async_api', 'playwright._impl',
|
||||
'PySide6.QtCore', 'PySide6.QtGui', 'PySide6.QtWidgets', 'PySide6.QtNetwork',
|
||||
'requests',
|
||||
'pandas',
|
||||
'sqlite3',
|
||||
'packaging',
|
||||
'markdown',
|
||||
'pyperclip',
|
||||
'psutil',
|
||||
'comtypes',
|
||||
'comtypes.stream',
|
||||
'win32com',
|
||||
'win32com.client',
|
||||
'win32com.server',
|
||||
'pythoncom',
|
||||
]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# [Helper Functions] 로직 분리
|
||||
# ============================================================================
|
||||
|
||||
def run_setup_clean():
|
||||
"""
|
||||
기존 빌드찌꺼기 제거
|
||||
"""
|
||||
print("\n>>> [Cleaner] 기존 빌드찌꺼기 제거 시작...")
|
||||
subprocess.run(["python", "setup_clean.py"])
|
||||
print(">>> [Cleaner] 기존 빌드찌꺼기 제거 완료!\n")
|
||||
|
||||
|
||||
def run_cython_build():
|
||||
"""
|
||||
setup_cython.py를 별도로 실행하지 않고,
|
||||
여기서 직접 Cython 빌드를 수행합니다. (경로 꼬임 방지)
|
||||
"""
|
||||
print("\n>>> [Cython] 내부 빌드 프로세스 시작...")
|
||||
|
||||
extensions = []
|
||||
|
||||
# 전역변수 CYTHON_MODULES 리스트를 사용
|
||||
for file_path in CYTHON_MODULES:
|
||||
# 파일 확인 (.py가 없는 경우 붙여서 확인)
|
||||
source_file = file_path
|
||||
if not os.path.exists(source_file):
|
||||
if os.path.exists(source_file + ".py"):
|
||||
source_file += ".py"
|
||||
else:
|
||||
print(f" [Warning] 컴파일 대상 파일을 찾을 수 없음: {file_path}")
|
||||
continue
|
||||
|
||||
# 모듈 이름 생성 (경로 -> 점 표기법)
|
||||
# 예: src/contents/option -> src.contents.option
|
||||
# 이렇게 해야 'src' 패키지 안의 모듈로 정확히 인식됩니다.
|
||||
no_ext = os.path.splitext(source_file)[0]
|
||||
module_name = no_ext.replace("/", ".").replace("\\", ".")
|
||||
|
||||
# Extension 객체 생성
|
||||
ext = Extension(
|
||||
name=module_name,
|
||||
sources=[source_file]
|
||||
)
|
||||
extensions.append(ext)
|
||||
|
||||
if not extensions:
|
||||
print(">>> [Cython] 컴파일할 대상이 없습니다.")
|
||||
return
|
||||
|
||||
# setuptools의 setup을 직접 호출 (인자값으로 build_ext --inplace 전달)
|
||||
try:
|
||||
cy_setup(
|
||||
name="CythonInternalBuild",
|
||||
ext_modules=cythonize(
|
||||
extensions,
|
||||
compiler_directives={'language_level': "3"},
|
||||
quiet=True
|
||||
),
|
||||
script_args=['build_ext', '--inplace']
|
||||
)
|
||||
print(">>> [Cython] 빌드 성공!\n")
|
||||
except Exception as e:
|
||||
print(f"\n>>> [Cython] 빌드 중 오류 발생: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def get_cython_freeze_config(modules_list):
|
||||
"""
|
||||
Cython 모듈 리스트를 기반으로 cx_Freeze용 설정을 생성합니다.
|
||||
Returns:
|
||||
(excludes_list, include_files_list, include_packages_to_remove)
|
||||
"""
|
||||
excludes = [] # .py 소스 제외용
|
||||
include_files = [] # .pyd 파일 포함용
|
||||
module_names = set()
|
||||
|
||||
for module_path in modules_list:
|
||||
# 모듈 이름 변환 (src/contents/price -> src.contents.price)
|
||||
clean_path = os.path.splitext(module_path)[0]
|
||||
module_name = clean_path.replace("/", ".").replace("\\", ".")
|
||||
module_names.add(module_name)
|
||||
|
||||
excludes.append(module_name)
|
||||
|
||||
# .pyd 파일 찾기 및 매핑
|
||||
dir_name = os.path.dirname(module_path)
|
||||
base_name = os.path.basename(module_path)
|
||||
pyd_pattern = os.path.join(dir_name, f"{base_name}*.pyd")
|
||||
found_pyds = glob.glob(pyd_pattern)
|
||||
|
||||
if found_pyds:
|
||||
src_pyd = found_pyds[0]
|
||||
# 라이브러리 구조 유지: lib/src/...
|
||||
dest_pyd = os.path.join("lib", dir_name, f"{base_name}.pyd")
|
||||
include_files.append((src_pyd, dest_pyd))
|
||||
print(f" [Protect] {module_name}: .py 제외, .pyd 포함")
|
||||
else:
|
||||
print(f" [Warning] .pyd 없음: {module_path}. (원본 .py가 포함될 수 있음)")
|
||||
|
||||
return excludes, include_files, module_names
|
||||
|
||||
def collect_include_files():
|
||||
"""DLL, 리소스 파일 등 기타 include_files 리스트를 생성합니다."""
|
||||
base_dir = os.path.dirname(__file__)
|
||||
updater_file = os.path.join(base_dir, 'updateManager', 'updater.exe')
|
||||
|
||||
# 기본 리소스 파일들
|
||||
files = [
|
||||
('src/Edit_PartTimer3.ico', 'lib/src/Edit_PartTimer3.ico'),
|
||||
('kiprisCategories.json', 'kiprisCategories.json'),
|
||||
('src/keyword/kiprisCategories.json', 'lib/src/keyword/kiprisCategories.json'),
|
||||
('src/modules/fonts', 'lib/src/modules/fonts'),
|
||||
(updater_file, 'updater.exe'),
|
||||
('퍼센티 다양한 카테고리 엑셀 수집(스스 기준).xlsx', '퍼센티 다양한 카테고리 엑셀 수집(스스 기준).xlsx'),
|
||||
('src/Percenty_SS_Code.json', 'lib/src/Percenty_SS_Code.json'),
|
||||
('src/browsers/chromium-1200', 'lib/src/browsers/chromium-1200'),
|
||||
('src/browsers/extensions', 'lib/src/browsers/extensions'),
|
||||
('C:/Windows/System32/vcomp140.dll', 'vcomp140.dll'),
|
||||
]
|
||||
|
||||
# VC Runtime Files
|
||||
vc_runtimes = [
|
||||
('C:/Windows/System32/vcruntime140.dll', 'vcruntime140.dll'),
|
||||
('C:/Windows/System32/vcruntime140_1.dll', 'vcruntime140_1.dll'),
|
||||
('C:/Windows/System32/msvcp140.dll', 'msvcp140.dll'),
|
||||
('C:/Windows/System32/msvcp140_1.dll', 'msvcp140_1.dll'),
|
||||
('C:/Windows/System32/msvcp140_2.dll', 'msvcp140_2.dll'),
|
||||
('C:/Windows/System32/concrt140.dll', 'concrt140.dll'),
|
||||
('C:/Windows/System32/vcomp140.dll', 'vcomp140.dll'),
|
||||
]
|
||||
files.extend(vc_runtimes)
|
||||
|
||||
dll_files = [
|
||||
# "LIBPQ.dll",
|
||||
# "MIMAPI64.dll",
|
||||
# "Qt63DQuickScene3D.dll",
|
||||
"api-ms-win-core-com-l1-1-0.dll",
|
||||
"api-ms-win-core-debug-l1-1-0.dll",
|
||||
"api-ms-win-core-errorhandling-l1-1-0.dll",
|
||||
"api-ms-win-core-handle-l1-1-0.dll",
|
||||
"api-ms-win-core-heap-l1-1-0.dll",
|
||||
# "api-ms-win-core-heap-l2-1-0.dll",
|
||||
"api-ms-win-core-interlocked-l1-1-0.dll",
|
||||
# "api-ms-win-core-libraryloader-l1-2-0.dll",
|
||||
# "api-ms-win-core-libraryloader-l1-2-1.dll",
|
||||
"api-ms-win-core-localization-l1-2-0.dll",
|
||||
# "api-ms-win-core-path-l1-1-0.dll",
|
||||
"api-ms-win-core-processthreads-l1-1-0.dll",
|
||||
"api-ms-win-core-processthreads-l1-1-1.dll",
|
||||
"api-ms-win-core-profile-l1-1-0.dll",
|
||||
# "api-ms-win-core-realtime-l1-1-1.dll",
|
||||
"api-ms-win-core-rtlsupport-l1-1-0.dll",
|
||||
"api-ms-win-core-synch-l1-1-0.dll",
|
||||
"api-ms-win-core-synch-l1-2-0.dll",
|
||||
"api-ms-win-core-sysinfo-l1-1-0.dll",
|
||||
# "api-ms-win-core-winrt-error-l1-1-0.dll",
|
||||
# "api-ms-win-core-winrt-l1-1-0.dll",
|
||||
# "api-ms-win-core-winrt-string-l1-1-0.dll",
|
||||
"api-ms-win-crt-conio-l1-1-0.dll",
|
||||
"api-ms-win-crt-environment-l1-1-0.dll",
|
||||
"api-ms-win-crt-filesystem-l1-1-0.dll",
|
||||
"api-ms-win-crt-multibyte-l1-1-0.dll",
|
||||
"api-ms-win-crt-private-l1-1-0.dll",
|
||||
"api-ms-win-crt-process-l1-1-0.dll",
|
||||
"api-ms-win-crt-time-l1-1-0.dll",
|
||||
"api-ms-win-crt-utility-l1-1-0.dll",
|
||||
# "api-ms-win-power-base-l1-1-0.dll",
|
||||
# "api-ms-win-power-setting-l1-1-0.dll",
|
||||
# "api-ms-win-shcore-scaling-l1-1-1.dll",
|
||||
]
|
||||
|
||||
# DLL 파일 처리
|
||||
system32_path = "C:/Windows/System32/downlevel"
|
||||
dll_include_files = [(os.path.join(system32_path, dll), dll) for dll in dll_files]
|
||||
files.extend(dll_include_files)
|
||||
|
||||
# 경로 존재 확인
|
||||
final_files = []
|
||||
for src, dest in files:
|
||||
if os.path.exists(src):
|
||||
final_files.append((src, dest))
|
||||
else:
|
||||
print(f" [Error] 경로 없음(Skip): {src}")
|
||||
|
||||
return final_files
|
||||
|
||||
# ============================================================================
|
||||
# [Main Logic] 실행 로직
|
||||
# ============================================================================
|
||||
|
||||
# 0. 기존 빌드찌꺼기 제거
|
||||
|
||||
run_setup_clean()
|
||||
|
||||
# 1. Cython 빌드 실행
|
||||
run_cython_build()
|
||||
|
||||
# 2. cx_Freeze 설정 준비
|
||||
cy_excludes, cy_include_files, cy_module_names = get_cython_freeze_config(CYTHON_MODULES)
|
||||
resource_include_files = collect_include_files()
|
||||
|
||||
# 3. 최종 옵션 조합
|
||||
final_includes = [mod for mod in BASE_INCLUDES if mod not in cy_module_names] # 충돌 방지
|
||||
final_excludes = BASE_EXCLUDES + cy_excludes
|
||||
final_include_files = resource_include_files + cy_include_files
|
||||
|
||||
build_options = {
|
||||
'packages': [
|
||||
'ctypes', 'asyncio', 'subprocess', 'pyperclip', 'numpy',
|
||||
'requests', 'PIL', 'bs4', 'psutil',
|
||||
'openai',
|
||||
'pandas', 'supabase', 'translatepy', 'markdown',
|
||||
'json', 'json.encoder', 'json.decoder', 'json.scanner',
|
||||
'playwright',
|
||||
# 'PySide6',
|
||||
],
|
||||
'includes': final_includes,
|
||||
'excludes': final_excludes,
|
||||
'include_files': final_include_files,
|
||||
'zip_include_packages': [],
|
||||
'optimize': 0,
|
||||
'silent': True,
|
||||
'include_msvcr': True,
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# [Custom Build Class] 빌드 프로세스 커스터마이징
|
||||
# ============================================================================
|
||||
|
||||
class CustomBuildExe(_build_exe):
|
||||
def run(self):
|
||||
# 전체 진행률: 100
|
||||
# 초기값 10: Cython 빌드는 이 클래스 실행 전(if __name__...)에 이미 완료됨
|
||||
print("\n" + "="*60)
|
||||
print(" AutoPercenty3 통합 빌드 시스템 시작")
|
||||
print("="*60 + "\n")
|
||||
try:
|
||||
# 총 6단계로 구성 (Cython은 이미 완료됨 -> 10%)
|
||||
with tqdm(total=100, initial=10, unit="pct",
|
||||
bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt}% [{elapsed}]",
|
||||
ncols=80, colour='green') as pbar:
|
||||
|
||||
# [Step 2] cx_Freeze 빌드 (10 -> 30%)
|
||||
pbar.set_description("Step 2/6: cx_Freeze Packaging")
|
||||
_build_exe.run(self)
|
||||
pbar.update(20)
|
||||
|
||||
|
||||
# [Step 3] 빌드 결과물 정리 (30 -> 35%)
|
||||
pbar.set_description("Step 3/6: Cleaning Build Output")
|
||||
self._cleanup_build_output()
|
||||
pbar.update(5)
|
||||
|
||||
# [Step 4] Inno Setup 컴파일 (35 -> 70%)
|
||||
pbar.set_description("Step 4/6: Inno Setup Compiling")
|
||||
self._run_inno_setup() # .iss 생성
|
||||
self._compile_inno_installer() # .exe 생성
|
||||
pbar.update(35)
|
||||
|
||||
|
||||
# [Step 5] 파일명 변경 (버전 정보 추가) (70 -> 75%)
|
||||
pbar.set_description("Step 5/6: Renaming Installer")
|
||||
final_installer_path = self._rename_installer_with_version()
|
||||
pbar.update(5)
|
||||
|
||||
# [Step 6] Gokapi 업로드 (75 -> 95%)
|
||||
if final_installer_path and GOKAPI_URL != "https://gokapi.your-domain.com":
|
||||
pbar.set_description("Step 6/6: Uploading to Gokapi")
|
||||
self._upload_to_gokapi(final_installer_path)
|
||||
else:
|
||||
print("\n[Skip] Gokapi 설정이 없거나 파일이 없어 업로드를 건너뜁니다.")
|
||||
pbar.update(20)
|
||||
|
||||
# [Final] 소스 정리 (95 -> 100%)
|
||||
pbar.set_description("Finalizing: Cleaning Source")
|
||||
self._cleanup_cython_artifacts()
|
||||
pbar.update(5)
|
||||
|
||||
pbar.set_description("Build Complete!")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n>>> 빌드가 취소되었습니다.")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"\n\n>>> 빌드 중 치명적인 오류 발생: {e}")
|
||||
# 에러 상세 내용을 보기 위해 traceback 출력
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
def _get_dir_size(self, path):
|
||||
total = 0
|
||||
for dirpath, _, filenames in os.walk(path):
|
||||
for f in filenames:
|
||||
total += os.path.getsize(os.path.join(dirpath, f))
|
||||
return total
|
||||
|
||||
def _rename_installer_with_version(self):
|
||||
"""생성된 설치 파일 이름에 버전을 추가합니다."""
|
||||
# Inno Setup의 기본 출력 경로 (dist/installer)
|
||||
dist_dir = os.path.join(os.path.dirname(__file__), "dist", "installer")
|
||||
|
||||
# 원래 생성되는 파일명 (generate_iss.py 설정에 따름, 보통 Setup.exe)
|
||||
# 로그에 찍힌 이름을 기준으로 찾습니다.
|
||||
original_name = "Edit_PartTimer Setup.exe"
|
||||
original_path = os.path.join(dist_dir, original_name)
|
||||
|
||||
if not os.path.exists(original_path):
|
||||
print(f"\n[Error] 원본 설치 파일을 찾을 수 없습니다: {original_path}")
|
||||
# 혹시 다른 이름일 수도 있으니 exe 파일을 검색해봅니다.
|
||||
exe_files = glob.glob(os.path.join(dist_dir, "*.exe"))
|
||||
if exe_files:
|
||||
original_path = max(exe_files, key=os.path.getctime) # 가장 최신 파일
|
||||
else:
|
||||
return None
|
||||
|
||||
# 새 파일명: Edit_PartTimer Setup_V3.12.15.exe
|
||||
new_name = f"Edit_PartTimer Setup_V{__version__}.exe"
|
||||
new_path = os.path.join(dist_dir, new_name)
|
||||
|
||||
try:
|
||||
# 기존에 같은 버전 파일이 있으면 삭제
|
||||
if os.path.exists(new_path):
|
||||
os.remove(new_path)
|
||||
|
||||
os.rename(original_path, new_path)
|
||||
print(f"\n[Rename] 파일명이 변경되었습니다:\n -> {new_name}")
|
||||
return new_path
|
||||
except Exception as e:
|
||||
print(f"\n[Error] 파일명 변경 실패: {e}")
|
||||
return original_path
|
||||
|
||||
def _upload_to_gokapi(self, file_path):
|
||||
"""Gokapi 서버로 파일을 업로드합니다."""
|
||||
print("\n[Upload] Gokapi 서버로 업로드를 시작합니다...")
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
print(" [Error] 업로드할 파일이 없습니다.")
|
||||
return
|
||||
|
||||
try:
|
||||
url = f"{GOKAPI_URL}/api/v1/upload"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {GOKAPI_APIKEY}"
|
||||
}
|
||||
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
# 멀티파트 업로드
|
||||
files = {"file": (os.path.basename(file_path), f)}
|
||||
data = {
|
||||
"expire": 0 # ✅ 영구 저장
|
||||
} # 만료일 설정 (예: 0=무제한, 7d=7일) - 필요시 data={'expiry': '7d'} 추가
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=headers,
|
||||
files=files,
|
||||
data=data,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if response.status_code in (200, 201):
|
||||
data = response.json()
|
||||
|
||||
# Gokapi 응답 구조
|
||||
hotlink = data.get("DownloadUrl") or data.get("File", {}).get("Url")
|
||||
|
||||
print("\n" + "#" * 50)
|
||||
print("업로드 성공!")
|
||||
print(f"다운로드 링크: {hotlink}")
|
||||
print("#" * 50 + "\n")
|
||||
else:
|
||||
print(f"\n[Error] 업로드 실패 (Status: {response.status_code})")
|
||||
print("Server Response:", response.text)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n[Error] 업로드 중 예외 발생: {e}")
|
||||
|
||||
def _cleanup_build_output(self):
|
||||
"""빌드된 exe 폴더 내부의 불필요한 파일 삭제"""
|
||||
print("\n[Cleaner] 빌드 결과물 정리 중...")
|
||||
build_dir = os.path.join(os.path.dirname(__file__), "build")
|
||||
|
||||
if not os.path.exists(build_dir): return
|
||||
|
||||
removed_count = 0
|
||||
total_saved = 0
|
||||
|
||||
for item in os.listdir(build_dir):
|
||||
if not item.startswith("exe."): continue
|
||||
|
||||
exe_dir = os.path.join(build_dir, item)
|
||||
for unnecessary in CLEANUP_TARGETS:
|
||||
target_path = os.path.join(exe_dir, unnecessary)
|
||||
try:
|
||||
if os.path.isdir(target_path):
|
||||
size = self._get_dir_size(target_path)
|
||||
shutil.rmtree(target_path)
|
||||
total_saved += size
|
||||
print(f" ✓ 디렉토리 삭제: {unnecessary}")
|
||||
elif os.path.isfile(target_path):
|
||||
size = os.path.getsize(target_path)
|
||||
os.remove(target_path)
|
||||
total_saved += size
|
||||
print(f" ✓ 파일 삭제: {unnecessary}")
|
||||
removed_count += 1
|
||||
except Exception:
|
||||
pass # 없는 파일은 무시
|
||||
|
||||
print(f"[Cleaner] 정리 완료: {total_saved / (1024*1024):.1f} MB 절약\n")
|
||||
|
||||
def _cleanup_cython_artifacts(self):
|
||||
"""소스 폴더의 .pyd, .c 임시 파일 삭제"""
|
||||
print("[Cleaner] 소스 폴더 Cython 임시 파일 정리 중...")
|
||||
deleted = 0
|
||||
for module_path in CYTHON_MODULES:
|
||||
dir_name = os.path.dirname(module_path)
|
||||
base_name = os.path.basename(module_path)
|
||||
patterns = [
|
||||
os.path.join(dir_name, f"{base_name}*.pyd"),
|
||||
os.path.join(dir_name, f"{base_name}.c")
|
||||
]
|
||||
for pattern in patterns:
|
||||
for f in glob.glob(pattern):
|
||||
try:
|
||||
os.remove(f)
|
||||
deleted += 1
|
||||
except Exception: pass
|
||||
print(f"[Cleaner] 소스 정리 완료: {deleted}개 파일 삭제\n")
|
||||
|
||||
def _run_inno_setup(self):
|
||||
"""Inno Setup 스크립트 생성기 실행"""
|
||||
print("[Inno Setup] 설치 스크립트 생성 시작...")
|
||||
try:
|
||||
result = subprocess.run([sys.executable, "generate_iss.py"], capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
print("Inno Setup 스크립트 생성 완료!")
|
||||
else:
|
||||
print(f"Error: {result.stderr}")
|
||||
except Exception as e:
|
||||
print(f"Exception: {e}")
|
||||
|
||||
def _compile_inno_installer(self):
|
||||
"""
|
||||
생성된 .iss 파일을 ISCC.exe로 컴파일하여 최종 Setup.exe를 만듭니다.
|
||||
"""
|
||||
print("\n[Inno Setup] 최종 설치 파일(Setup.exe) 컴파일 시작...")
|
||||
|
||||
# 1. ISCC.exe 경로 설정 (Inno Setup 6 기준 기본 경로)
|
||||
# 만약 설치 경로가 다르다면 이 부분을 수정해주세요.
|
||||
iscc_path = r"C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
|
||||
|
||||
if not os.path.exists(iscc_path):
|
||||
print(f" [Error] Inno Setup 컴파일러를 찾을 수 없습니다: {iscc_path}")
|
||||
print(" Inno Setup 6가 설치되어 있는지 확인하거나, iscc_path를 수정하세요.")
|
||||
return
|
||||
|
||||
# 2. 가장 최신 .iss 파일 찾기 (AutoPercenty_날짜.iss 패턴)
|
||||
# generate_iss.py가 매번 새로운 이름으로 만들기 때문에 최신 파일을 찾아야 함
|
||||
iss_files = glob.glob("AutoPercenty_*.iss")
|
||||
if not iss_files:
|
||||
print(" [Error] 컴파일할 .iss 파일을 찾을 수 없습니다.")
|
||||
return
|
||||
|
||||
# 파일 생성 시간 기준으로 정렬하여 가장 마지막(최신) 파일 선택
|
||||
latest_iss = max(iss_files, key=os.path.getctime)
|
||||
print(f" Target Script: {latest_iss}")
|
||||
|
||||
# 3. 컴파일 명령어 실행
|
||||
try:
|
||||
# subprocess로 ISCC 실행
|
||||
print(" Compiling... (시간이 조금 걸릴 수 있습니다)")
|
||||
subprocess.run([iscc_path, latest_iss], check=True)
|
||||
print(" [Success] ★★★ 모든 작업 완료! 설치 파일이 생성되었습니다. ★★★\n")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f" [Error] Inno Setup 컴파일 실패: {e}")
|
||||
|
||||
# ============================================================================
|
||||
# [Setup] 최종 실행
|
||||
# ============================================================================
|
||||
|
||||
base = 'Win32GUI' if sys.platform == 'win32' else None
|
||||
|
||||
setup(
|
||||
name=__title__,
|
||||
version=__version__,
|
||||
description=__description__,
|
||||
author=__author__,
|
||||
author_email=__author_email__,
|
||||
license=__license__,
|
||||
packages=find_packages(),
|
||||
install_requires=__install_requires__,
|
||||
python_requires='>=3.11',
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
options={'build_exe': build_options},
|
||||
executables=[
|
||||
Executable(
|
||||
'main.py',
|
||||
base=base,
|
||||
target_name='Edit_PartTimer3.exe',
|
||||
icon="Edit_PartTimer3.ico"
|
||||
)
|
||||
],
|
||||
cmdclass={
|
||||
"build_exe": CustomBuildExe,
|
||||
},
|
||||
)
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,2 +1,13 @@
|
|||
__version__ = "1.3.0"
|
||||
__version__ = "1.3.6"
|
||||
|
||||
|
||||
__title__ = "ImgWorker"
|
||||
__description__ = "Image Worker"
|
||||
__author__ = "WhenRideMyCar"
|
||||
__author_email__ = "kkebiini@gmail.com"
|
||||
__license__ = "MIT"
|
||||
__install_requires__ = [
|
||||
"fastapi",
|
||||
"uvicorn",
|
||||
"pydantic",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,17 @@
|
|||
###1.3.5 ChangeLog
|
||||
- MIGAN 모델 경로를 내부 고정 경로로 변경 (toggle_states 의존성 제거)
|
||||
- MIGAN 초기화 실패 시 OpenCV(Telea) 인페인팅으로 자동 폴백
|
||||
- 인페인팅 폴백 로직 강화 (다양한 마스크 형태 지원)
|
||||
- 워커 재시작 전까지 안정적인 CV 모드 유지
|
||||
- Windows 로그 파일 롤링 문제 해결 (10MB 초과 시 0KB 되는 문제 수정)
|
||||
|
||||
###1.3.4 ChangeLog
|
||||
- 병렬 작업 처리(Concurrency) 개선 및 안정화
|
||||
- DirectML(GPU) 환경 교착상태(Deadlock) 해결을 위한 스마트 락(Lock) 적용
|
||||
- CPU 모드 시 병렬 처리 허용 및 점유율 최적화
|
||||
- 외부 인페인팅 서버 헬스 체크 기능 추가
|
||||
- 로그 파일 교착 상태 방지를 위한 프로세스별 로그 분리(PID 적용)
|
||||
|
||||
###1.3.0 ChangeLog
|
||||
- 서버코드정리
|
||||
- 메모리관리 최적화
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue