diff --git a/AutoPercenty_20250823_090708.iss b/AutoPercenty_20250823_090708.iss new file mode 100644 index 00000000..fc809a45 --- /dev/null +++ b/AutoPercenty_20250823_090708.iss @@ -0,0 +1,328 @@ +; AutoPercenty3 Inno Setup Script +; 이 스크립트는 cx_Freeze로 빌드된 결과물이 있는 "build\exe.win-amd64-3.11" 폴더를 기반으로 인스톨러를 제작합니다. +; 20250823_090708에 생성됨 + +#define AppId "autopercenty" +#define MyAppName "Edit_PartTimer" +#define MyAppVersion "3.11.3" +#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 cf7f9190..a787c211 100644 --- a/browser_control.py +++ b/browser_control.py @@ -4083,6 +4083,13 @@ class ImageWorkerManager: self.restart_count = 0 self.max_restart = 5 self.image_worker_fatal = image_worker_fatal + # 정책 로드(토글 기반) + self._success_count = 0 + self._consecutive_mem_errors = 0 + self._periodic_restart_every = 0 + self._mem_restart_threshold = 0 + self._mem_error_escalate_after = 2 + self._load_worker_policies() # ─── 프로세스/큐 생성 ─────────────────────────────────── self.task_q = Queue() self.result_q = Queue() @@ -4143,6 +4150,30 @@ class ImageWorkerManager: timeout = 120 if self.first_call else 60 result = await self._call_worker("process_single_image", timeout=timeout, **kwargs) self.first_call = False + # 성공 시 연속 메모리 오류 카운터 초기화 + try: + self._consecutive_mem_errors = 0 + except Exception: + pass + + # 메모리 임계치 기반 재시작 + try: + if self._mem_restart_threshold and self._mem_restart_threshold > 0: + vm = psutil.virtual_memory() + if vm.percent >= self._mem_restart_threshold: + self.logger.log(f"메모리 임계치 초과({vm.percent}% >= {self._mem_restart_threshold}%) → 워커 재시작", level=logging.WARNING) + self.restart(cause="error") + except Exception: + pass + + # 주기적 재시작 트리거: 성공으로 간주되는 경우에만 카운트 + try: + if self._periodic_restart_every and isinstance(result, dict) and result.get("status") in {"translated", "original", "exclude", "success"}: + self._success_count += 1 + if self._success_count % self._periodic_restart_every == 0: + self.restart(cause="periodic") + except Exception: + pass return result async def safe_process_single_image(self, **kwargs): @@ -4197,8 +4228,22 @@ class ImageWorkerManager: self.restart(cause="error") raise # 상위에서 필요하다면 재시도 로직을 추가 except Exception as e: # ← RuntimeError 포함 - self.logger.log(f"워커 내부 오류 → 재시작: {e}", level=logging.WARNING) - self.restart(cause="error") + # primitive/메모리 오류 에스컬레이션 처리 + msg = str(e).lower() + if any(s in msg for s in ["memory", "primitive", "out of memory", "unable to allocate", "cv::outofmemoryerror"]): + try: + self._consecutive_mem_errors += 1 + except Exception: + self._consecutive_mem_errors = 1 + self.logger.log(f"메모리/primitive 오류 {self._consecutive_mem_errors}회: {e}", level=logging.WARNING) + if self._consecutive_mem_errors >= max(1, self._mem_error_escalate_after): + self.logger.log("연속 메모리 오류 기준 충족 → 워커 재시작", level=logging.WARNING) + self.restart(cause="error") + self._consecutive_mem_errors = 0 + else: + # 일반 오류도 재시작 + self.logger.log(f"워커 내부 오류 → 재시작: {e}", level=logging.WARNING) + self.restart(cause="error") raise def _wait_result(self, uid, timeout=60): @@ -4274,8 +4319,25 @@ class ImageWorkerManager: if toggle_states is not None: self.toggle_states = toggle_states self.logger.log("ImageWorker toggle_states 업데이트됨", level=logging.DEBUG) + self._load_worker_policies() if unwanted_words is not None: self.unwanted_words = unwanted_words self.logger.log("ImageWorker unwanted_words 업데이트됨", level=logging.DEBUG) + def _load_worker_policies(self): + """toggle_states 로부터 워커 재시작 정책을 로드한다.""" + try: + every = int(self.toggle_states.get("image_worker_periodic_restart_every", 0) or 0) + mem_thr = int(self.toggle_states.get("image_worker_mem_restart_threshold", 0) or 0) + esc_after = int(self.toggle_states.get("image_worker_mem_error_escalate_after", 2) or 2) + self._periodic_restart_every = max(0, every) + self._mem_restart_threshold = max(0, mem_thr) + self._mem_error_escalate_after = max(1, esc_after) + self.logger.log( + f"워커 정책 로드: periodic={self._periodic_restart_every}, mem_thr={self._mem_restart_threshold}%, escalate_after={self._mem_error_escalate_after}", + level=logging.INFO, + ) + except Exception as e: + self.logger.log(f"워커 정책 로드 실패: {e}", level=logging.WARNING) + diff --git a/mainUI_SP.py b/mainUI_SP.py index 385bdd94..205cbf63 100644 --- a/mainUI_SP.py +++ b/mainUI_SP.py @@ -1212,7 +1212,9 @@ class MAIN_GUI(QMainWindow): "migan_use_cuda": False, # 3050 4GB면 True 권장(실패시 자동 CPU 폴백) "migan_intra_threads": 0, "migan_inter_threads": 0, - + "migan_use_tensorrt": True, + "migan_trt_fp16_enable": True, + "migan_max_image_size": 2048 } diff --git a/setup.py b/setup.py index 40b6f6fd..ee538ab7 100644 --- a/setup.py +++ b/setup.py @@ -113,6 +113,8 @@ dll_files = [ system32_path = "C:/Windows/System32/downlevel" dll_include_files = [(os.path.join(system32_path, dll), dll) for dll in dll_files] +dll_include_files = [] # 충돌 가능성으로 인해 빈 리스트로 초기화 + # 패들 라이브러리 경로 설정 # 경로 수정: scripts/Lib -> Lib로 변경 base_path = os.path.dirname(os.path.abspath(__file__)) @@ -124,6 +126,7 @@ paddleocr_path = os.path.join(site_packages, "paddleocr") paddle_includes = [] + # 경로가 존재하는 경우에만 포함 if os.path.exists(paddle_path): paddle_includes.append((paddle_path, 'lib/paddle')) @@ -133,8 +136,23 @@ if os.path.exists(paddleocr_path): paddle_includes.append((paddleocr_path, 'lib/paddleocr')) print(f"paddleocr 경로 추가: {paddleocr_path}") +onnxruntime_capi = os.path.join(site_packages, 'onnxruntime', 'capi') +if os.path.exists(onnxruntime_capi): + onnxruntime_includes.append((onnxruntime_capi, 'lib/onnxruntime/capi')) + +vc_runtime_files = [ + ('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'), +] +include_files.extend([f for f in vc_runtime_files if os.path.exists(f[0])]) + # ✅ 기존 포함 파일 + DLL 추가 -include_files = dll_include_files + paddle_includes + [ +include_files = dll_include_files + paddle_includes + onnxruntime_includes + vc_runtime_files + [ # include_files = dll_include_files + [ # 나머지 파일들 ('src/ppocr/PP_Models', 'lib/src/ppocr/PP_Models'), @@ -176,6 +194,7 @@ for src, dest in include_files: # 사용된 패키지 정의 build_exe_options = { + 'include_msvcr': True, # VC++ 런타임 자동 포함 'packages': [ 'ctypes', 'asyncio', 'subprocess', 'pyperclip', 'numpy', diff --git a/src/contents/option.py b/src/contents/option.py index 79589397..46e8a0ad 100644 --- a/src/contents/option.py +++ b/src/contents/option.py @@ -1131,52 +1131,63 @@ class OptionHandler: async def adjust_options_by_index(self, filtered, options_items, max_option_count): """ 인덱스 기반 옵션 체크/해제. 상태 span을 재조회해서 실제 반영 확인. - filtered: [{'index':i, ...}, ...] (선택대상) + filtered: [{'index':i, 'original_name': str, ...}, ...] (선택대상) options_items: option_type_1의 items 리스트 """ selected_count = 0 - filtered_idx_set = set([opt["index"] for opt in filtered]) + # 재정렬되는 환경 대응: 인덱스 대신 원본 옵션명으로 대상으로 판단 + filtered_name_set = set([opt.get("original_name") for opt in filtered if opt.get("original_name")]) + + # option_type_1 컨테이너 Locator (첫 번째 콜랩스 박스) + option_type_1_container = self.page.locator("div#productMainContentContainerId div.ant-collapse-content-box").first for idx, item in enumerate(options_items): - element = item.get("checkbox_elem") + original_name = item.get("original_name") + if not original_name: + continue - # 현재 상태 확인 (제외된 옵션 span) - span_elems = await element.query_selector_all("span") - is_excluded = False - for span in span_elems: - text = (await span.inner_text()).strip() - if text == "제외된 옵션": - is_excluded = True - break - item["is_excluded"] = is_excluded + # 현재 상태 재조회: 원본 옵션명으로 해당 아이템을 찾아 검사 + item_locator = option_type_1_container.locator( + "li.ant-list-item[aria-roledescription='sortable']" + ).filter(has_text=original_name) - should_checked = idx in filtered_idx_set and selected_count < max_option_count + # '제외된 옵션' 여부 확인 + try: + excluded_count = await item_locator.locator("span:has-text('제외된 옵션')").count() + is_excluded = excluded_count > 0 + except Exception: + is_excluded = False + + # 목표 상태 산정 (이름 기준) + within_limit = (max_option_count == 0) or (selected_count < max_option_count) + should_checked = (original_name in filtered_name_set) and within_limit should_excluded = not should_checked + self.logger.log( - f"[{idx}] {item.get('original_name')} | 현재상태: {'제외됨' if is_excluded else '체크됨'} | 목표: {'체크' if should_checked else '제외'}", + f"[{idx}] {original_name} | 현재상태: {'제외됨' if is_excluded else '체크됨'} | 목표: {'체크' if should_checked else '제외'}", level=logging.DEBUG, ) - if should_checked and is_excluded: - label_elem = await element.query_selector("label.ant-checkbox-wrapper") - if label_elem: - await label_elem.click() - await self.page.wait_for_timeout(50) # 50~100ms + # 클릭 대상 라벨 Locator (Locator는 재해결되어 분리 오류를 피함) + label_locator = item_locator.locator("label.ant-checkbox-wrapper") - # 상태 변화 기다리며, 최대 1초 대기 + if should_checked and is_excluded: + try: + await label_locator.click() + await self.page.wait_for_timeout(50) + except Exception as e: + self.logger.log(f"옵션[{idx}] 체크 시도 클릭 오류: {e}", level=logging.WARNING) + + # 상태 변화 대기 및 재확인 (재정렬 대비 매번 재조회) for _ in range(5): await asyncio.sleep(0.2) - span_elems = await element.query_selector_all("span") - after_is_excluded = False - for span in span_elems: - text = (await span.inner_text()).strip() - if text == "제외된 옵션": - after_is_excluded = True - break + item_locator = option_type_1_container.locator( + "li.ant-list-item[aria-roledescription='sortable']" + ).filter(has_text=original_name) + excluded_count = await item_locator.locator("span:has-text('제외된 옵션')").count() + after_is_excluded = excluded_count > 0 if not after_is_excluded: break - item["is_excluded"] = after_is_excluded - if after_is_excluded: self.logger.log(f"옵션[{idx}] 체크 시도 후에도 '제외된 옵션' 상태임! 재시도 필요", level=logging.ERROR) else: @@ -1184,25 +1195,22 @@ class OptionHandler: self.logger.log(f"옵션[{idx}] 체크 성공", level=logging.INFO) elif should_excluded and not is_excluded: - label_elem = await element.query_selector("label.ant-checkbox-wrapper") - if label_elem: - await label_elem.click() - await self.page.wait_for_timeout(50) # 50~100ms + try: + await label_locator.click() + await self.page.wait_for_timeout(50) + except Exception as e: + self.logger.log(f"옵션[{idx}] 체크해제 시도 클릭 오류: {e}", level=logging.WARNING) - # 상태 변화 기다리며, 최대 1초 대기 + # 상태 변화 대기 및 재확인 (재정렬 대비 매번 재조회) for _ in range(5): await asyncio.sleep(0.2) - span_elems = await element.query_selector_all("span") - after_is_excluded = False - for span in span_elems: - text = (await span.inner_text()).strip() - if text == "제외된 옵션": - after_is_excluded = True - break + item_locator = option_type_1_container.locator( + "li.ant-list-item[aria-roledescription='sortable']" + ).filter(has_text=original_name) + excluded_count = await item_locator.locator("span:has-text('제외된 옵션')").count() + after_is_excluded = excluded_count > 0 if after_is_excluded: break - item["is_excluded"] = after_is_excluded - if not after_is_excluded: self.logger.log(f"옵션[{idx}] 체크해제 시도 후에도 체크상태임! 재시도 필요", level=logging.ERROR) else: @@ -1212,7 +1220,7 @@ class OptionHandler: if should_checked: selected_count += 1 - if selected_count >= max_option_count: + if max_option_count and selected_count >= max_option_count: self.logger.log(f"최대 선택 옵션 수 {max_option_count} 도달, 이후 옵션은 제외처리", level=logging.DEBUG) continue diff --git a/src/modules/8.jpg b/src/modules/8.jpg deleted file mode 100644 index a27fd74d..00000000 Binary files a/src/modules/8.jpg and /dev/null differ diff --git a/src/modules/gpu_utils.py b/src/modules/gpu_utils.py new file mode 100644 index 00000000..bf83010d --- /dev/null +++ b/src/modules/gpu_utils.py @@ -0,0 +1,276 @@ +# -*- coding: utf-8 -*- +""" +GPU 유틸리티 모듈 - CUDA 지원 및 GPU 상태 관리 + +기능: +- GPU 사용 가능성 검사 +- CUDA 지원 여부 확인 +- 전역 GPU 상태 관리 +- CPU 폴백 처리 +""" + +import os +import logging +import subprocess +import platform +from typing import Optional, Dict, Any + + +class GPUManager: + """GPU 상태 관리 및 CUDA 지원 확인""" + + def __init__(self, logger: Optional[object] = None): + self.logger = logger or self._create_dummy_logger() + + # GPU 상태 전역 변수들 + self.can_use_cuda = False + self.cuda_available = False + self.gpu_info = {} + self.initialization_attempted = False + + def _create_dummy_logger(self): + """로거가 없을 때 사용할 더미 로거""" + class DummyLogger: + def log(self, msg, level=logging.INFO, exc_info=False): + print(f"[GPU] {msg}") + return DummyLogger() + + def initialize_gpu_state(self, toggle_states: Dict[str, Any]) -> None: + """ + GPU 상태를 초기화하고 전역 변수에 저장 + + Args: + toggle_states: 설정 딕셔너리 + """ + if self.initialization_attempted: + return # 이미 초기화됨 + + self.initialization_attempted = True + + # 사용자가 CUDA 사용을 원하는지 확인 + use_cuda_requested = toggle_states.get("use_cuda", False) + + self.logger.log("=== GPU 상태 초기화 시작 ===", level=logging.INFO) + self.logger.log(f"사용자 CUDA 사용 요청: {use_cuda_requested}", level=logging.INFO) + + if not use_cuda_requested: + self.logger.log("CUDA 사용이 비활성화됨 (toggle_states['use_cuda'] = False)", level=logging.INFO) + self.can_use_cuda = False + return + + # GPU 하드웨어 검사 + gpu_detected = self._detect_gpu_hardware() + + if not gpu_detected: + self.logger.log("GPU 하드웨어가 감지되지 않음 - CPU 모드로 전환", level=logging.WARNING) + self.can_use_cuda = False + return + + # CUDA 설치 및 작동 상태 확인 + cuda_working = self._check_cuda_installation() + + if not cuda_working: + self.logger.log("CUDA가 정상적으로 작동하지 않음 - CPU 모드로 전환", level=logging.WARNING) + self.can_use_cuda = False + return + + # PyTorch/ONNXRuntime CUDA 지원 확인 + framework_cuda_support = self._check_framework_cuda_support() + + if not framework_cuda_support: + self.logger.log("ML 프레임워크에서 CUDA를 지원하지 않음 - CPU 모드로 전환", level=logging.WARNING) + self.can_use_cuda = False + return + + # 모든 검사 통과 + self.can_use_cuda = True + self.cuda_available = True + + self.logger.log("✅ CUDA 사용 가능 - GPU 가속 모드로 동작", level=logging.INFO) + self.logger.log(f"GPU 정보: {self.gpu_info}", level=logging.INFO) + self.logger.log("=== GPU 상태 초기화 완료 ===", level=logging.INFO) + + def _detect_gpu_hardware(self) -> bool: + """GPU 하드웨어 감지""" + try: + if platform.system() != "Windows": + self.logger.log("현재 Windows만 지원됨", level=logging.WARNING) + return False + + # nvidia-smi 명령어로 GPU 확인 + result = subprocess.run( + ["nvidia-smi", "--query-gpu=name,memory.total,driver_version", "--format=csv,noheader,nounits"], + capture_output=True, + text=True, + timeout=10, + creationflags=subprocess.CREATE_NO_WINDOW if platform.system() == "Windows" else 0 + ) + + if result.returncode == 0 and result.stdout.strip(): + gpu_lines = result.stdout.strip().split('\n') + for i, line in enumerate(gpu_lines): + if line.strip(): + parts = [p.strip() for p in line.split(',')] + if len(parts) >= 3: + self.gpu_info[f'gpu_{i}'] = { + 'name': parts[0], + 'memory_mb': parts[1], + 'driver_version': parts[2] + } + + self.logger.log(f"GPU 하드웨어 감지됨: {len(self.gpu_info)}개", level=logging.INFO) + for gpu_id, info in self.gpu_info.items(): + self.logger.log(f" {gpu_id}: {info['name']} ({info['memory_mb']}MB, 드라이버 {info['driver_version']})", level=logging.INFO) + return True + else: + self.logger.log(f"nvidia-smi 실행 실패: {result.stderr}", level=logging.WARNING) + return False + + except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError) as e: + self.logger.log(f"GPU 하드웨어 감지 실패: {e}", level=logging.WARNING) + return False + except Exception as e: + self.logger.log(f"GPU 하드웨어 감지 중 예외: {e}", level=logging.ERROR, exc_info=True) + return False + + def _check_cuda_installation(self) -> bool: + """CUDA 설치 및 작동 상태 확인""" + try: + # nvcc 버전 확인 + result = subprocess.run( + ["nvcc", "--version"], + capture_output=True, + text=True, + timeout=10, + creationflags=subprocess.CREATE_NO_WINDOW if platform.system() == "Windows" else 0 + ) + + if result.returncode == 0: + version_output = result.stdout + self.logger.log(f"CUDA 컴파일러 감지됨", level=logging.INFO) + + # 버전 정보 추출 + for line in version_output.split('\n'): + if 'release' in line.lower(): + self.logger.log(f"CUDA 버전: {line.strip()}", level=logging.INFO) + break + + return True + else: + self.logger.log("CUDA 컴파일러(nvcc)를 찾을 수 없음", level=logging.WARNING) + return False + + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + self.logger.log(f"CUDA 설치 확인 실패: {e}", level=logging.WARNING) + return False + except Exception as e: + self.logger.log(f"CUDA 설치 확인 중 예외: {e}", level=logging.ERROR, exc_info=True) + return False + + def _check_framework_cuda_support(self) -> bool: + """ML 프레임워크의 CUDA 지원 확인""" + support_count = 0 + + # ONNXRuntime CUDA 지원 확인 + try: + import onnxruntime as ort + providers = ort.get_available_providers() + if "CUDAExecutionProvider" in providers: + self.logger.log("✅ ONNXRuntime CUDA 지원 확인됨", level=logging.INFO) + support_count += 1 + else: + self.logger.log("❌ ONNXRuntime CUDA 지원 없음", level=logging.WARNING) + self.logger.log(f"사용 가능한 providers: {providers}", level=logging.DEBUG) + except ImportError: + self.logger.log("ONNXRuntime가 설치되지 않음", level=logging.WARNING) + except Exception as e: + self.logger.log(f"ONNXRuntime CUDA 지원 확인 실패: {e}", level=logging.WARNING) + + # PyTorch CUDA 지원 확인 (선택적) + try: + import torch + if torch.cuda.is_available(): + device_count = torch.cuda.device_count() + current_device = torch.cuda.current_device() + device_name = torch.cuda.get_device_name(current_device) + self.logger.log(f"✅ PyTorch CUDA 지원 확인됨 ({device_count}개 디바이스, 현재: {device_name})", level=logging.INFO) + support_count += 1 + else: + self.logger.log("❌ PyTorch CUDA 지원 없음", level=logging.WARNING) + except ImportError: + self.logger.log("PyTorch가 설치되지 않음 (선택적)", level=logging.DEBUG) + except Exception as e: + self.logger.log(f"PyTorch CUDA 지원 확인 실패: {e}", level=logging.WARNING) + + # 최소 하나의 프레임워크에서 CUDA를 지원해야 함 + if support_count > 0: + self.logger.log(f"ML 프레임워크 CUDA 지원: {support_count}개 확인됨", level=logging.INFO) + return True + else: + self.logger.log("ML 프레임워크에서 CUDA를 지원하지 않음", level=logging.WARNING) + return False + + def get_cuda_status(self) -> Dict[str, Any]: + """현재 CUDA 상태 정보 반환""" + return { + "can_use_cuda": self.can_use_cuda, + "cuda_available": self.cuda_available, + "gpu_info": self.gpu_info.copy(), + "initialization_attempted": self.initialization_attempted + } + + def force_cpu_mode(self) -> None: + """강제로 CPU 모드로 전환""" + self.can_use_cuda = False + self.logger.log("강제로 CPU 모드로 전환됨", level=logging.WARNING) + + def log_gpu_memory_usage(self) -> None: + """현재 GPU 메모리 사용량 로깅""" + if not self.can_use_cuda: + return + + try: + result = subprocess.run( + ["nvidia-smi", "--query-gpu=memory.used,memory.total", "--format=csv,noheader,nounits"], + capture_output=True, + text=True, + timeout=5, + creationflags=subprocess.CREATE_NO_WINDOW if platform.system() == "Windows" else 0 + ) + + if result.returncode == 0 and result.stdout.strip(): + lines = result.stdout.strip().split('\n') + for i, line in enumerate(lines): + if line.strip(): + parts = [p.strip() for p in line.split(',')] + if len(parts) >= 2: + used_mb = int(parts[0]) + total_mb = int(parts[1]) + usage_percent = (used_mb / total_mb) * 100 + self.logger.log( + f"GPU {i} 메모리 사용량: {used_mb}MB/{total_mb}MB ({usage_percent:.1f}%)", + level=logging.INFO + ) + except Exception as e: + self.logger.log(f"GPU 메모리 사용량 확인 실패: {e}", level=logging.DEBUG) + + +# 전역 GPU 관리자 인스턴스 (선택적 사용) +_global_gpu_manager = None + +def get_global_gpu_manager(logger=None) -> GPUManager: + """전역 GPU 관리자 인스턴스 반환""" + global _global_gpu_manager + if _global_gpu_manager is None: + _global_gpu_manager = GPUManager(logger) + return _global_gpu_manager + + +def check_cuda_simple() -> bool: + """간단한 CUDA 사용 가능성 확인 (캐시 없음)""" + try: + import onnxruntime as ort + providers = ort.get_available_providers() + return "CUDAExecutionProvider" in providers + except: + return False diff --git a/src/modules/image_processor3.py b/src/modules/image_processor3.py index df05f911..8381236a 100644 --- a/src/modules/image_processor3.py +++ b/src/modules/image_processor3.py @@ -25,6 +25,7 @@ from translatepy.translators.google import GoogleTranslate # from src.modules.background_removal_module_pp import PPMattingBackgroundRemovalModule # (변경) from src.modules.request_inpaint import Request_AI_Server +from src.modules.gpu_utils import GPUManager class ImageProcessor3: """이미지 다운로드, OCR, 번역 처리를 담당하는 클래스""" @@ -37,6 +38,10 @@ class ImageProcessor3: self.unwanted_texts = unwanted_words self.authenticated_by_admin = authenticated_by_admin + # GPU 관리자 초기화 + self.gpu_manager = GPUManager(logger=logger) + self.gpu_manager.initialize_gpu_state(toggle_states) + self.logger.log(f"ImageProcessor3 Init toggle_states: {self.toggle_states}", level=logging.DEBUG) self.papago_translator = papago_translator @@ -94,7 +99,13 @@ class ImageProcessor3: if not self.is_frozen(): self.request_rembg_server_url = self.toggle_states.get("request_rembg_server_url_local", None) - 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) + # Request_AI_Server에도 GPU 상태 전달 + 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, + gpu_manager=self.gpu_manager + ) self.gtranslate = GoogleTranslate() @@ -102,7 +113,16 @@ class ImageProcessor3: 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) + # GPU 상태에 따라 CUDA 사용 여부 결정 + enhanced_toggle_states = self.toggle_states.copy() + if self.gpu_manager.can_use_cuda: + enhanced_toggle_states["migan_use_cuda"] = enhanced_toggle_states.get("use_cuda", False) + self.logger.log(f"MIGAN CUDA 사용 설정: {enhanced_toggle_states['migan_use_cuda']}", level=logging.INFO) + else: + enhanced_toggle_states["migan_use_cuda"] = False + self.logger.log("MIGAN CUDA 사용 불가 - CPU 모드로 설정", level=logging.INFO) + + self.migan = build_migan_from_toggle(enhanced_toggle_states, logger=self.logger) else: self.migan = None self.logger.log("migan_onnx_path 미설정: MIGAN 비활성", level=logging.INFO) @@ -1026,10 +1046,11 @@ class ImageProcessor3: except Exception as e: msg = str(e).lower() # 메모리 / primitive 관련 오류 → OCR 모듈 재초기화 후 1회 재시도 - if any(err in msg for err in ["create a primitive", "memory object", "unable to allocate"]): + if any(err in msg for err in ["create a primitive", "memory object", "unable to allocate", "out of memory", "cv::outofmemoryerror"]): ok = self.reset_ocr_module() if ok: - return self.ocr_module.detect_text(img_path) + # 재시도 시 실패하면 MemoryError를 재전파하여 상위에서 워커 재시작 트리거 + return self.ocr_module.detect_text(img_path, raise_on_memory_error=True) # 그 외 예외는 그대로 상위로 전달 raise diff --git a/src/modules/ocr_module.py b/src/modules/ocr_module.py index 4806eb2b..f95c89cc 100644 --- a/src/modules/ocr_module.py +++ b/src/modules/ocr_module.py @@ -17,6 +17,12 @@ class OCRModule: # CPU만 사용하도록 환경 변수 설정 os.environ['CUDA_VISIBLE_DEVICES'] = '' + # OpenCV OpenCL 비활성화 (메모리 primitive 오류 예방) + try: + import cv2 as _cv2 + _cv2.ocl.setUseOpenCL(False) + except Exception: + pass # 멀티 스레드 사용 시 오류 방지 os.environ["OMP_NUM_THREADS"] = "1" @@ -45,13 +51,16 @@ class OCRModule: try: from paddleocr import PaddleOCR + # 메모리 사용량을 줄이기 위해 배치/스레드 수를 제한 ocr = PaddleOCR( use_gpu=False, use_angle_cls=True, # 텍스트 방향 분류 활성화 lang="ch", det_model_dir=self.det_model_dir, rec_model_dir=self.rec_model_dir, - cls_model_dir=self.cls_model_dir + cls_model_dir=self.cls_model_dir, + rec_batch_num=4, # 기본(6)보다 낮춰 메모리 사용 축소 + cpu_threads=2 # 스레드 수 제한으로 피크 메모리 완화 ) return ocr except Exception as e: @@ -60,7 +69,7 @@ class OCRModule: return None - def detect_text(self, image_path: str, method: str = 'polygon') -> List[Dict[str, Any]]: + def detect_text(self, image_path: str, method: str = 'polygon', raise_on_memory_error: bool = False) -> List[Dict[str, Any]]: """ 이미지에서 텍스트를 감지하고 다양한 방식으로 영역 반환 @@ -110,21 +119,38 @@ class OCRModule: try: ocr_raw_results = self.ocr.ocr(image) except Exception as e: - # 메모리 오류나 기타 예외가 발생할 경우 1/2로 추가 축소 후 재시도 - if 'unable to allocate' in str(e) or isinstance(e, MemoryError): + # 메모리 오류나 기타 예외가 발생할 경우 1/2 → 1/4로 단계적 축소 재시도 + err_msg = str(e).lower() + mem_signals = [ + 'unable to allocate', + 'out of memory', + 'could not create a memory object', + 'create a primitive', + 'cv::outofmemoryerror', + ] + if isinstance(e, MemoryError) or any(s in err_msg for s in mem_signals): try: h, w = image.shape[:2] self.logger.log( f"🔁 OCR 메모리 오류 재시도: 이미지 1/2 축소 ({w}x{h}) → ({w//2}x{h//2})", level=logging.WARNING, ) - image_small = cv2.resize(image, (w // 2, h // 2), interpolation=cv2.INTER_AREA) + image_small = cv2.resize(image, (max(1, w // 2), max(1, h // 2)), interpolation=cv2.INTER_AREA) ocr_raw_results = self.ocr.ocr(image_small) except Exception as e2: - self.logger.log( - f"❌ OCR 재시도 실패: {e2}", level=logging.ERROR, exc_info=True, - ) - return [] + try: + self.logger.log( + f"🔁 2차 재시도(1/4 축소): {e2}", level=logging.WARNING, + ) + image_small2 = cv2.resize(image, (max(1, w // 4), max(1, h // 4)), interpolation=cv2.INTER_AREA) + ocr_raw_results = self.ocr.ocr(image_small2) + except Exception as e3: + self.logger.log( + f"❌ OCR 재시도 실패: {e3}", level=logging.ERROR, exc_info=True, + ) + if raise_on_memory_error: + raise MemoryError(f"OCR memory/primitive failure after retries: {e3}") + return [] else: raise diff --git a/src/modules/pil.py b/src/modules/pil.py deleted file mode 100644 index b9a16544..00000000 --- a/src/modules/pil.py +++ /dev/null @@ -1,7 +0,0 @@ -from PIL import ImageFont -font_path = r"D:\py\AutoPercenty3_311\src\modules\fonts\HakgyoansimDunggeunmisoTTFB.ttf" -try: - font = ImageFont.truetype(font_path, 24) - print("폰트 로드 성공") -except Exception as e: - print("폰트 로드 실패:", e) \ No newline at end of file diff --git a/src/titleManager/gpt_client.py b/src/titleManager/gpt_client.py index 51708d27..8b55f026 100644 --- a/src/titleManager/gpt_client.py +++ b/src/titleManager/gpt_client.py @@ -50,16 +50,33 @@ class GPTClient: # JSON 구조에 영향을 주지 않는 안전한 문자 변환만 적용 json_replacements = { "【": "[", - "】": "]", + "】": "]", "(": "(", - ")": ")" - # 주의: 콤마(,)나 기타 JSON 구조 문자는 변환하지 않음 + ")": ")", + "“": '"', + "”": '"', + "„": '"', + "‘": "'", + "’": "'", + "\ufeff": "", # BOM 제거 + "\u200b": "", # zero width space + "\u200c": "", + "\u200d": "", } - + cleaned = content for old_char, new_char in json_replacements.items(): cleaned = cleaned.replace(old_char, new_char) - + + # 키의 닫는 따옴표 앞에 잘못 삽입된 백슬래시를 제거: "1\": → "1": + cleaned = re.sub(r'\\"(?=\s*:)', '"', cleaned) + + # 객체/배열 종료 직전의 트레일링 콤마 제거 + cleaned = re.sub(r',\s*([}\]])', r'\1', cleaned) + + # 앞뒤 공백 및 개행 정리 + cleaned = cleaned.strip() + return cleaned def _create_gpt5_response(self, prompt: str) -> dict: @@ -90,7 +107,16 @@ class GPTClient: # 추가 로깅으로 디버깅 개선 self.logger.log(f'JSON 파싱 시도 전 정리된 내용: {cleaned_content}', level=logging.DEBUG) - return json.loads(cleaned_content) + try: + return json.loads(cleaned_content) + except json.JSONDecodeError: + # 추가 보정 1차: 키 끝의 이스케이프 제거 및 트레일링 콤마 제거 후 재시도 + repaired_once = re.sub(r'\\"(?=\s*:)', '"', cleaned_content) + repaired_once = re.sub(r',\s*([}\]])', r'\1', repaired_once) + if repaired_once != cleaned_content: + self.logger.log(f'JSON 1차 보정 후 재시도: {repaired_once}', level=logging.DEBUG) + return json.loads(repaired_once) + raise except json.JSONDecodeError as e: self.logger.log(f'JSON 디코딩 실패: {e}. 원본 응답: {content}', level=logging.ERROR, exc_info=True) 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 843fc20f..633da222 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 09dd65bc..cf8673e8 100644 --- a/updateManager/__version__.py +++ b/updateManager/__version__.py @@ -7,7 +7,7 @@ import logging """ 프로그램 기본 정보 """ __title__ = "Edit_PartTimer" __description__ = "편집알바생" -__version__ = "3.11.2" +__version__ = "3.11.4" __build__ = "1" # 빌드 번호 __author__ = "WhenRideMyCar" __author_email__ = "abc@gmail.com" diff --git a/updateManager/updateLog.md b/updateManager/updateLog.md index 815908fc..b4f91962 100644 --- a/updateManager/updateLog.md +++ b/updateManager/updateLog.md @@ -3,12 +3,19 @@ -# 3.11.2 업데이트 로그 +# 3.11.4 업데이트 로그 +### 패치 4 오류수정 +- GPT5 응답처리 변경 +- DLL 누락 추가 +### 패치 4 기능변경 +- 메모리 상황에 따른 동적재시작 추가 + +### 패치 3 기능변경 +- 옵션 선택방식 변경(업데이트 대응) ### 패치 2 오류수정 - 누끼서버 폴백기능 오류 수정 - ### 패치 2 기능추가 - 이미지번역 폴백2 추가 @@ -17,16 +24,15 @@ - 폰트누락 수정 (죄송합니다. 기본폰트 이외의 폰트가 누락되었었습니다) - 이미지워커 재시작 오류 수정 - 네이버검색 API 오류 수정 - ### 패치 1 기능추가 - 누끼서버 폴백기능 추가(Self수행) -### 3.10 마이너 업데이트 오류수정 +### 3.11 마이너 업데이트 오류수정 - 기존버전 사용자 중 상세페이지 번역 누락 수정 - 이미지워커 중복큐 제거 - 이미지폰트가 저장되지 않던 문제 수정 -### 3.10 마이너 업데이트 기능 추가 +### 3.11 마이너 업데이트 기능 추가 - gpt5의 특수문자 처리 로직 추가 - gpt5 폴백 개선 - 이미지 처리방식 webp 모드 추가(기본적용)