브라우저 컨트롤러에서 컨텍스트 재시작 로직을 개선하고, 상품 단위로 컨텍스트를 재시작하는 기능을 추가하였습니다. UI에서 컨텍스트 재시작 주기를 설정할 수 있도록 수정하였으며, 이미지 처리 메서드의 안정성을 높이기 위해 오류 처리 로직을 강화하였습니다. 또한, 텍스트 렌더링 모듈에서 메모리 관리 최적화를 위한 개선 사항을 반영하였습니다.

This commit is contained in:
9700X_PC 2025-07-28 15:29:46 +09:00
parent 1217aecc16
commit 9dbb98bc56
5 changed files with 438 additions and 31 deletions

View File

@ -0,0 +1,328 @@
; AutoPercenty3 Inno Setup Script
; 이 스크립트는 cx_Freeze로 빌드된 결과물이 있는 "build\exe.win-amd64-3.11" 폴더를 기반으로 인스톨러를 제작합니다.
; 20250728_021330에 생성됨
#define AppId "autopercenty"
#define MyAppName "Edit_PartTimer"
#define MyAppVersion "3.9.10"
#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

@ -122,8 +122,11 @@ class BrowserController(QThread):
self.image_worker_restart_every = self.toggle_states.get("image_worker_restart_every", 10)
# 컨텍스트 자동 재시작 주기(분) 설정 및 태스크 핸들
self.context_restart_interval_min = self.toggle_states.get("context_restart_interval_min", None)
# # 컨텍스트 자동 재시작 주기(분) 설정 및 태스크 핸들
# self.context_restart_interval_min = self.toggle_states.get("context_restart_interval_min", None)
# 상품 N개마다 컨텍스트를 재시작하기 위한 임계치 (기본 20개)
self.products_per_context_restart = self.toggle_states.get("products_per_context_restart", 20)
self.context_restart_task = None
self.route_registered = False
@ -2793,11 +2796,19 @@ class BrowserController(QThread):
await self.insert_product_infos_to_memo(memo_button, title_infos)
await self.random_human_behavior(self.page)
except PlaywrightError as item_err:
# 페이지나 브라우저가 닫힌 치명적 오류 → 바깥으로 전파하여 재시작 유도
screenshot_path = await self.save_error_screenshot()
self.logger.log(f"▶ Playwright 오류: {item_err}", level=logging.ERROR, exc_info=True)
raise
except Exception as item_err:
# 이 상품만 실패로 기록하고, 다음 상품으로
screenshot_path = await self.save_error_screenshot()
self.logger.log(f" ▶ 상품 {index}/{len(product_buttons)} 처리중 오류: {item_err}", level=logging.ERROR, exc_info=True)
finally:
# 이전 상품 처리 중 예외로 다이얼로그가 남아 있을 수 있으므로 먼저 닫아준다.
try:
@ -2818,6 +2829,12 @@ class BrowserController(QThread):
self.image_worker_mgr.restart(cause="periodic")
self.logger.log(f"이미지 워커 재시작", level=logging.DEBUG)
# --- (추가) N개마다 컨텍스트 재시작 ---
if (completed_count % self.products_per_context_restart) == 0:
self.logger.log(f"{self.products_per_context_restart}개 상품 단위로 컨텍스트 재시작", level=logging.INFO)
await self.restart_main_context()
await self.resume_after_restart()
# #test
# self.image_worker_mgr.restart()
# self.logger.log(f"이미지 워커 재시작", level=logging.DEBUG)
@ -2859,10 +2876,15 @@ class BrowserController(QThread):
self.logger.log(f"전체 작업 중 오류: {fatal}", level=logging.ERROR, exc_info=True)
self.translation_error.emit(str(fatal))
else:
# 예외 없이 '정상적으로' 전체 루프를 마쳤을 때
self.logger.log(f"모든 상품({completed_count}/{total_products}) 처리 완료", level=logging.INFO)
self.translation_completed.emit(completed_count)
except PlaywrightError as fatal:
# 브라우저/페이지가 닫히는 등 Playwright 치명적 오류는 상위 핸들러에서 재시작하도록 전파
raise
except Exception as fatal:
screenshot_path = await self.save_error_screenshot()
# 전체 작업 중에 발생한 일반 예외 작업 중단
self.logger.log(f"전체 작업 중 오류: {fatal}", level=logging.ERROR, exc_info=True)
self.translation_error.emit(str(fatal))
except (PlaywrightError, TimeoutError) as fatal:
# Playwright 내부에서 브라우저/페이지가 닫힌 경우 등
@ -3433,7 +3455,7 @@ class BrowserController(QThread):
try:
self.logger.log("메인 브라우저 컨텍스트 재시작 시작", level=logging.INFO)
# 1. 기존 페이지/컨텍스트 정리
# 1-1. 기존 페이지/컨텍스트 정리
if self.page:
try:
await self.page.close()
@ -3445,6 +3467,18 @@ class BrowserController(QThread):
except Exception:
pass
# 1-2. 기존 parsing 브라우저 정리
if self.parsing_page:
try:
await self.parsing_page.close()
except Exception:
pass
if self.parsing_browser:
try:
await self.parsing_browser.close()
except Exception:
pass
# 2. 새 컨텍스트 생성 (로그인 세션은 user_data_dir에 보존됨)
debug_mode = self.toggle_states.get('debug_mode', False)
browser_path = os.path.join(self.base_path, 'browsers', 'chromium-1155', 'chrome-win', 'chrome.exe')
@ -3488,7 +3522,7 @@ class BrowserController(QThread):
"""
)
await self.page.goto('https://percenty.co.kr/signin')
await self.page.goto('https://percenty.co.kr')
# 불필요한 첫 탭 닫기
if self.browser.pages:
@ -3507,10 +3541,10 @@ class BrowserController(QThread):
ed_mode = self.toggle_states.get('ed_mode', False)
try:
if ed_mode:
locator = self.locator_manager.get_locator('BrowserControl', 'registered_product_page_locator')
locator = self.registered_product_page_locator
await self.page.click(locator)
else:
locator = self.locator_manager.get_locator('BrowserControl', 'new_product_page_locator')
locator = self.new_product_page_locator
await self.page.click(locator)
await self.close_ant_modal_dialogs()
except Exception as e:
@ -3543,18 +3577,34 @@ class BrowserController(QThread):
self.logger.log(f"브라우저 컨텍스트 재시작 실패: {e}", level=logging.ERROR, exc_info=True)
await self.save_error_screenshot()
async def periodic_context_restart(self, interval_minutes: int = 60):
"""주기적으로 컨텍스트를 재시작한다."""
while True:
await asyncio.sleep(interval_minutes * 60)
await self.restart_main_context()
# 3. parsing_browser 재생성
self.parsing_browser = await self.playwright.chromium.launch(
executable_path=browser_path,
headless=True,
args=[
'--disable-blink-features=AutomationControlled',
'--no-sandbox', '--disable-infobars',
'--disable-dev-shm-usage', '--disable-gpu',
'--start-minimized', '--incognito',
'--window-position=-32000,-32000',
]
)
self.parsing_context = await self.parsing_browser.new_context(
locale="ko-KR",
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36",
viewport={"width": 1280, "height": 720},
extra_http_headers={"Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7"},
)
self.parsing_page = await self.parsing_context.new_page()
self.parsing_page.set_default_navigation_timeout(30000)
self.parsing_page.set_default_timeout(30000)
# 2) PapagoTranslator 에 새 페이지 전달
self.papago_translator.update_page(self.parsing_page)
def restart_main_context_task(self):
"""이벤트 루프에 컨텍스트 재시작 작업을 예약한다."""
if self.loop and not self.loop.is_closed():
asyncio.run_coroutine_threadsafe(self.restart_main_context(), self.loop)
else:
self.logger.log("컨텍스트 재시작 태스크를 추가할 이벤트 루프가 없습니다.", level=logging.ERROR)
async def resume_after_restart(self):
"""브라우저 재시작 후 이전 진행 위치(그룹·페이지·상품)를 복원한다."""
@ -3578,6 +3628,20 @@ class BrowserController(QThread):
# async def periodic_context_restart(self, interval_minutes: int = 60):
# """주기적으로 컨텍스트를 재시작한다."""
# while True:
# await asyncio.sleep(interval_minutes * 60)
# await self.restart_main_context()
# def restart_main_context_task(self):
# """이벤트 루프에 컨텍스트 재시작 작업을 예약한다."""
# if self.loop and not self.loop.is_closed():
# asyncio.run_coroutine_threadsafe(self.restart_main_context(), self.loop)
# else:
# self.logger.log("컨텍스트 재시작 태스크를 추가할 이벤트 루프가 없습니다.", level=logging.ERROR)
#------------------------------------
@ -3693,7 +3757,7 @@ class ImageWorkerManager:
self.first_call = False
return result
async def safe_process_image(self, **kwargs):
async def safe_process_single_image(self, **kwargs):
for attempt in range(2): # 최대 1회 재시도
try:
return await self.process_single_image(**kwargs)

View File

@ -127,17 +127,21 @@ class MAIN_GUI(QMainWindow):
self.request_rembg_server_url = self.locator_manager.get_locator('Global', 'request_rembg_server_url')
self.request_rembg_server_url_local = self.locator_manager.get_locator('Global', 'request_rembg_server_url_local')
self.image_worker_restart_every = int(self.locator_manager.get_locator('Global', 'image_worker_restart_every'))
self.products_per_context_restart = int(self.locator_manager.get_locator('Global', 'products_per_context_restart'))
self.toggle_states['request_inpainting_server_url'] = self.request_inpainting_server_url
self.toggle_states['request_rembg_server_url'] = self.request_rembg_server_url
self.toggle_states['request_rembg_server_url_local'] = self.request_rembg_server_url_local
self.toggle_states['image_worker_restart_every'] = self.image_worker_restart_every
self.toggle_states['products_per_context_restart'] = self.products_per_context_restart
self.logger.log(f"request_inpainting_server_url 업데이트: {self.request_inpainting_server_url}", level=logging.DEBUG)
self.logger.log(f"request_rembg_server_url 업데이트: {self.request_rembg_server_url}", level=logging.DEBUG)
self.logger.log(f"request_rembg_server_url_local 업데이트: {self.request_rembg_server_url_local}", level=logging.DEBUG)
self.logger.log(f"image_worker_restart_every 업데이트: {self.image_worker_restart_every}", level=logging.DEBUG)
self.logger.log(f"products_per_context_restart 업데이트: {self.products_per_context_restart}", level=logging.DEBUG)
self.toggle_states['membership_level'] = self.user_membership_level
self.logger.log(f"membership_level 업데이트 : {self.toggle_states['membership_level']}", level=logging.DEBUG)
@ -1126,7 +1130,7 @@ class MAIN_GUI(QMainWindow):
'membership_level': "basic",
'image_worker_restart_every': 10,
'image_worker_restart_count': 0,
'context_restart_interval_min': 60,
'products_per_context_restart': 20,
}
# def connect_signals(self):

View File

@ -928,11 +928,14 @@ class ImageProcessor3:
try:
return self.ocr_module.detect_text(img_path)
except Exception as e:
if "create a primitive" in str(e).lower(): # primitive 에러 탐지
ok = self.reset_ocr_module() # ① OCR 모듈 재생성
msg = str(e).lower()
# 메모리 / primitive 관련 오류 → OCR 모듈 재초기화 후 1회 재시도
if any(err in msg for err in ["create a primitive", "memory object", "unable to allocate"]):
ok = self.reset_ocr_module()
if ok:
return self.ocr_module.detect_text(img_path) # 1회 재시도
raise # 실패 → 상위로 예외 던짐
return self.ocr_module.detect_text(img_path)
# 그 외 예외는 그대로 상위로 전달
raise
async def remove_background(self, original_image_url, file_prefix=""):
"""배경제거 전용 메서드 (썸네일 등 외부 호출용).

View File

@ -407,9 +407,17 @@ class TextRenderingModuleOptimized(TextRenderingModule):
roi = target_bgr[y0:y1, x0:x1]
src = src_rgba[:h, :w]
alpha = src[:, :, 3:4].astype(np.float32) / 255.0
# 보장: contiguous & float32 계산 → uint8 캐스팅
if not src.flags['C_CONTIGUOUS']:
src = np.ascontiguousarray(src)
alpha = (src[:, :, 3].astype(np.float32) / 255.0)[..., None] # (h,w,1)
if np.all(alpha == 0):
return
rgb = src[:, :, :3][:, :, ::-1].astype(np.float32) # RGBA->BGR
np.multiply(roi, 1 - alpha, out=roi, casting="unsafe")
roi += rgb * alpha
src_bgr = src[:, :, :3][:, :, ::-1].astype(np.float32) # RGBA ➜ BGR
roi_bgr = roi.astype(np.float32)
blended = roi_bgr * (1.0 - alpha) + src_bgr * alpha
roi[:, :, :] = blended.astype(np.uint8)