IMG_Worker/modules/client/image_worker_client.py

550 lines
23 KiB
Python

# -*- coding: utf-8 -*-
"""
ImageWorker FastAPI 클라이언트 헬퍼
- 이미지 URL 다운로드 → 로컬 경로 전달 방식으로 서버에 제출
- /v1/process-image, /v1/remove-background 호출 및 대기
"""
import os
import time
import uuid
import shutil
import mimetypes
import asyncio
import random
from typing import Dict, Any, Optional, List
import logging
import cv2 # for cleanup (destroyAllWindows)
import requests
import psutil
from urllib.parse import urlparse
import json
def _compat_result_from_job(job: Dict[str, Any]) -> Dict[str, Any]:
"""ImageProcessor3 결과와 호환되도록 키를 정규화."""
rr = (job or {}).get("result") or {}
result = {
"status": rr.get("status") or job.get("status"),
"path": rr.get("path"),
"inpaint_method": rr.get("inpaint_method"),
"inpaint_device": rr.get("inpaint_device"),
"timings": rr.get("timings"),
"child_results": rr.get("child_results"),
"message": rr.get("message") or rr.get("msg"),
"error": rr.get("error"),
}
# 누락 키 보장
for k in ("status", "path", "inpaint_method", "inpaint_device", "timings", "child_results", "message", "error"):
result.setdefault(k, None)
return result
def _read_server_info() -> Optional[Dict[str, Any]]:
"""ProgramData/ImgWorker/server.json에서 서버 정보를 읽어온다."""
try:
program_data = os.environ.get("PROGRAMDATA", r"C:\\ProgramData")
info_path = os.path.join(program_data, "ImgWorker", "server.json")
if os.path.isfile(info_path):
with open(info_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
return None
class ImageWorkerClient:
def __init__(self, logger, api_base: str = "http://127.0.0.1:8009", work_dir: Optional[str] = None, timeout: int = 30, max_concurrency: int = 8):
self.logger = logger
# API base 우선순위: 명시 인자 > IMGWK_API_BASE 환경변수 > server.json > 기본값
api = api_base
try:
env_api = os.environ.get("IMGWK_API_BASE")
if env_api and isinstance(env_api, str) and env_api.strip():
api = env_api.strip()
else:
# 인자가 기본값일 때만 server.json 자동 사용(명시적 인자 우선)
if api_base == "http://127.0.0.1:8009":
info = _read_server_info()
if isinstance(info, dict):
base = info.get("base") or (f"http://{info.get('host','127.0.0.1')}:{info.get('port',8009)}")
if base:
api = base
except Exception:
pass
self.api = (api or "http://127.0.0.1:8009").rstrip("/")
# 기본 작업 디렉토리: C:\ProgramData\ImgWorker\incoming
if work_dir is None:
program_data = os.environ.get("PROGRAMDATA", r"C:\\ProgramData")
work_dir = os.path.join(program_data, "ImgWorker", "incoming")
self.logger.log(f"work_dir: {work_dir}", level=logging.DEBUG)
os.makedirs(work_dir, exist_ok=True)
self.work_dir = work_dir
self.TEMP_IMAGE_DIR = work_dir # download_image 메서드 호환성을 위해
self.timeout = timeout
# 동시요청 제한(세마포어)
# try:
# n = int(max_concurrency)
# except Exception:
# n = 8
# self._sema = asyncio.Semaphore(max(1, n))
# def set_max_concurrency(self, n: int):
# """동시 요청 제한을 런타임에 조정"""
# try:
# n = int(n)
# except Exception:
# n = 1
# self._sema = asyncio.Semaphore(max(1, n))
def is_valid_image_data(self, data):
"""이미지 데이터의 유효성을 검사합니다"""
if not data or len(data) < 100: # 최소 크기 검사
return False
# JPEG, PNG, GIF, WebP 시그니처 검사
if data.startswith(b'\xff\xd8\xff'): # JPEG
return True
elif data.startswith(b'\x89PNG\r\n\x1a\n'): # PNG
return True
elif data.startswith(b'GIF87a') or data.startswith(b'GIF89a'): # GIF
return True
elif data.startswith(b'RIFF') and b'WEBP' in data[:12]: # WebP
return True
return False
# ---------------------- 유틸 ----------------------
def _guess_ext(self, url: str, content_type: Optional[str]) -> str:
# 1) 헤더 content-type
if content_type:
ext = mimetypes.guess_extension(content_type.split(";")[0].strip())
if ext:
return ext
# 2) URL 경로
try:
basename = os.path.basename(url.split("?")[0])
_, ext = os.path.splitext(basename)
if ext:
return ext
except Exception:
pass
# 3) 기본값
return ".jpg"
def download_image(self, image_url, index, file_prefix="", max_retries=3):
"""Requests를 사용해 이미지를 다운로드합니다"""
# 로컬 파일 경로면 바로 반환
if os.path.isfile(image_url):
self.logger.log(f"로컬 파일 경로 감지, 다운로드 생략: {image_url}", level=logging.DEBUG)
return image_url
# 로컬 파일 경로가 아니면 다운로드 시도
try:
# "https://assets.alicdn.com"으로 시작하는 URL은 건너뛰기
if image_url.startswith("https://assets.alicdn.com") or image_url.startswith("https://gtms01.alicdn.com"):
self.logger.log(f"다운로드 제외 URL: {image_url}", level=logging.DEBUG)
return None
# URL에서 파일명 추출 및 접두사 포함
parsed_url = urlparse(image_url)
base_filename = f"image_{index:03d}_{os.path.basename(parsed_url.path)}"
if not base_filename.endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')):
base_filename += '.jpg'
# 접두사가 있으면 파일명에 포함
if file_prefix:
filename = f"{file_prefix}_{base_filename}"
else:
filename = base_filename
local_path = os.path.join(self.TEMP_IMAGE_DIR, filename)
# HTTP 헤더 설정
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"DNT": "1", # Do Not Track 요청 헤더
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
"Cache-Control": "max-age=0"
}
retries = 0
while retries < max_retries:
try:
# 메모리 추적: 다운로드 시작 전
before_mem = psutil.virtual_memory()
before_mb = before_mem.used / 1024 / 1024
self.logger.log(f"이미지 다운로드 중: {filename}", level=logging.DEBUG)
response = requests.get(image_url, headers=headers, stream=True, timeout=30)
if response.status_code == 200:
image_data = response.content
# 이미지 데이터 유효성 검사
if self.is_valid_image_data(image_data):
with open(local_path, 'wb') as f:
f.write(image_data)
# 메모리 추적: 다운로드 완료 후
after_mem = psutil.virtual_memory()
after_mb = after_mem.used / 1024 / 1024
change_mb = after_mb - before_mb
change_percent = (change_mb / before_mb) * 100 if before_mb > 0 else 0
self.logger.log(
f"메모리 변화 [다운로드 완료]: {before_mb:.1f}MB -> {after_mb:.1f}MB "
f"({change_mb:+.1f}MB, {change_percent:+.1f}%) - {filename}",
level=logging.DEBUG if abs(change_mb) < 10 else logging.INFO
)
self.logger.log(f"이미지 다운로드 완료: {filename}", level=logging.DEBUG)
return local_path
else:
self.logger.log(f"유효하지 않은 이미지 데이터: {image_url}", level=logging.WARNING)
return None
else:
self.logger.log(f"이미지 다운로드 실패 (HTTP {response.status_code}): {image_url}. 재시도 {retries + 1}/{max_retries}", level=logging.ERROR)
retries += 1
if retries < max_retries:
time.sleep(random.randint(2, 5)) # 2~5초 대기 후 재시도
except requests.exceptions.RequestException as e:
self.logger.log(f"이미지 다운로드 중 네트워크 오류: {e}. 재시도 {retries + 1}/{max_retries}", level=logging.ERROR)
retries += 1
if retries < max_retries:
time.sleep(random.randint(2, 5)) # 예외 발생 시 대기 후 재시도
except Exception as e:
self.logger.log(f"이미지 다운로드 중 예상치 못한 오류: {e}. 재시도 {retries + 1}/{max_retries}", level=logging.ERROR)
retries += 1
if retries < max_retries:
time.sleep(random.randint(2, 5))
self.logger.log(f"이미지 다운로드 최대 재시도 횟수를 초과했습니다: {image_url}", level=logging.ERROR)
return None
except Exception as e:
self.logger.log(f"이미지 다운로드 중 오류: {e}", level=logging.ERROR, exc_info=True)
return None
async def health(self) -> Dict[str, Any]:
try:
hr = requests.get(f"{self.api}/health", timeout=10)
hr.raise_for_status()
return hr.json()
except Exception as e:
return {"status": "error", "error": str(e)}
# ---------------------- 제출/대기 ----------------------
async def submit_process_image(self, file_path: str, index: int, file_prefix: str,
font_type: str, unwanted_texts: List[str],
is_member_valid: bool, authenticated_by_admin: bool,
extra_overrides: Optional[Dict[str, Any]] = None,
ocr: Optional[bool] = None) -> str:
payload: Dict[str, Any] = {
"file_path": file_path,
"index": int(index),
"file_prefix": file_prefix,
# per-request 토글 오버라이드
"toggle_overrides": {
"font_type": font_type,
"unwanted_texts": list(unwanted_texts or []),
"is_member_valid": bool(is_member_valid),
"authenticated_by_admin": bool(authenticated_by_admin),
},
}
if extra_overrides:
payload["toggle_overrides"].update(extra_overrides)
# ocr 플래그가 명시되면 서버로도 전달
if ocr is not None:
payload["ocr"] = bool(ocr)
r = requests.post(f"{self.api}/v1/process-image", json=payload, timeout=self.timeout)
r.raise_for_status()
return r.json().get("job_id")
async def submit_remove_background(self, file_path: str, file_prefix: str,
extra_overrides: Optional[Dict[str, Any]] = None) -> str:
payload: Dict[str, Any] = {
"file_path": file_path,
"file_prefix": file_prefix,
}
if extra_overrides:
payload["toggle_overrides"] = extra_overrides
r = requests.post(f"{self.api}/v1/remove-background", json=payload, timeout=self.timeout)
r.raise_for_status()
return r.json().get("job_id")
async def wait_job(self, job_id: str, timeout_sec: int = 900) -> Dict[str, Any]:
end = time.time() + timeout_sec
while time.time() < end:
r = requests.get(f"{self.api}/v1/jobs/{job_id}", timeout=15)
if r.status_code == 200:
data = r.json()
if data.get("status") in ("done", "error", "cancelled"):
return data
await asyncio.sleep(0.2)
raise TimeoutError("job wait timeout")
# ---------------------- 고수준 URL 편의 ----------------------
async def process_image_url(self, image_url: str, index: int, file_prefix: str,
font_type: str, unwanted_texts: List[str],
is_member_valid: bool, authenticated_by_admin: bool,
extra_overrides: Optional[Dict[str, Any]] = None,
download_first: bool = True,
ocr: Optional[bool] = None) -> Optional[Dict[str, Any]]:
# 420 에러 방지를 위해 순차 처리 (세마포어 제거)
path = image_url
if download_first and (image_url.startswith("http://") or image_url.startswith("https://")):
# 동기식 download_image를 executor로 실행
loop = asyncio.get_event_loop()
path = await loop.run_in_executor(None, self.download_image, image_url, index, file_prefix)
jid = await self.submit_process_image(
file_path=path,
index=index,
file_prefix=file_prefix,
font_type=font_type,
unwanted_texts=unwanted_texts,
is_member_valid=is_member_valid,
authenticated_by_admin=authenticated_by_admin,
extra_overrides=extra_overrides,
ocr=ocr,
)
job = await self.wait_job(jid)
return _compat_result_from_job(job)
async def remove_background_url(self, image_url: str, file_prefix: str,
extra_overrides: Optional[Dict[str, Any]] = None,
download_first: bool = True) -> Optional[Dict[str, Any]]:
# 420 에러 방지를 위해 순차 처리 (세마포어 제거)
path = image_url
if download_first and (image_url.startswith("http://") or image_url.startswith("https://")):
# 동기식 download_image를 executor로 실행
loop = asyncio.get_event_loop()
path = await loop.run_in_executor(None, self.download_image, image_url, 0, file_prefix)
jid = await self.submit_remove_background(
file_path=path,
file_prefix=file_prefix,
extra_overrides=extra_overrides,
)
job = await self.wait_job(jid)
return _compat_result_from_job(job)
# ---------------------- 간단 제어 API (트레이에서 사용) ----------------------
def worker_status(self) -> Dict[str, Any]:
try:
r = requests.get(f"{self.api}/v1/worker/status", timeout=5)
r.raise_for_status()
return r.json()
except Exception as e:
return {"ready": False, "error": str(e)}
def worker_start(self) -> Dict[str, Any]:
try:
r = requests.post(f"{self.api}/v1/worker/start", timeout=10)
r.raise_for_status()
return r.json()
except Exception as e:
return {"ok": False, "error": str(e)}
def worker_stop(self) -> Dict[str, Any]:
try:
r = requests.post(f"{self.api}/v1/worker/stop", timeout=10)
r.raise_for_status()
return r.json()
except Exception as e:
return {"ok": False, "error": str(e)}
def shutdown_server(self) -> Dict[str, Any]:
try:
r = requests.post(f"{self.api}/v1/server/shutdown", timeout=5)
r.raise_for_status()
return r.json()
except Exception as e:
return {"ok": False, "error": str(e)}
# ---------------------- 호환성 메서드 (기존 ImageProcessor3와 동일한 인터페이스) ----------------------
async def process_single_image(self, original_image_url, index, delay=1.0, file_prefix="", ocr: Optional[bool] = None):
"""
기존 ImageProcessor3.process_single_image과 호환되는 메서드
Args:
original_image_url (str): 처리할 이미지 URL
index (int): 이미지 인덱스
delay (float): 요청 간격 (초) - 호환성을 위해 유지
file_prefix (str): 파일명에 추가할 접두사
Returns:
dict: 기존 ImageProcessor3과 동일한 포맷의 결과
- status: 'translated', 'original', 'exclude', 'failed' 중 하나
- path: 처리된 이미지 파일 경로 또는 원본 이미지 파일 경로
- error: 오류 메시지 (status가 'failed'인 경우에만 포함)
- inpaint_method: 사용된 인페인팅 방법
- inpaint_device: 사용된 인페인팅 장치
"""
try:
# 요청 간격 조절 (호환성을 위해)
if delay > 0:
await asyncio.sleep(delay)
# ImageWorkerClient를 통한 처리
result = await self.process_image_url(
image_url=original_image_url,
index=index,
file_prefix=file_prefix,
font_type="", # 기본값 사용 (필요시 외부에서 설정 가능)
unwanted_texts=[], # 기본값 사용 (필요시 외부에서 설정 가능)
is_member_valid=False, # 기본값 사용 (필요시 외부에서 설정 가능)
authenticated_by_admin=False, # 기본값 사용 (필요시 외부에서 설정 가능)
ocr=ocr,
)
if result and isinstance(result, dict):
# 서버 결과를 기존 포맷으로 변환
status = result.get("status", "failed")
path = result.get("path", original_image_url)
# status 매핑 (서버 결과에 따라 조정)
if status == "translated":
return {
'status': 'translated',
'path': path,
'inpaint_method': result.get('inpaint_method', 'unknown'),
'inpaint_device': result.get('inpaint_device', 'unknown')
}
elif status == "original":
return {
'status': 'original',
'path': path,
'inpaint_method': None,
'inpaint_device': None
}
elif status == "exclude":
return {
'status': 'exclude',
'path': path,
'inpaint_method': None,
'inpaint_device': None
}
else:
# 기타 상태는 실패로 처리
return {
'status': 'failed',
'path': original_image_url,
'error': f'Unknown status: {status}',
'inpaint_method': None,
'inpaint_device': None
}
else:
return {
'status': 'failed',
'path': original_image_url,
'error': 'No result from server',
'inpaint_method': None,
'inpaint_device': None
}
except Exception as e:
self.logger.log(f"process_single_image 호환성 메서드 오류: {e}", level=logging.ERROR, exc_info=True)
return {
'status': 'failed',
'path': original_image_url,
'error': str(e),
'inpaint_method': None,
'inpaint_device': None
}
async def remove_background(self, original_image_url, file_prefix=""):
"""
기존 ImageProcessor3.remove_background과 호환되는 메서드
Args:
original_image_url (str): 처리할 이미지 URL
file_prefix (str): 파일명에 추가할 접두사
Returns:
dict: 기존 ImageProcessor3과 동일한 포맷의 결과
- status: 'success', 'failed' 중 하나
- path: 처리된 이미지 파일 경로
- error: 오류 메시지 (status가 'failed'인 경우에만 포함)
"""
try:
# ImageWorkerClient를 통한 배경제거
result = await self.remove_background_url(
image_url=original_image_url,
file_prefix=file_prefix
)
if result and isinstance(result, dict):
status = result.get("status", "failed")
path = result.get("path", original_image_url)
if status == "success":
return {
'status': 'success',
'path': path
}
else:
return {
'status': 'failed',
'path': original_image_url,
'error': f'Remove background failed: {status}'
}
else:
return {
'status': 'failed',
'path': original_image_url,
'error': 'No result from server'
}
except Exception as e:
self.logger.log(f"remove_background 호환성 메서드 오류: {e}", level=logging.ERROR, exc_info=True)
return {
'status': 'failed',
'path': original_image_url,
'error': str(e)
}
def __del__(self):
"""소멸자에서 리소스 정리"""
self.cleanup()
self.logger.log("이미지 프로세서 소멸", level=logging.DEBUG)
def cleanup(self):
"""리소스 정리"""
try:
# Python GC 강제 실행
import gc
gc.collect()
# OpenCV 윈도우 정리
try:
cv2.destroyAllWindows()
except:
pass
# 임시 폴더 삭제
if hasattr(self, 'TEMP_IMAGE_DIR') and os.path.exists(self.TEMP_IMAGE_DIR):
# shutil.rmtree(self.TEMP_IMAGE_DIR)
self.logger.log(f"임시 폴더 삭제됨: {self.TEMP_IMAGE_DIR}", level=logging.DEBUG)
except Exception as e:
self.logger.log(f"리소스 정리 중 오류: {e}", level=logging.ERROR, exc_info=True)