Introduction
안녕하세요, codedbyjst입니다!
프로그램 작동 영상을 확인해보신 분이라면 아마 이 생각이 가장 먼저 드셨을 겁니다.
✈️'빠르다!'✈️
예 맞습니다, 정말 빠릅니다!
원하는 목표가 도출되지 않았을 시 전체 루프 1회 실행 시간은 약 2분 정도로, 기타 다른 매크로와 비교해도 거의 최고 수준의 속도를 보입니다.
그런데 대체 어떤 방식을 이용했길래 이렇게 빠른 걸까요?
사실 루프 세분화, greedy한 동작 등 여러 부분에서 실행시간을 단축시키기 위해 노력한 부분들이 있지만, 가장 시간 단축에 영향을 크게 준 것은 바로 '스크린샷' 기능의 구현 방식입니다.
이번에는 개발 과정 중에 가장 연구에 시간이 많이 투자된 '스크린샷' 기능에 대해 이야기해보려 합니다.
[첫번째 접근] 아니, 그거 그냥 화면 스크린샷 찍어서 가져오면 되는거 아닌가?
처음 접근한 방식은 매우 기초적인 방식이었습니다.
adb를 이용해서 안드로이드에 직접 접근해서, 스크린샷 파일을 png로 저장되게 한 다음, 해당 파일을 꺼내오는 방식이었습니다.
실제로 해보시면 알겠지만, 이 방식은 매우 느립니다.(약 1초이상 소요)
생각해보면 당연한것이, 과정 중에 Android 내부 처리 과정에 보면 3번 단계(파일을 저장하는 과정)이 있는데,
해당 방식은 I/O 처리시간을 요구하게 되므로 Android에도 부담을 주고 시간 지연이 생깁니다.
# 1번 방식(Android에게 모든 작업 위임)
subprocess.run([ADB_PATH, "-s", self.control_device_serial, "shell", "screencap", "-p", "/sdcard/screen.png"], capture_output=True)
subprocess.run([ADB_PATH, "-s", self.control_device_serial, "pull", "/sdcard/screen.png", "./TEMP/screen.png"], capture_output=True)
[두번째 접근] I/O 개선
그렇다면 I/O 처리를 Android 단에서 최소화시켜주면 좀 더 빠르지 않을까요?
조금 더 자세히 말하자면, /sdcard/screen.png파일로 저장하고 해당 파일을 가져오는 대신,
변환된 png파일(바이트 결과값)을 그대로 출력 버퍼로 받아서 컴퓨터에 파일을 저장하면 되지 않을까요?
해당 방식은 실제로 아주 약간 더 빠릅니다!
Android가 해야 할 단계를 하나 건너뛰었으니 당연히 더욱 빠르겠죠!
다만, 스크린샷 촬영 과정 중 가장 Android에게 부담을 씌우는 부분은 해당 부분이 아니라,
'raw를 png로 변환'하는 단계입니다. 여전히 해당 부분이 상당한 시간 지연을 일으키고 있네요.
# 2번 방식 : 파일을 그대로 stdout로 불러와 1번보단 빠르지만, 여전히 png 변환을 이용하여 조금 느립니다.(디스플레이 멈춤 현상 발생)
with open("./TEMP/screen.png", "w") as file:
subprocess.run([ADB_PATH, "-s", self.control_device_serial, "exec-out", "screencap", "-p"], stdout=file)
[세번째 접근] raw => png Python에게 맡기기
그렇다면 raw를 png로 변환하는 작업을 더욱 빠른 Python에게 맡기면 돼죠!
2번째 방식처럼 출력 버퍼를 이용해서 raw를 가져오고, raw를 png로 변환하는 작업을 Python에게 위임하면 됩니다.
상당한 시간 단축이 이루어집니다!(약 절반으로 감소)
이 정도면 처음에 비해 상당히 많이 빨라졌네요. 하지만 아직 개선될 여지가 남아있습니다.
결국 이 방식도 adb를 이용하여 명령을 내리는 방식인데, adb의 구동방식 특성상
명령어를 인식하여 새로운 java 앱을 생성 => 실행 => 결과 반환을 진행하기 때문에
초 미만의 단위로 스크린샷을 가져오기엔 구조적 문제가 있습니다.
# 3번 방식 : raw 파일로 불러와 처리합니다.
with open("./TEMP/screen.raw", "w") as file:
subprocess.run([ADB_PATH, "-s", self.control_device_serial, "exec-out", "screencap",], stdout=file)
with open('./TEMP/screen.raw', 'rb') as raw:
data = raw.read()
image_pil = Image.frombuffer('RGBA', (self.height, self.width), data[12:], 'raw', 'RGBX', 0, 1) # 세로 화면이여서 width, height 반대인 것 주의.
image_np = np.array(image_pil)
screenshot_img = cv2.cvtColor(image_np, cv2.COLOR_RGB2BGR) # CV2 용으로 변환
cv2.imwrite("./TEMP/screen.png", screenshot_img) # 파일로 저장
self.resized_screenshot_img = cv2.resize(screenshot_img, (480,800))
[네번째 접근] uiautomator2 이용
그럼 어떻게 하면 될까요?
쉽습니다. adb말고 다른 걸 쓰면 돼죠!
uiautomator2는 안드로이드 테스트 자동화를 위한 프레임워크입니다.
사실 appium이라는 자동화 툴을 위해서 주로 사용하는데, 호환성이 좋기 때문에 그냥 개별적으로 사용해도 괜찮습니다!
uiautomator2는 adb와 다른 구조로 Android와 통신하기 때문에, 기존의 adb를 이용한 방식에 비해 빠른 자동화 처리가 가능합니다.
정말 빨라졌습니다!
처음의 1/3정도의 시간만으로 스크린샷을 찍어오고 처리하는 것을 확인할 수 있었습니다.
현재까지 파악한 바론, 해당 방식이 Android 내부와 통신해서 스크린샷을 가져오는 방식 중 가장 빠른 방식입니다.
# 4번 방식 : uiautomator2 이용
# 1~3번 방식과 달리 ADB가 아닌 uiautomator2를 이용합니다.
# JAVA 앱을 실행시키는 방식인 ADB에 비교하여 빠릅니다.
screenshot_img = self.control_device.screenshot(format='opencv') # cv2 이미지 객체로 불러옵니다.
cv2.imwrite(save_path, screenshot_img)
self.resized_screenshot_img = cv2.resize(screenshot_img, (480,800))
print("[Backend]Took Screenshot!")
[다섯번째 접근] WIN32API 이용
하지만 여기서 포기할 수는 없죠. 여전히 0.5초 근처의 시간이 소요되는데, 더욱 줄일 방법은 없을까요?
고민하던 와중, 한 가지 생각이 떠오르게 됩니다.
'결국 윈도우 위에서 도는건데, 윈도우단에서 가져올 순 없나?'
결국 처리시간이 길어지는건 Android단에서 뭔가를 하려고 하니까 느려지는건데, 이를 아예 스킵하고 윈도우단에서 가져올 순 없을까요?
어차피 사람이 볼 때도 윈도우에 뜬 화면을 보고 클릭하는건데 말이죠!
결과적으로, 훨씬 빨라졌습니다!
따로 측정하진 않았습니다만, 측정하는 시간이 무의미할 정도로 매우 빠릅니다!(0.1초 미만)
# 5번 방식 : WIN32 API 이용(윈도우단 처리)
# 더 빠른 처리를 위해 win32 api를 이용합니다.
# 참조 : https://www.reddit.com/r/learnpython/comments/9f4lls/how_to_take_a_screenshot_of_a_specific_window/
toplist, winlist = [], []
def enum_cb(hwnd, results):
winlist.append((hwnd, win32gui.GetWindowText(hwnd)))
win32gui.EnumWindows(enum_cb, toplist)
# 윈도우 창 제목으로 hwnd 번호를 가져옵니다.(충돌 가능성 존재하여 사용하지 않음)
self.control_window_hwnd = win32gui.FindWindow(None, self.control_window_title)
# 스크린샷 촬영 전 창을 최소화상태에서 깨웁니다.
# 참조 : https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-showwindow
win32gui.ShowWindow(self.control_window_hwnd, win32con.SW_SHOWNOACTIVATE)
# 기본적으론, 스크린샷을 얻기 위해선 창을 맨 앞으로 가져와야 합니다.
# 하지만 그래선 확인이 어려우니, 아래의 방법을 따라 화면이 창에 가려도 상관 없도록 합니다.
# 참조 : https://stackoverflow.com/questions/19695214/screenshot-of-inactive-window-printwindow-win32gui/24352388#24352388
left, top, right, bot = win32gui.GetWindowRect(self.control_window_hwnd)
w = right - left
h = bot - top
hwndDC = win32gui.GetWindowDC(self.control_window_hwnd)
mfcDC = win32ui.CreateDCFromHandle(hwndDC)
saveDC = mfcDC.CreateCompatibleDC()
saveBitMap = win32ui.CreateBitmap()
saveBitMap.CreateCompatibleBitmap(mfcDC, w, h)
saveDC.SelectObject(saveBitMap)
result = windll.user32.PrintWindow(self.control_window_hwnd, saveDC.GetSafeHdc(), 0)
bmpinfo = saveBitMap.GetInfo()
bmpstr = saveBitMap.GetBitmapBits(True)
img = Image.frombuffer(
'RGB',
(bmpinfo['bmWidth'], bmpinfo['bmHeight']),
bmpstr, 'raw', 'BGRX', 0, 1)
win32gui.DeleteObject(saveBitMap.GetHandle())
saveDC.DeleteDC()
mfcDC.DeleteDC()
win32gui.ReleaseDC(self.control_window_hwnd, hwndDC)
# 얻은 image를 처리하기 위해 cv2 BGR image로 변환합니다.
numpy_image = np.array(img)
screenshot_img = cv2.cvtColor(numpy_image, cv2.COLOR_RGB2BGR)
if self.control_window_imagename == 'NoxVMHandle.exe':
self.resized_screenshot_img = cv2.resize(screenshot_img, (522,834))
elif self.control_window_imagename == "HD-Player.exe":
self.resized_screenshot_img = cv2.resize(screenshot_img, (514,834))
else:
self.resized_screenshot_img = cv2.resize(screenshot_img, (514,834))
self.resized_screenshot_img = self.resized_screenshot_img[-800:, :480]
cv2.imwrite(save_path, self.resized_screenshot_img)
Conclusion
결국, 초기 방식(약 1~2초)에 비해 약 20배가 넘는 처리속도 개선을 통해, 빠르게 스크린샷을 찍도록 구현에 성공했습니다.
다만 해당 방식에는 기존 방식과는 다른 문제점이 있습니다.
앱플레이어 창을 가져오는 방식을 사용하다 보니, 앱플레이어 창 크기에 민감합니다.
기존 adb 방식은 창이 최소화되있던, 크기가 매우 작던 상관없이 Android 내부에서 스크린샷을 찍기 때문에 상관이 없었습니다만, 새로운 방식은 앱플레이어 창의 스크린샷을 찍는 것으로 동작하므로 창 크기에 민감합니다.
창이 다른 창에 가려지는 것은 상관없게 처리했으나, 최소화시에는 아예 화면이 그려지지 않아 동작이 불가하므로 창은 늘 '충분히 큰 상태로', '최소화 상태가 아니어야' 합니다.
사실 이는 창 크기 초기화를 지원하는 Nox는 큰 문제가 안 됩니다만, Bluestacks의 경우 창 크기 초기화를 지원하지 않으므로 혹시 기본 창 크기에서 변경됐다면 그냥 창을 키우는 방법 외에는 별 다른 수가 없습니다.
창 제목을 가져오는 과정이 필요합니다.
코드상에선 self.control_window_title로 되어 있는 부분인데요, 기존엔 adb포트만 알면 됐던 것과 달리
앱플레이어 창의 제목을 구하는 과정이 추가적으로 필요하게 됩니다.
현재 이상적인 상태에선 작동에 문제가 없도록 구현되어 있지만, 사용하는 유저의 pc 상황에 따라 제대로 가져오지 못 할 가능성이 있습니다.
하지만 그 댓가로 얻은 매우 빠른 속도는 훌륭하기 때문에, 제가 제작한 매크로는 위의 방식을 이용합니다!
'리세마라 매크로 > 개발일지' 카테고리의 다른 글
[4080 에러] VPN, 직접 만들고 말지! (0) | 2022.08.07 |
---|---|
[Main]우마무스메 리세마라 codedby.jst (0) | 2022.08.07 |