브라우저 컨트롤러의 팝업 처리 로직에서 대기 시간을 단축하고, MIGAN ONNX 파이프라인을 설정하는 코드를 추가하였습니다. 또한, 이미지 처리 요청 시 내장 rembg 모듈 사용 조건을 개선하여 서버 오류 발생 시 백업 모듈을 사용할 수 있도록 하였습니다. 버전 3.11.2로 업데이트하며, 업데이트 로그를 수정하여 변경 사항을 반영하였습니다.

This commit is contained in:
9700X_PC 2025-08-22 00:05:26 +09:00
parent 9f8908b3fb
commit e622f4125a
12 changed files with 771 additions and 42 deletions

View File

@ -0,0 +1,328 @@
; AutoPercenty3 Inno Setup Script
; 이 스크립트는 cx_Freeze로 빌드된 결과물이 있는 "build\exe.win-amd64-3.11" 폴더를 기반으로 인스톨러를 제작합니다.
; 20250821_233810에 생성됨
#define AppId "autopercenty"
#define MyAppName "Edit_PartTimer"
#define MyAppVersion "3.11.1"
#define MyAppPublisher "WhenRideMyCar"
#define MyAppProgramName "편집알바생"
#define MyAppDescription "편집알바생"
#define MyAppCopyright "Copyright 2024"
#define MyAppExeName "Edit_PartTimer3"
#define MySetupName "Edit_PartTimer Setup"
#define MySetupIcon "src/Edit_PartTimer3.ico"
#define MySetupOutputDir "dist/installer"
[Setup]
; 기본 설정
AppId={#AppId}
AppName={#MyAppProgramName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppPublisher}
OutputDir={#MySetupOutputDir}
OutputBaseFilename={#MySetupName}
SetupIconFile={#MySetupIcon}
Compression=lzma
SolidCompression=yes
; 업데이트 관련 설정 - 권한 최적화
PrivilegesRequired=admin
PrivilegesRequiredOverridesAllowed=dialog
UpdateUninstallLogAppName=yes
AppMutex={#MyAppName}
CloseApplications=yes
RestartApplications=no
; 보안 및 호환성 설정
ArchitecturesAllowed=x64
ArchitecturesInstallIn64BitMode=x64
AllowNoIcons=yes
; 버전 정보
VersionInfoVersion={#MyAppVersion}
VersionInfoCompany={#MyAppPublisher}
VersionInfoDescription={#MyAppDescription}
VersionInfoCopyright={#MyAppCopyright}
VersionInfoProductName={#MyAppProgramName}
VersionInfoProductVersion={#MyAppVersion}
[Languages]
Name: "korean"; MessagesFile: "compiler:Languages\Korean.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
[Dirs]
; 설치 시 {app}\logs 폴더를 생성하고,
; Users 그룹에 'modify' 권한(=쓰기 가능)을 부여
Name: "{app}\logs"; Permissions: users-modify
; 설치 시 {app}\user_data 폴더를 생성하고,
; Users 그룹에 'modify' 권한(=쓰기 가능)을 부여
Name: "{app}\user_data"; Permissions: users-modify
; Playwright 브라우저 폴더를 Program Files 내부에 생성
Name: "{app}\lib\src\browsers\chromium-1155"; Permissions: users-modify
; Playwright 브라우저 사용자폴더를 Program Files 내부에 생성
Name: "{app}\lib\src\browsers\user_data"; Permissions: users-modify
[Files]
; 프로그램 파일만 설치 (항상 덮어쓰기)
Source: "build\exe.win-amd64-3.11\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
; VC++ 재배포 패키지 파일을 임시 폴더({tmp})에 복사
Source: "VC_redist.x64.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall
[Registry]
; Playwright 브라우저 경로를 Program Files 내부로 설정
Root: HKCU; Subkey: "Environment"; ValueType: expandsz; ValueName: "PLAYWRIGHT_BROWSERS_PATH"; ValueData: "{app}\lib\src\browsers"; Flags: preservestringtype
[Icons]
; 시작 메뉴 바로가기
Name: "{group}\{#MyAppProgramName}"; Filename: "{app}\{#MyAppExeName}.exe"
; 바탕화면 바로가기
Name: "{autodesktop}\{#MyAppProgramName}"; Filename: "{app}\{#MyAppExeName}.exe"; Tasks: desktopicon
; 프로그램 제거 바로가기
Name: "{group}\{#MyAppProgramName} 제거"; Filename: "{uninstallexe}"
[Run]
; VC++ 재배포 패키지 설치 (필요할 경우)
Filename: "{tmp}\VC_redist.x64.exe"; Parameters: "/install /passive /norestart"; StatusMsg: "VC++ 재배포 패키지 설치 중..."; Check: NeedsVCredist
; 설치 후 프로그램 실행 (원할 경우)
Filename: "{app}\{#MyAppExeName}.exe"; Description: "{cm:LaunchProgram,{#MyAppProgramName}}"; Flags: nowait postinstall skipifsilent
[Code]
function CompareVersion(V1, V2: string): Integer;
var
P1, P2, N1, N2: Integer;
begin
P1 := 1;
P2 := 1;
Result := 0;
while (Result = 0) and ((P1 <= Length(V1)) or (P2 <= Length(V2))) do begin
while (P1 <= Length(V1)) and (V1[P1] = '.') do Inc(P1);
while (P2 <= Length(V2)) and (V2[P2] = '.') do Inc(P2);
if (P1 <= Length(V1)) and (P2 <= Length(V2)) then begin
N1 := 0; while (P1 <= Length(V1)) and (V1[P1] >= '0') and (V1[P1] <= '9') do begin N1 := N1 * 10 + Ord(V1[P1]) - Ord('0'); Inc(P1); end;
N2 := 0; while (P2 <= Length(V2)) and (V2[P2] >= '0') and (V2[P2] <= '9') do begin N2 := N2 * 10 + Ord(V2[P2]) - Ord('0'); Inc(P2); end;
if N1 < N2 then Result := -1 else if N1 > N2 then Result := 1;
end else begin
if P1 <= Length(V1) then Result := 1 else if P2 <= Length(V2) then Result := -1;
end;
while (P1 <= Length(V1)) and (V1[P1] <> '.') do Inc(P1);
while (P2 <= Length(V2)) and (V2[P2] <> '.') do Inc(P2);
end;
end;
// 파일 또는 폴더 복사 함수
procedure CopyDir(const SourcePath, DestPath: string);
var
FindRec: TFindRec;
SourceFilePath: string;
DestFilePath: string;
begin
ForceDirectories(DestPath);
if FindFirst(SourcePath + '\*', FindRec) then
begin
try
repeat
if (FindRec.Name <> '.') and (FindRec.Name <> '..') then
begin
SourceFilePath := SourcePath + '\' + FindRec.Name;
DestFilePath := DestPath + '\' + FindRec.Name;
if FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY = 0 then
begin
if FileCopy(SourceFilePath, DestFilePath, False) then
Log('파일 복사 성공: ' + SourceFilePath + ' -> ' + DestFilePath)
else
Log('파일 복사 실패: ' + SourceFilePath);
end
else
CopyDir(SourceFilePath, DestFilePath);
end;
until not FindNext(FindRec);
finally
FindClose(FindRec);
end;
end;
end;
// 디렉토리 삭제 함수
procedure DeleteDir(const DirPath: string);
var
FindRec: TFindRec;
FilePath: string;
begin
if not DirExists(DirPath) then Exit;
if FindFirst(DirPath + '\*', FindRec) then
begin
try
repeat
if (FindRec.Name <> '.') and (FindRec.Name <> '..') then
begin
FilePath := DirPath + '\' + FindRec.Name;
if FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY = 0 then
begin
if DeleteFile(FilePath) then
Log('파일 삭제 성공: ' + FilePath)
else
Log('파일 삭제 실패: ' + FilePath);
end
else
DeleteDir(FilePath);
end;
until not FindNext(FindRec);
finally
FindClose(FindRec);
end;
end;
if RemoveDir(DirPath) then
Log('디렉토리 삭제 성공: ' + DirPath)
else
Log('디렉토리 삭제 실패: ' + DirPath);
end;
// 프로그램 실행 여부 확인
function IsAppRunning(const FileName: string): Boolean;
var
Handle: THandle;
begin
Handle := FindWindowByWindowName('{#MyAppProgramName}'); // 프로그램의 윈도우 타이틀로 찾기
Result := (Handle <> 0);
end;
// 프로그램 종료
procedure CloseApplication(const FileName: string);
var
Handle: THandle;
begin
Handle := FindWindowByWindowName('{#MyAppProgramName}');
if Handle <> 0 then
begin
PostMessage(Handle, 18, 0, 0); // WM_QUIT
Sleep(1000); // 종료 대기
end;
end;
// VC++ 재배포 패키지 필요 여부 확인
function NeedsVCredist: Boolean;
begin
if RegKeyExists(HKEY_LOCAL_MACHINE, 'SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64') then
Result := False // 이미 설치됨
else
Result := True; // 미설치 -> 설치 필요
end;
// 설치 완료 후 실행 여부 확인
function InitializeFinish(): Boolean;
var
ResultCode: Integer;
begin
Result := True;
if MsgBox('설치가 완료되었습니다. 프로그램을 실행하시겠습니까?' + #13#10 +
'(실행 시 서버와 동기화하여 설정이 업데이트됩니다)',
mbConfirmation, MB_YESNO) = IDYES then
begin
Exec(ExpandConstant('{app}\{#MyAppExeName}.exe'), '', '', SW_SHOW, ewNoWait, ResultCode);
end;
end;
function InitializeSetup(): Boolean;
var
OldVersion: String;
NewVersion: String;
OldAppPath: String;
UserDataSourcePath, UserDataBackupPath: String;
ResultCode: Integer;
begin
Result := True;
NewVersion := '{#MyAppVersion}';
UserDataBackupPath := ExpandConstant('{tmp}\user_data_backup');
// 현재 프로그램 버전 확인
if RegQueryStringValue(HKLM, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#MyAppName}_is1',
'DisplayVersion', OldVersion) then
begin
// 같은 버전이거나 더 높은 버전이 설치되어 있는 경우
if CompareVersion(OldVersion, NewVersion) >= 0 then
begin
MsgBox('현재 설치된 버전(' + OldVersion + ')이 이 설치 프로그램의 버전(' +
NewVersion + ')과 같거나 더 높습니다.' + #13#10 +
'설치를 계속할 수 없습니다.', mbInformation, MB_OK);
Result := False;
exit;
end;
// 이전 버전이 설치되어 있는 경우 업데이트 진행
if CompareVersion(OldVersion, NewVersion) < 0 then
begin
Log('업데이트 설치 진행: ' + OldVersion + ' -> ' + NewVersion);
// 프로그램이 실행 중인지 확인하고 종료 요청
if IsAppRunning('{#MyAppExeName}.exe') then
begin
if MsgBox('프로그램을 업데이트하기 위해 실행 중인 프로그램을 종료해야 합니다.' + #13#10 +
'계속하시겠습니까?', mbConfirmation, MB_YESNO) = IDNO then
begin
Result := False;
exit;
end;
CloseApplication('{#MyAppExeName}.exe');
Sleep(2000); // 프로세스 종료 대기
end;
// 레지스트리에서 설치 경로 확인
if RegQueryStringValue(HKLM, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#MyAppName}_is1',
'InstallLocation', OldAppPath) then
begin
Log('기존 설치 경로: ' + OldAppPath);
// lib/src/user_data 폴더 백업
UserDataSourcePath := OldAppPath + '\lib\src\user_data';
if DirExists(UserDataSourcePath) then
begin
Log('사용자 데이터 백업 중: ' + UserDataSourcePath + ' -> ' + UserDataBackupPath);
ForceDirectories(UserDataBackupPath);
CopyDir(UserDataSourcePath, UserDataBackupPath);
end
else
begin
Log('사용자 데이터 폴더가 존재하지 않음: ' + UserDataSourcePath);
end;
// 기존 설치 폴더 완전 삭제
if DirExists(OldAppPath) then
begin
Log('기존 설치 폴더 삭제 중: ' + OldAppPath);
DeleteDir(OldAppPath);
end;
end;
end;
end;
end;
procedure CurStepChanged(CurStep: TSetupStep);
var
UserDataBackupPath, UserDataDestPath: String;
begin
// 설치 완료 후
if CurStep = ssPostInstall then
begin
UserDataBackupPath := ExpandConstant('{tmp}\user_data_backup');
UserDataDestPath := ExpandConstant('{app}\lib\src\user_data');
// 백업한 사용자 데이터 폴더가 있으면 복원
if DirExists(UserDataBackupPath) then
begin
Log('사용자 데이터 복원 중: ' + UserDataBackupPath + ' -> ' + UserDataDestPath);
ForceDirectories(UserDataDestPath);
CopyDir(UserDataBackupPath, UserDataDestPath);
Log('사용자 데이터 복원 완료');
end;
end;
end;

View File

@ -469,14 +469,14 @@ class BrowserController(QThread):
# return debug_dir
async def close_welcome_popup_if_exists(self, page, timeout_sec: int = 3):
async def close_welcome_popup_if_exists(self, page, timeout_sec: int = 1):
"""
로그인 직후 간헐적으로 뜨는 '환영합니다' 팝업이 있을 경우 닫아준다.
일정 시간 동안 팝업이 감지되지 않으면 아무것도 하지 않고 종료한다.
Args:
page (Page): Playwright의 page 객체
timeout_sec (int): 팝업을 최대 동안 기다릴지 (기본값 3)
timeout_sec (int): 팝업을 최대 동안 기다릴지 (기본값 1)
"""
# shadow-root 내부의 닫기 버튼을 가리키는 선택자
# self.welcome_popup_closeBTN_selector = '#ch-plugin >>> div:has(span.a11y-hidden:has-text("닫기")) button'
@ -493,7 +493,7 @@ class BrowserController(QThread):
finally:
await page.keyboard.press('Escape')
await asyncio.sleep(0.53)
await asyncio.sleep(0.37)
await page.keyboard.press('Escape')
async def start_browser_async(self):
@ -2031,7 +2031,7 @@ class BrowserController(QThread):
# self.logger.log(f"이미지 붙여넣기 중 오류: {e}", level=logging.ERROR, exc_info=True)
# return False
async def handle_exist_productname_popup(self, timeout_sec: int = 3) -> bool:
async def handle_exist_productname_popup(self, timeout_sec: int = 1) -> bool:
"""
이미 존재하는 상품명 알림(다이얼로그/모달) 처리
@ -2048,11 +2048,11 @@ class BrowserController(QThread):
await ok_btn.click()
self.logger.log("다이얼로그 확인버튼 클릭 완료.", level=logging.INFO)
try:
await self.page.locator(self.dialog_selector).wait_for(state="detached", timeout=2000)
await self.page.locator(self.dialog_selector).wait_for(state="detached", timeout=1000)
return True
except TimeoutError:
await ok_btn.click()
await self.page.locator(self.dialog_selector).wait_for(state="detached", timeout=2000)
await self.page.locator(self.dialog_selector).wait_for(state="detached", timeout=1000)
return True
except TimeoutError:
self.logger.log("다이얼로그 팝업 감지 안됨.", level=logging.WARNING)
@ -2078,7 +2078,7 @@ class BrowserController(QThread):
# ────────────────────────────────────────────────────────────────
async def _resolve_duplication(step_desc: str) -> bool:
"""저장/닫기 단계에서 중복 팝업이 뜨면 상품명 재설정 후 재저장한다."""
await asyncio.sleep(0.5) # 팝업 렌더링 대기
await asyncio.sleep(0.2) # 팝업 렌더링 대기
rename_need = await self.handle_exist_productname_popup()
if not rename_need:
return False # 중복 없음
@ -2096,7 +2096,7 @@ class BrowserController(QThread):
# 상품명 탭 이동 → 제목 재설정
await self.edit_product_name()
await asyncio.sleep(0.5)
await asyncio.sleep(0.2)
is_set = await self.titleGenerator.set_product_name(new_title, duplication_suffix)
if not is_set:
@ -2638,13 +2638,13 @@ class BrowserController(QThread):
except asyncio.TimeoutError:
self.logger.log(f'스크롤 중 타임아웃 발생 (시도 {retry_count + 1}/{max_retries})', level=logging.WARNING)
retry_count += 1
await asyncio.sleep(1.0) # 타임아웃 후 잠시 대기
await asyncio.sleep(0.2) # 타임아웃 후 잠시 대기
# 페이지 상태 재확인
try:
previous_height = await asyncio.wait_for(
self.page.evaluate("() => window.pageYOffset"),
timeout=2.0
timeout=1.0
)
except asyncio.TimeoutError:
self.logger.log('페이지 상태 확인 실패, 스크롤 중단', level=logging.WARNING)
@ -3056,7 +3056,7 @@ class BrowserController(QThread):
try:
await self.page.wait_for_selector(self.memo_input_locator,
state="detached", # hidden 으로 바꿔도 됨
timeout=3000)
timeout=1000)
except TimeoutError:
# 남아있어도 다음 재시도 전에 old-element 가 선택되지 않도록
self.logger.log("메모 팝업이 닫히지 않았습니다 selector 재생성", level=logging.WARNING)

View File

@ -149,6 +149,9 @@ class MAIN_GUI(QMainWindow):
self.base_dir = self.get_base_dir()
self.toggle_states['base_dir'] = self.base_dir
# MIGAN ONNX 경로 설정
self.toggle_states['migan_onnx_path'] = os.path.join(self.base_dir, "modules", "migan_onnx", "migan_pipeline_v2.onnx")
# # 폰트 경로 설정
# self.toggle_states['image_font_path'] = os.path.join(self.base_dir, "fonts", "HakgyoansimDunggeunmisoTTFB.ttf")
# self.toggle_states['watermark_font_path'] = os.path.join(self.base_dir, 'fonts', 'HakgyoansimDunggeunmisoTTFB.ttf')
@ -1202,7 +1205,14 @@ class MAIN_GUI(QMainWindow):
"blend_mode": "simple", # 단순 블렌딩
"performance_mode": True, # 빠른 경로 사용
"max_image_size": 1280, # 더 작은 크기 제한
"roi_area_high": 0.0 # 기본값: 0.60 → 0.0으로 변경 # 풀프레임 인페인팅 강제
"roi_area_high": 0.0, # 기본값: 0.60 → 0.0으로 변경 # 풀프레임 인페인팅 강제
"local_inpaint_method": "migan",
"migan_onnx_path": "",
"migan_use_cuda": False, # 3050 4GB면 True 권장(실패시 자동 CPU 폴백)
"migan_intra_threads": 0,
"migan_inter_threads": 0,
}

View File

@ -155,6 +155,8 @@ include_files = dll_include_files + paddle_includes + [
('src/modules/fonts/NanumBarunGothic.ttf', 'lib/src/modules/fonts/NanumBarunGothic.ttf'),
('src/modules/fonts/NanumSquareRoundR.ttf', 'lib/src/modules/fonts/NanumSquareRoundR.ttf'),
('src/modules/fonts/HakgyoansimDunggeunmisoTTFB.ttf', 'lib/src/modules/fonts/HakgyoansimDunggeunmisoTTFB.ttf'),
('src/modules/migan_onnx/migan_pipeline_v2.onnx', 'lib/src/modules/migan_onnx/migan_onnx/migan_pipeline_v2.onnx'),
('퍼센티 다양한 카테고리 엑셀 수집(스스 기준).xlsx', '퍼센티 다양한 카테고리 엑셀 수집(스스 기준).xlsx'),
('src/Percenty_SS_Code.json', 'lib/src/Percenty_SS_Code.json'),
@ -177,17 +179,20 @@ build_exe_options = {
'packages': [
'ctypes', 'asyncio',
'subprocess', 'pyperclip', 'numpy',
# "numba", "rembg", "pymatting",
'numba', 'rembg', # rembg 의존성 추가
'cv2', 'requests', 'pyclipper', 'skimage',
'PIL', 'bs4', 'PySide6', 'psutil',
# 'win32api', 'win32file', 'win32pipe', 'win32event', 'pywintypes', 'win32con', 'win32process', 'win32clipboard', 'win32gui',
'pandas', 'supabase', 'translatepy', 'markdown',
'paddle', 'paddleocr', # paddle 관련 모듈 포함
'jsonschema', # rembg 추가 의존성
],
'includes': [
# 'PySide6.QtWidgets', 'PySide6.QtCore', 'PySide6.QtGui',
'shiboken6','playwright','comtypes.stream', 'win32com.client', 'win32com.server', 'pythoncom',
'loggerModule', 'toggleSwitch', 'src.modules.request_inpaint', 'onnxruntime', 'rembg',
'loggerModule', 'toggleSwitch', 'src.modules.request_inpaint', 'onnxruntime', 'rembg', 'numba', 'numba.core.types.old_scalars',
'jsonschema', # rembg 관련 의존성 추가
'numba.core', 'numba.core.types', 'numba.core.types.scalars', 'numba.typed', 'numba.experimental', # numba 내부 모듈들
'browser_control', 'locatorManager', 'src.cmdDiag', 'src.inputDiag', 'src.keyword', 'src.priceSetDiag', 'src.modules.image_processor3',
'skimage', 'skimage.morphology', 'skimage.measure', 'skimage.filters', 'skimage.color', 'skimage.util', 'imghdr', 'imgaug', 'rapidfuzz',
'albumentations', 'albumentations', 'cython', 'fire', 'lmdb', 'PIL', 'docx', 'yaml', 'shapely', 'tqdm',

View File

@ -69,6 +69,8 @@ class ImageProcessor3:
self.logger.log(f"self.unwanted_texts: {self.unwanted_texts}", level=logging.DEBUG)
self.logger.log(f"self.inpaint_method: {self.inpaint_method}", level=logging.DEBUG)
# ----------------------------- 메모리 파편화 완화 -----------------------------
# Pillow 가 거대 이미지를 열 때 과도한 메모리를 점유하지 않도록 최대 픽셀 수 제한
max_px = self.toggle_states.get("max_image_pixels", 20_000_000) # 약 20MP, 필요 시 조정 (20MP = 4500x4500, 50MP=8000x6000)
@ -95,6 +97,19 @@ class ImageProcessor3:
self.request_ai_server = Request_AI_Server(logger=self.logger, inpaint_server_url=self.request_inpainting_server_url, rembg_server_url=self.request_rembg_server_url)
self.gtranslate = GoogleTranslate()
# MIGAN ONNX 파이프라인 준비(옵션 토글 기반)
try:
from src.modules.migan_module import build_migan_from_toggle
if self.toggle_states.get("migan_onnx_path"):
self.migan = build_migan_from_toggle(self.toggle_states, logger=self.logger)
else:
self.migan = None
self.logger.log("migan_onnx_path 미설정: MIGAN 비활성", level=logging.INFO)
except Exception as e:
self.migan = None
self.logger.log(f"MIGAN 초기화 실패: {e}", level=logging.ERROR, exc_info=True)
except Exception as e:
self.logger.log(f"ImageProcessor3 초기화 중 오류 발생: {e}", level=logging.ERROR, exc_info=True)
@ -289,15 +304,48 @@ class ImageProcessor3:
inpainted_image = self.request_ai_server.request_inpaint(local_image_path, masks)
if inpainted_image is None:
self.logger.log(f"Request 인페인팅 실패, opencv 인페인팅으로 대체", level=logging.WARNING)
inpainted_image = self.opencv_inpaint(local_image_path, masks, method='telea', radius=3)
# inpainted_image = self.lama_inpaint(local_image_path, masks)
# inpainted_image = self.opencv_inpaint(local_image_path, masks, method='telea', radius=3)
# # inpainted_image = self.lama_inpaint(local_image_path, masks)
inpainted_image = None
if getattr(self, "migan", None) is not None:
try:
self.logger.log(f"MIGAN 인페인팅 시작", level=logging.DEBUG)
inpainted_image = self.migan.inpaint(local_image_path, masks)
self.logger.log(f"MIGAN 인페인팅 완료", level=logging.DEBUG)
except Exception as e:
self.logger.log(f"MIGAN 인페인팅 실패, OpenCV로 폴백: {e}", level=logging.WARNING, exc_info=True)
if inpainted_image is None:
inpainted_image = self.opencv_inpaint(local_image_path, masks, method='telea', radius=3)
else:
self.logger.log(f"자체 인페인팅 실행", level=logging.DEBUG)
inpainted_image = self.opencv_inpaint(local_image_path, masks, method='telea', radius=3)
# inpainted_image = self.lama_inpaint(local_image_path, masks)
inpainted_image = None
if getattr(self, "migan", None) is not None:
try:
self.logger.log(f"MIGAN 인페인팅 시작", level=logging.DEBUG)
inpainted_image = self.migan.inpaint(local_image_path, masks)
self.logger.log(f"MIGAN 인페인팅 완료", level=logging.DEBUG)
except Exception as e:
self.logger.log(f"MIGAN 인페인팅 실패, OpenCV로 폴백: {e}", level=logging.WARNING, exc_info=True)
if inpainted_image is None:
inpainted_image = self.opencv_inpaint(local_image_path, masks, method='telea', radius=3)
self.logger.log(f"인페인팅 완료", level=logging.DEBUG)
# # migan 테스트 코드
# inpainted_image = None
# if getattr(self, "migan", None) is not None:
# try:
# self.logger.log(f"MIGAN TEST 인페인팅 시작", level=logging.DEBUG)
# inpainted_image = self.migan.inpaint(local_image_path, masks)
# self.logger.log(f"MIGAN TEST 인페인팅 완료", level=logging.DEBUG)
# except Exception as e:
# self.logger.log(f"MIGAN Test 인페인팅 실패, OpenCV로 폴백: {e}", level=logging.WARNING, exc_info=True)
# 인페인팅 실패 시 원본 이미지 사용
if inpainted_image is None:

243
src/modules/migan_module.py Normal file
View File

@ -0,0 +1,243 @@
# -*- coding: utf-8 -*-
"""
src/modules/migan_module.py
- MI-GAN ONNX 파이프라인(/후처리 포함) 기존 파이프라인과 100% 호환되게 감싼 래퍼.
- 핵심 호환 포인트:
1) 입력은 네가 쓰던 방식대로: image_path(str), mask(np.ndarray, 0~255 그레이, 텍스트영역=255)
2) 마스크는 내부에서 자동으로 (이진화 -> 반전) 하여 MI-GAN 규칙(255=known, 0=hole) 맞춤
3) CUDA/CPU 자동 선택, 실패 CPU 폴백
4) 로거 인터페이스는 코드(logger.log(msg, level=...)) 동일하게 사용
의존성: onnxruntime, opencv-python, numpy
설정: toggle_states에 다음 사용(선택):
- "migan_onnx_path": 파이프라인 ONNX 경로(필수)
- "migan_use_cuda": True/False (기본 False)
- "migan_intra_threads": int (기본 0 = onnxruntime 기본)
- "migan_inter_threads": int (기본 0)
"""
import os
import sys
import time
import logging
from typing import Optional
import cv2
import numpy as np
import onnxruntime as ort
# OpenCV 내부 최적화 off (네 기존 코드와 동일 정책)
cv2.setUseOptimized(False)
def _np_uint8_2d(arr, name="mask"):
if arr is None:
raise ValueError(f"{name} is None")
if not isinstance(arr, np.ndarray):
raise TypeError(f"{name} must be np.ndarray, got {type(arr)}")
if arr.ndim != 2:
raise ValueError(f"{name} must be 2D, got shape={arr.shape}")
if arr.dtype != np.uint8:
# 안전 변환
arr = arr.astype(np.uint8, copy=False)
return arr
def _ensure_logger(logger: Optional[object]) -> logging.Logger:
"""네 logger( .log(msg, level=) )가 없을 때를 대비한 기본 로거"""
if logger and hasattr(logger, "log"):
return logger
pylogger = logging.getLogger("MIGAN")
if not pylogger.handlers:
pylogger.setLevel(logging.DEBUG)
h = logging.StreamHandler(stream=sys.stdout)
h.setFormatter(logging.Formatter("[%(asctime)s][%(levelname)s] %(message)s"))
pylogger.addHandler(h)
# .log 호환 어댑터
class _Adapter:
def __init__(self, _lg): self._lg = _lg
def log(self, msg, level=logging.INFO, **kwargs):
self._lg.log(level, msg)
return _Adapter(pylogger)
class MIGANPipelineONNXCompat:
"""
MI-GAN ONNX 파이프라인 래퍼( 프로젝트 호환용)
- 입력: image_path(str), mask(gray uint8 HxW) 텍스트영역=255(너의 MaskModule 출력 가정)
- 내부에서 mask를 (이진화반전)하여 MI-GAN 규칙(255=known, 0=hole)으로 맞춤
- 출력: BGR uint8(H,W,3)
"""
_SESSION_CACHE = {} # onnx_path -> InferenceSession 캐시
def __init__(self,
onnx_path: str,
logger: Optional[object] = None,
use_cuda: bool = False,
intra_threads: int = 0,
inter_threads: int = 0):
self.logger = _ensure_logger(logger)
self.onnx_path = onnx_path
self.use_cuda = bool(use_cuda)
self.intra_threads = int(intra_threads or 0)
self.inter_threads = int(inter_threads or 0)
if not os.path.exists(self.onnx_path):
self.logger.log(f"[MIGAN] ONNX 파일을 찾을 수 없습니다: {self.onnx_path}", level=logging.ERROR)
raise FileNotFoundError(self.onnx_path)
self.session = self._get_or_create_session()
ins = self.session.get_inputs()
outs = self.session.get_outputs()
self.in_image = ins[0].name
self.in_mask = ins[1].name
self.out_name = outs[0].name
# 입력/출력 형태 정보 로깅 (디버깅용)
for i, inp in enumerate(ins):
self.logger.log(f"[MIGAN] 입력 {i}: {inp.name}, 형태: {inp.shape}, 타입: {inp.type}", level=logging.INFO)
for i, out in enumerate(outs):
self.logger.log(f"[MIGAN] 출력 {i}: {out.name}, 형태: {out.shape}, 타입: {out.type}", level=logging.INFO)
self.logger.log(f"[MIGAN] 세션 준비 완료. providers={self.session.get_providers()}", level=logging.INFO)
def _get_or_create_session(self) -> ort.InferenceSession:
key = (self.onnx_path, self.use_cuda, self.intra_threads, self.inter_threads)
if key in self._SESSION_CACHE:
return self._SESSION_CACHE[key]
so = ort.SessionOptions()
if self.intra_threads > 0:
so.intra_op_num_threads = self.intra_threads
if self.inter_threads > 0:
so.inter_op_num_threads = self.inter_threads
providers = []
if self.use_cuda:
providers.append("CUDAExecutionProvider")
providers.append("CPUExecutionProvider")
try:
sess = ort.InferenceSession(self.onnx_path, sess_options=so, providers=providers)
except Exception as e:
# CUDA 실패 시 CPU 폴백
self.logger.log(f"[MIGAN] CUDA EP 초기화 실패, CPU로 폴백: {e}", level=logging.WARNING)
sess = ort.InferenceSession(self.onnx_path, sess_options=so, providers=["CPUExecutionProvider"])
self._SESSION_CACHE[key] = sess
return sess
# ───────────────────────────────────────────────────────────────
# 퍼블릭 API: 네 파이프라인에서 바로 호출
# ───────────────────────────────────────────────────────────────
def inpaint(self, image_path: str, mask_gray_255_text: np.ndarray) -> Optional[np.ndarray]:
"""
Args
image_path: 원본 이미지 경로 (BGR 로딩)
mask_gray_255_text: 0~255 그레이, '텍스트영역=255' 형태( MaskModule 출력)
(GaussianBlur 포함되어 있을 있음)
Return
inpainted BGR 이미지 (np.ndarray) or None
"""
try:
# 1) 입력 이미지 로드
bgr = cv2.imread(image_path, cv2.IMREAD_COLOR)
if bgr is None:
self.logger.log(f"[MIGAN] 이미지 로드 실패: {image_path}", level=logging.ERROR)
return None
H, W = bgr.shape[:2]
# 2) 마스크 정규화: (이진화 → 반전) 해서 255=known, 0=hole 맞추기
mask = _np_uint8_2d(mask_gray_255_text, name="mask")
if mask.shape != (H, W):
self.logger.log(f"[MIGAN] 마스크 크기 불일치: mask={mask.shape}, img={(H,W)}", level=logging.ERROR)
return None
# 이진화: 128 스레시hold 기준
_, mask_bin = cv2.threshold(mask, 128, 255, cv2.THRESH_BINARY)
# 너의 마스크는 “텍스트=255”, MI-GAN은 “hole=0”이므로 반전
# (텍스트영역 255 -> 0), (배경 0 -> 255)
mask_known255 = 255 - mask_bin
# 3) RGB 변환 (파이프라인 입력은 RGB uint8)
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
# 4) ONNX 추론 - 배치 차원 추가 및 차원 순서 변경
start = time.time()
# ONNX 모델 입력 형태:
# - image: (1, 3, H, W) - 배치, 채널, 높이, 너비 순서
# - mask: (1, 1, H, W) - 배치, 채널(1), 높이, 너비 순서
# 이미지: (H, W, 3) -> (1, 3, H, W)
rgb_batch = np.expand_dims(rgb, 0).transpose(0, 3, 1, 2)
# 마스크: (H, W) -> (1, 1, H, W)
mask_batch = np.expand_dims(mask_known255, (0, 1))
self.logger.log(f"[MIGAN] 입력 형태 - 이미지: {rgb_batch.shape}, 마스크: {mask_batch.shape}", level=logging.DEBUG)
out = self.session.run(
[self.out_name],
{self.in_image: rgb_batch, self.in_mask: mask_batch}
)[0] # expect RGB uint8(1,3,H,W)
# 출력 차원 처리: (1,3,H,W) -> (H,W,3)
if out.ndim == 4 and out.shape[0] == 1:
out = out[0].transpose(1, 2, 0) # (1,3,H,W) -> (3,H,W) -> (H,W,3)
elif out.ndim == 3 and out.shape[0] == 3: # (3,H,W) -> (H,W,3)
out = out.transpose(1, 2, 0)
self.logger.log(f"[MIGAN] 출력 형태: {out.shape}, dtype: {out.dtype}", level=logging.DEBUG)
if not isinstance(out, np.ndarray) or out.ndim != 3 or out.dtype != np.uint8:
self.logger.log(f"[MIGAN] ONNX 출력 형식 오류: type={type(out)}, shape={getattr(out,'shape',None)}, dtype={getattr(out,'dtype',None)}",
level=logging.ERROR)
return None
elapsed = (time.time() - start) * 1000.0
self.logger.log(f"[MIGAN] 추론 완료: {elapsed:.2f} ms", level=logging.DEBUG)
# 5) BGR로 되돌려 반환
bgr_out = cv2.cvtColor(out, cv2.COLOR_RGB2BGR)
return bgr_out
except Exception as e:
error_msg = str(e).lower()
if "invalid rank" in error_msg or "invalid argument" in error_msg:
self.logger.log(f"[MIGAN] ONNX 입력 차원 오류: {e}", level=logging.ERROR)
self.logger.log(f"[MIGAN] 입력 이미지 형태: {rgb_batch.shape if 'rgb_batch' in locals() else 'N/A'}", level=logging.ERROR)
self.logger.log(f"[MIGAN] 입력 마스크 형태: {mask_batch.shape if 'mask_batch' in locals() else 'N/A'}", level=logging.ERROR)
else:
self.logger.log(f"[MIGAN] inpaint 예외: {e}", level=logging.ERROR, exc_info=True)
return None
# ───────────────────────────────────────────────────────────────
# 네 ImageProcessor3에서 바로 부를 수 있게 하는 편의 함수
# ───────────────────────────────────────────────────────────────
def build_migan_from_toggle(toggle_states: dict, logger: Optional[object] = None) -> MIGANPipelineONNXCompat:
"""
toggle_states로부터 설정을 읽어 MIGANPipelineONNXCompat 인스턴스를 생성.
필수 :
- migan_onnx_path
선택 :
- migan_use_cuda (bool)
- migan_intra_threads (int)
- migan_inter_threads (int)
"""
onnx_path = toggle_states.get("migan_onnx_path", "")
if not onnx_path:
raise ValueError("toggle_states['migan_onnx_path'] 가 필요합니다.")
use_cuda = bool(toggle_states.get("migan_use_cuda", False))
intra = int(toggle_states.get("migan_intra_threads", 0) or 0)
inter = int(toggle_states.get("migan_inter_threads", 0) or 0)
return MIGANPipelineONNXCompat(
onnx_path=onnx_path,
logger=logger,
use_cuda=use_cuda,
intra_threads=intra,
inter_threads=inter,
)

Binary file not shown.

View File

@ -109,7 +109,7 @@ class Request_AI_Server:
Args:
image: 입력 이미지 (np.ndarray 또는 파일 경로)
use_local_rembg: True면 서버 상태와 관계없이 내장 rembg 모듈 직접
use_local_rembg: True면 서버 실패 백업으로 내장 rembg 모듈 사용
local_model_name: 내장 rembg에서 사용할 모델명 (기본값: "birefnet-general-lite")
"""
try:
@ -123,24 +123,18 @@ class Request_AI_Server:
self.logger.log(f"이미지 파일을 읽을 수 없습니다: {image}", level=logging.ERROR)
return None
try:
# 내장 모듈 직접 사용 요청시
if use_local_rembg:
self.logger.log(f"외부 인자에 의해 내장 rembg 모듈({local_model_name})을 직접 사용합니다.", level=logging.INFO)
result = self._use_backup_rembg(image_data, local_model_name)
if result is None:
self.logger.log("내장 rembg 모듈 사용이 요청되었지만 실행할 수 없습니다. rembg 관련 의존성을 확인하세요.", level=logging.ERROR)
return result
except Exception as e:
self.logger.log(f"내장 rembg 모듈 사용 실패: {e}", level=logging.ERROR, exc_info=True)
if use_local_rembg:
# 내장 모듈만 사용하도록 요청된 경우 서버 대안 시도하지 않음
return None
# # 🚀 테스트를 위해 무조건 내장 rembg 모듈 사용
# self.logger.log("테스트 모드: 무조건 내장 rembg 모듈 사용", level=logging.INFO)
# return self._use_backup_rembg(image_data, local_model_name)
# 서버 상태 먼저 확인
if not self.is_server_alive(self.rembg_base_url):
self.logger.log("rembg 서버가 비정상입니다. 백업 내장 rembg 모듈을 사용합니다.", level=logging.WARNING)
return self._use_backup_rembg(image_data, local_model_name)
if use_local_rembg:
self.logger.log("rembg 서버가 비정상입니다. 백업 내장 rembg 모듈을 사용합니다.", level=logging.WARNING)
return self._use_backup_rembg(image_data, local_model_name)
else:
self.logger.log("rembg 서버가 비정상이지만 백업 모듈 사용이 비활성화되어 있습니다.", level=logging.ERROR)
return None
# base64 인코딩 (data URL)
_, img_encoded = cv2.imencode('.png', image_data)
@ -153,22 +147,30 @@ class Request_AI_Server:
response = requests.post(self.rembg_api_url, json=payload)
if response.status_code != 200:
self.logger.log(f"rembg 서버 에러: {response.text}. 백업 내장 rembg 모듈을 사용합니다.", level=logging.WARNING)
return self._use_backup_rembg(image_data, local_model_name)
if use_local_rembg:
self.logger.log(f"rembg 서버 에러: {response.text}. 백업 내장 rembg 모듈을 사용합니다.", level=logging.WARNING)
return self._use_backup_rembg(image_data, local_model_name)
else:
self.logger.log(f"rembg 서버 에러: {response.text}. 백업 모듈 사용이 비활성화되어 있습니다.", level=logging.ERROR)
return None
# PNG 바이너리 → numpy
nparr = np.frombuffer(response.content, np.uint8)
result_img = cv2.imdecode(nparr, cv2.IMREAD_UNCHANGED)
if result_img is None or result_img.ndim != 3:
self.logger.log("서버 응답 이미지 디코딩 실패. 백업 내장 rembg 모듈을 사용합니다.", level=logging.WARNING)
return self._use_backup_rembg(image_data, local_model_name)
if use_local_rembg:
self.logger.log("서버 응답 이미지 디코딩 실패. 백업 내장 rembg 모듈을 사용합니다.", level=logging.WARNING)
return self._use_backup_rembg(image_data, local_model_name)
else:
self.logger.log("서버 응답 이미지 디코딩 실패. 백업 모듈 사용이 비활성화되어 있습니다.", level=logging.ERROR)
return None
# ---- 후처리: 마스크 정제 및 중앙 배치 ----
return self._postprocess_rembg_result(result_img)
except Exception as e:
self.logger.log(f"rembg 서버 에러: {e}. 백업 내장 rembg 모듈을 사용합니다.", level=logging.WARNING, exc_info=True)
self.logger.log(f"request_rembg 실행 중 오류: {e}. 백업 내장 rembg 모듈을 사용합니다.", level=logging.WARNING, exc_info=True)
return self._use_backup_rembg(image_data if 'image_data' in locals() else image, local_model_name)
def _use_backup_rembg(self, image_data: np.ndarray, model_name: str = "birefnet-general-lite") -> np.ndarray:

View File

@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
"""
tests/test_migan_module.py
- 로컬 이미지와 마스크로 MIGAN 파이프라인을 단독 테스트
- 너의 MaskModule 없이도 간단 폴리곤으로 마스크 생성 가능
"""
import os
import cv2
import numpy as np
import logging
from src.modules.migan_module import MIGANPipelineONNXCompat
def get_logger():
lg = logging.getLogger("MIGAN_TEST")
if not lg.handlers:
lg.setLevel(logging.DEBUG)
h = logging.StreamHandler()
h.setFormatter(logging.Formatter("[%(asctime)s][%(levelname)s] %(message)s"))
lg.addHandler(h)
# 네 프로젝트와 호환(.log 인터페이스)
class _Adapter:
def __init__(self, _lg): self._lg = _lg
def log(self, msg, level=logging.INFO, **kwargs): self._lg.log(level, msg)
return _Adapter(lg)
def make_demo_mask_like_yours(img_path: str):
"""네 MaskModule 스타일(텍스트영역=255) 흉내내기: 사각형 2개를 255로 채움 + 블러"""
img = cv2.imread(img_path, cv2.IMREAD_COLOR)
h,w = img.shape[:2]
mask = np.zeros((h,w), np.uint8)
cv2.rectangle(mask, (int(0.1*w), int(0.2*h)), (int(0.4*w), int(0.3*h)), 255, -1)
cv2.rectangle(mask, (int(0.6*w), int(0.55*h)), (int(0.9*w), int(0.65*h)), 255, -1)
mask = cv2.GaussianBlur(mask, (15,15), 0)
return mask
def main():
logger = get_logger()
# 경로 설정
onnx_path = os.environ.get("MIGAN_ONNX", "migan_pipeline_v2.onnx")
img_path = os.environ.get("MIGAN_IMG", "examples/input.png")
out_path = os.environ.get("MIGAN_OUT", "examples/output_migan.png")
if not os.path.exists(onnx_path):
logger.log(f"ONNX 파일 없음: {onnx_path}", level=logging.ERROR); return
if not os.path.exists(img_path):
logger.log(f"입력 이미지 없음: {img_path}", level=logging.ERROR); return
migan = MIGANPipelineONNXCompat(onnx_path, logger=logger, use_cuda=True)
mask = make_demo_mask_like_yours(img_path)
out = migan.inpaint(img_path, mask)
if out is None:
logger.log("인페인팅 실패", level=logging.ERROR); return
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
cv2.imwrite(out_path, out)
logger.log(f"저장 완료: {out_path}")
if __name__ == "__main__":
main()

View File

@ -7,7 +7,7 @@ import logging
""" 프로그램 기본 정보 """
__title__ = "Edit_PartTimer"
__description__ = "편집알바생"
__version__ = "3.11.1"
__version__ = "3.11.2"
__build__ = "1" # 빌드 번호
__author__ = "WhenRideMyCar"
__author_email__ = "abc@gmail.com"

View File

@ -3,6 +3,36 @@
# 3.11.2 업데이트 로그
### 패치 2 오류수정
- 누끼서버 폴백기능 오류 수정
### 패치 2 기능추가
- 이미지번역 폴백2 추가
### 패치 1 오류수정
- 폰트누락 수정 (죄송합니다. 기본폰트 이외의 폰트가 누락되었었습니다)
- 이미지워커 재시작 오류 수정
- 네이버검색 API 오류 수정
### 패치 1 기능추가
- 누끼서버 폴백기능 추가(Self수행)
### 3.10 마이너 업데이트 오류수정
- 기존버전 사용자 중 상세페이지 번역 누락 수정
- 이미지워커 중복큐 제거
- 이미지폰트가 저장되지 않던 문제 수정
### 3.10 마이너 업데이트 기능 추가
- gpt5의 특수문자 처리 로직 추가
- gpt5 폴백 개선
- 이미지 처리방식 webp 모드 추가(기본적용)
- 로거모듈 개선
# 3.11.1 업데이트 로그
### 패치 1 오류수정