perf: reuse image array to improve memory performance

This commit is contained in:
2026-07-05 18:55:37 +08:00
parent d34c082ef3
commit aa4afbf6a2
4 changed files with 74 additions and 80 deletions
+4 -4
View File
@@ -1,16 +1,16 @@
import re
import time
from functools import wraps
import cv2
import numpy as np
import time
from adbutils.errors import AdbError
from lxml import etree
from module.base.decorator import Config
from module.device.connection import Connection
from module.device.method.utils import (RETRY_TRIES, retry_sleep, remove_prefix, handle_adb_error,
ImageTruncated, PackageNotInstalled)
from module.device.method.utils import (ImageTruncated, PackageNotInstalled, RETRY_TRIES, handle_adb_error,
remove_prefix, retry_sleep)
from module.exception import RequestHumanTakeover, ScriptError
from module.logger import logger
@@ -128,7 +128,7 @@ class Adb(Connection):
if image is None:
raise ImageTruncated('Empty image after cv2.imdecode')
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB, dst=image)
if image is None:
raise ImageTruncated('Empty image after cv2.cvtColor')
+3 -4
View File
@@ -6,8 +6,7 @@ from adbutils.errors import AdbError
from module.base.utils import *
from module.device.connection import Connection
from module.device.method.utils import (RETRY_TRIES, retry_sleep,
handle_adb_error, ImageTruncated)
from module.device.method.utils import (ImageTruncated, RETRY_TRIES, handle_adb_error, retry_sleep)
from module.exception import RequestHumanTakeover, ScriptError
from module.logger import logger
@@ -165,11 +164,11 @@ class AScreenCap(Connection):
# ValueError: cannot reshape array of size 0 into shape (720,1280,4)
raise ImageTruncated(str(e))
image = cv2.flip(image, 0)
image = cv2.flip(image, 0, dst=image)
if image is None:
raise ImageTruncated('Empty image after cv2.flip')
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB, dst=image)
if image is None:
raise ImageTruncated('Empty image after cv2.cvtColor')
+64 -68
View File
@@ -6,11 +6,11 @@ import numpy as np
import requests
from adbutils.errors import AdbError
from module.base.decorator import Config, cached_property, del_cached_property
from module.base.decorator import cached_property, del_cached_property
from module.base.timer import Timer
from module.device.method.uiautomator_2 import Uiautomator2, ProcessInfo
from module.device.method.utils import (retry_sleep, RETRY_TRIES, handle_adb_error,
ImageTruncated, PackageNotInstalled)
from module.device.method.uiautomator_2 import ProcessInfo, Uiautomator2
from module.device.method.utils import (
ImageTruncated, PackageNotInstalled, RETRY_TRIES, handle_adb_error, retry_sleep)
from module.exception import RequestHumanTakeover
from module.logger import logger
@@ -91,7 +91,7 @@ def retry(func):
class DroidCast(Uiautomator2):
"""
DroidCast, another screenshot method, https://github.com/rayworks/DroidCast
DroidCast_raw, a modified version of DroidCast sending raw bitmap https://github.com/Torther/DroidCastS
DroidCast_raw, a modified version of DroidCast sending raw bitmap and png, https://github.com/Torther/DroidCastS
"""
_droidcast_port: int = 0
@@ -103,92 +103,74 @@ class DroidCast(Uiautomator2):
self._droidcast_port = self.adb_forward('tcp:53516')
return session
def droidcast_url(self, url='/screenshot?format=png'):
"""
Check APIs from source code:
https://github.com/rayworks/DroidCast/blob/master/app/src/main/java/com/rayworks/droidcast/Main.java
"""
Check APIs from source code:
https://github.com/Torther/DroidCast_raw/blob/DroidCast_raw/app/src/main/java/ink/mol/droidcast_raw/KtMain.kt
Available APIs:
- /screenshot
To get a RGB565 bitmap
- /preview
To get PNG screenshots.
"""
Available APIs:
- /screenshot
To get JPG screenshots.
- /screenshot?format=png
To get PNG screenshots.
- /screenshot?format=webp
To get WEBP screenshots.
- /src
Websocket to get JPG screenshots.
def droidcast_url(self, url='/preview'):
return f'http://127.0.0.1:{self._droidcast_port}{url}'
Note that /screenshot?format=jpg is unavailable.
"""
def droidcast_raw_url(self, url='/screenshot'):
return f'http://127.0.0.1:{self._droidcast_port}{url}'
@Config.when(DROIDCAST_VERSION='DroidCast')
def droidcast_init(self):
logger.hr('Droidcast init')
logger.hr('DroidCast init')
self.droidcast_stop()
logger.info('Pushing DroidCast apk')
self.adb_push(self.config.DROIDCAST_FILEPATH_LOCAL, self.config.DROIDCAST_FILEPATH_REMOTE)
logger.info('Starting DroidCast apk')
# CLASSPATH=/data/local/tmp/DroidCast.apk app_process / com.rayworks.droidcast.Main > /dev/null
# DroidCast_raw-release-1.0.apk
# CLASSPATH=/data/local/tmp/DroidCast_raw.apk app_process / ink.mol.droidcast_raw.Main > /dev/null
# adb shell CLASSPATH=/data/local/tmp/DroidCast_raw.apk app_process / ink.mol.droidcast_raw.Main
resp = self.u2_shell_background([
'CLASSPATH=/data/local/tmp/DroidCast.apk',
'CLASSPATH=/data/local/tmp/DroidCast_raw.apk',
'app_process',
'/',
'com.rayworks.droidcast.Main',
'ink.mol.droidcast_raw.Main',
'>',
'/dev/null'
])
logger.info(resp)
del_cached_property(self, 'droidcast_session')
_ = self.droidcast_session
logger.attr('DroidCast', self.droidcast_url())
self.droidcast_wait_startup()
@Config.when(DROIDCAST_VERSION='DroidCast_raw')
def droidcast_init(self):
logger.hr('Droidcast init')
self.resolution_check_uiautomator2()
self.droidcast_stop()
logger.info('Pushing DroidCast apk')
self.adb_push(self.config.DROIDCAST_RAW_FILEPATH_LOCAL, self.config.DROIDCAST_RAW_FILEPATH_REMOTE)
logger.info('Starting DroidCast apk')
# DroidCastS-release-1.1.5.apk
# CLASSPATH=/data/local/tmp/DroidCastS-release-1.1.5.apk app_process / com.torther.droidcasts.Main > /dev/null
resp = self.u2_shell_background([
'CLASSPATH=/data/local/tmp/DroidCastS.apk',
'app_process',
'/',
'com.torther.droidcasts.Main',
'>',
'/dev/null'
])
logger.info(resp)
del_cached_property(self, 'droidcast_session')
_ = self.droidcast_session
logger.attr('DroidCast', self.droidcast_url())
self.droidcast_wait_startup()
if self.config.DROIDCAST_VERSION == 'DroidCast':
logger.attr('DroidCast', self.droidcast_url())
self.droidcast_wait_startup()
elif self.config.DROIDCAST_VERSION == 'DroidCast_raw':
logger.attr('DroidCast_raw', self.droidcast_raw_url())
self.droidcast_wait_startup()
else:
logger.error(f'Unknown DROIDCAST_VERSION: {self.config.DROIDCAST_VERSION}')
@retry
def screenshot_droidcast(self):
self.config.DROIDCAST_VERSION = 'DroidCast'
image = self.droidcast_session.get(self.droidcast_url(), timeout=3).content
resp = self.droidcast_session.get(self.droidcast_url(), timeout=3)
if resp.status_code == 404:
raise DroidCastVersionIncompatible('DroidCast server does not have /preview')
image = resp.content
image = np.frombuffer(image, np.uint8)
if image is None:
raise ImageTruncated('Empty image after reading from buffer')
if image.shape == (1843200,):
raise DroidCastVersionIncompatible('Requesting screenshots from `DroidCast` but server is `DroidCast_raw`')
if image.size < 500:
logger.warning(f'Unexpected screenshot: {resp.content}')
image = cv2.imdecode(image, cv2.IMREAD_COLOR)
if image is None:
raise ImageTruncated('Empty image after cv2.imdecode')
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
cv2.cvtColor(image, cv2.COLOR_BGR2RGB, dst=image)
if image is None:
raise ImageTruncated('Empty image after cv2.cvtColor')
@@ -197,12 +179,14 @@ class DroidCast(Uiautomator2):
@retry
def screenshot_droidcast_raw(self):
self.config.DROIDCAST_VERSION = 'DroidCast_raw'
image = self.droidcast_session.get(self.droidcast_url(), timeout=3).content
image = self.droidcast_session.get(self.droidcast_raw_url(), timeout=3).content
# DroidCast_raw returns a RGB565 bitmap
try:
arr = np.frombuffer(image, dtype=np.uint16).reshape((720, 1280))
except ValueError as e:
if len(image) < 500:
logger.warning(f'Unexpected screenshot: {image}')
# Try to load as `DroidCast`
image = np.frombuffer(image, np.uint8)
if image is not None:
@@ -228,14 +212,25 @@ class DroidCast(Uiautomator2):
# image = cv2.merge([r, g, b])
# The same as the code above but costs about 5ms instead of 10ms.
r = cv2.multiply(arr & 0b1111100000000000, 0.00390625).astype(np.uint8)
g = cv2.multiply(arr & 0b0000011111100000, 0.125).astype(np.uint8)
b = cv2.multiply(arr & 0b0000000000011111, 8).astype(np.uint8)
r = cv2.add(r, cv2.multiply(r, 0.03125))
g = cv2.add(g, cv2.multiply(g, 0.015625))
b = cv2.add(b, cv2.multiply(b, 0.03125))
image = cv2.merge([r, g, b])
r = cv2.bitwise_and(arr, 0b1111100000000000)
cv2.multiply(r, 0.00390625, dst=r)
r = np.uint8(r)
m = cv2.multiply(r, 0.03125)
cv2.add(r, m, dst=r)
g = cv2.bitwise_and(arr, 0b0000011111100000)
cv2.multiply(g, 0.125, dst=g)
g = np.uint8(g)
m = cv2.multiply(g, 0.015625)
cv2.add(g, m, dst=g)
b = cv2.bitwise_and(arr, 0b0000000000011111)
cv2.multiply(b, 8, dst=b)
b = np.uint8(b)
m = cv2.multiply(b, 0.03125)
cv2.add(b, m, dst=b)
image = cv2.merge([r, g, b])
return image
def droidcast_wait_startup(self):
@@ -262,13 +257,12 @@ class DroidCast(Uiautomator2):
def droidcast_uninstall(self):
"""
Stop all DroidCast processes and remove DroidCast APK.
DroidCast has't been installed but a JAVA class call, uninstall is a file delete.
Stop DroidCast processes and remove DroidCast APK.
DroidCast hasn't been installed but a JAVA class call, uninstall is a file delete.
"""
self.droidcast_stop()
logger.info('Removing DroidCast')
self.adb_shell(["rm", self.config.DROIDCAST_FILEPATH_REMOTE])
self.adb_shell(["rm", self.config.DROIDCAST_RAW_FILEPATH_REMOTE])
def _iter_droidcast_proc(self) -> t.Iterable[ProcessInfo]:
"""
@@ -280,10 +274,12 @@ class DroidCast(Uiautomator2):
yield proc
if 'com.torther.droidcasts.Main' in proc.cmdline:
yield proc
if 'ink.mol.droidcast_raw.Main' in proc.cmdline:
yield proc
def droidcast_stop(self):
"""
Stop all DroidCast processes.
Stop DroidCast processes.
"""
logger.info('Stopping DroidCast')
for proc in self._iter_droidcast_proc():
+3 -4
View File
@@ -1,4 +1,3 @@
import typing as t
from dataclasses import dataclass
from functools import wraps
from json.decoder import JSONDecodeError
@@ -10,8 +9,8 @@ from lxml import etree
from module.base.utils import *
from module.device.connection import Connection
from module.device.method.utils import (RETRY_TRIES, retry_sleep, handle_adb_error,
ImageTruncated, PackageNotInstalled, possible_reasons)
from module.device.method.utils import (ImageTruncated, PackageNotInstalled, RETRY_TRIES, handle_adb_error,
possible_reasons, retry_sleep)
from module.exception import RequestHumanTakeover
from module.logger import logger
@@ -122,7 +121,7 @@ class Uiautomator2(Connection):
if image is None:
raise ImageTruncated('Empty image after cv2.imdecode')
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB, dst=image)
if image is None:
raise ImageTruncated('Empty image after cv2.cvtColor')