diff --git a/AutoPercenty_20250821_233810.iss b/AutoPercenty_20250821_233810.iss new file mode 100644 index 00000000..eda5da32 --- /dev/null +++ b/AutoPercenty_20250821_233810.iss @@ -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; diff --git a/browser_control.py b/browser_control.py index d29c509d..cf7f9190 100644 --- a/browser_control.py +++ b/browser_control.py @@ -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) diff --git a/mainUI_SP.py b/mainUI_SP.py index 68c359ad..385bdd94 100644 --- a/mainUI_SP.py +++ b/mainUI_SP.py @@ -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, + } diff --git a/setup.py b/setup.py index 6d7e7ec3..40b6f6fd 100644 --- a/setup.py +++ b/setup.py @@ -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', diff --git a/src/modules/image_processor3.py b/src/modules/image_processor3.py index a95ad1d1..df05f911 100644 --- a/src/modules/image_processor3.py +++ b/src/modules/image_processor3.py @@ -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: diff --git a/src/modules/migan_module.py b/src/modules/migan_module.py new file mode 100644 index 00000000..d4c67786 --- /dev/null +++ b/src/modules/migan_module.py @@ -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, + ) diff --git a/src/modules/migan_onnx/migan_pipeline_v2.onnx b/src/modules/migan_onnx/migan_pipeline_v2.onnx new file mode 100644 index 00000000..3f75aded Binary files /dev/null and b/src/modules/migan_onnx/migan_pipeline_v2.onnx differ diff --git a/src/modules/request_inpaint.py b/src/modules/request_inpaint.py index 76f9517b..9144e40f 100644 --- a/src/modules/request_inpaint.py +++ b/src/modules/request_inpaint.py @@ -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: diff --git a/src/modules/test_migan_module.py b/src/modules/test_migan_module.py new file mode 100644 index 00000000..60cd2640 --- /dev/null +++ b/src/modules/test_migan_module.py @@ -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() diff --git a/src/user_data/user_data_909d2ef8-7053-4006-ab40-49eb49f20383.db b/src/user_data/user_data_909d2ef8-7053-4006-ab40-49eb49f20383.db index b32378a5..3dce23ed 100644 Binary files a/src/user_data/user_data_909d2ef8-7053-4006-ab40-49eb49f20383.db and b/src/user_data/user_data_909d2ef8-7053-4006-ab40-49eb49f20383.db differ diff --git a/updateManager/__version__.py b/updateManager/__version__.py index 9b0187c1..09dd65bc 100644 --- a/updateManager/__version__.py +++ b/updateManager/__version__.py @@ -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" diff --git a/updateManager/updateLog.md b/updateManager/updateLog.md index 05581922..45aed7ad 100644 --- a/updateManager/updateLog.md +++ b/updateManager/updateLog.md @@ -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 오류수정