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