버전 3.11.4로 업데이트하며, 메모리 상황에 따른 동적 재시작 기능을 추가하고, GPT5 응답 처리 로직을 개선하였습니다. DLL 누락 문제를 해결하였으며, 업데이트 로그를 수정하여 변경 사항을 반영하였습니다.

This commit is contained in:
9700X_PC 2025-08-25 16:56:27 +09:00
parent 6b9ac3321f
commit 672c5a3e4f
14 changed files with 847 additions and 80 deletions

View File

@ -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;

View File

@ -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)

View File

@ -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
}

View File

@ -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',

View File

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

276
src/modules/gpu_utils.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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"

View File

@ -3,12 +3,19 @@
# 3.11.2 업데이트 로그
# 3.11.4 업데이트 로그
### 패치 4 오류수정
- GPT5 응답처리 변경
- DLL 누락 추가
### 패치 4 기능변경
- 메모리 상황에 따른 동적재시작 추가
### 패치 3 기능변경
- 옵션 선택방식 변경(업데이트 대응)
### 패치 2 오류수정
- 누끼서버 폴백기능 오류 수정
### 패치 2 기능추가
<!-- - CPU 이미지번역 개선(품질/속도개선) -->
- 이미지번역 폴백2 추가
@ -17,16 +24,15 @@
- 폰트누락 수정 (죄송합니다. 기본폰트 이외의 폰트가 누락되었었습니다)
- 이미지워커 재시작 오류 수정
- 네이버검색 API 오류 수정
### 패치 1 기능추가
- 누끼서버 폴백기능 추가(Self수행)
### 3.10 마이너 업데이트 오류수정
### 3.11 마이너 업데이트 오류수정
- 기존버전 사용자 중 상세페이지 번역 누락 수정
- 이미지워커 중복큐 제거
- 이미지폰트가 저장되지 않던 문제 수정
### 3.10 마이너 업데이트 기능 추가
### 3.11 마이너 업데이트 기능 추가
- gpt5의 특수문자 처리 로직 추가
- gpt5 폴백 개선
- 이미지 처리방식 webp 모드 추가(기본적용)