Skip to content

Commit 5a3748f

Browse files
Merge pull request #2 from AlessandroFlati/develop
First working demo
2 parents 6166008 + 064f648 commit 5a3748f

15 files changed

Lines changed: 761 additions & 0 deletions

BlueStacksAgent/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# This file makes BlueStacksAgent a Python package.
2+
from .agents import *
3+
from .actuators import *
4+
from .bluestacks_agent import BlueStacksAgent
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Expose actuator base class for easy import
2+
from .base import BaseActuator

BlueStacksAgent/actuators/base.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import abc
2+
3+
import numpy as np
4+
5+
6+
class BaseActuator(abc.ABC):
7+
"""
8+
Abstract class for actuators that work with BlueStacksAgent.
9+
The process method must be implemented by any subclass to process frames.
10+
"""
11+
12+
@abc.abstractmethod
13+
def process(self, frames: np.ndarray):
14+
"""
15+
Callback function to process a captured frame.
16+
:param frames: The buffer of captured frames (i.e., a 3D NumPy array, where the first dimension
17+
represents the frame index, and the remaining dimensions represent the frame data).
18+
"""

BlueStacksAgent/agents/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Expose agents for easy import
2+
from .base import BaseAgent
3+
from .scrcpy_agent import ScrcpyAgent

BlueStacksAgent/agents/base.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import abc
2+
import os
3+
from collections import deque
4+
from typing import Tuple
5+
6+
from adbutils import adb, AdbDevice
7+
8+
9+
class BaseAgent(abc.ABC):
10+
"""
11+
Abstract Base Class for BlueStacks screen capture agents.
12+
This class defines common configuration and the interface for all capture methods.
13+
"""
14+
15+
def __init__(self,
16+
adb_path: str = "adb",
17+
adb_port: int = 5555,
18+
resolution: Tuple[int, int] = None,
19+
bitrate: int = 8000000,
20+
max_fps: int = 30,
21+
queue_size: int = 3):
22+
"""
23+
:param resolution: Tuple (width, height) for the desired resolution, or None for native
24+
:param bitrate: Bitrate for video encoding (default 8Mbps)
25+
:param max_fps: Maximum frames per second (default 30)
26+
:param queue_size: Number of frames to buffer (default 3)
27+
"""
28+
self.adb_path = adb_path
29+
self.adb_port = adb_port
30+
self.resolution = resolution
31+
self.bitrate = bitrate
32+
self.max_fps = max_fps
33+
self.queue_size = queue_size
34+
35+
# Check if the resolution is valid
36+
if resolution is not None and not self._is_resolution_valid():
37+
raise ValueError(f"Invalid resolution: {self.resolution}")
38+
39+
# Check if the bitrate is valid
40+
if not self._is_bitrate_valid():
41+
raise ValueError(f"Invalid bitrate: {self.bitrate}")
42+
43+
# Check if the max_fps is valid
44+
if not self._is_max_fps_valid():
45+
raise ValueError(f"Invalid max_fps: {self.max_fps}")
46+
47+
# Check if the queue_size is valid
48+
if not self._is_queue_size_valid():
49+
raise ValueError(f"Invalid queue_size: {self.queue_size}")
50+
51+
# Create the frame buffer
52+
self.frame_buffer = deque(maxlen=self.queue_size)
53+
54+
# Connect to ADB
55+
self._connect_adb()
56+
self.adb_device: AdbDevice = adb.device(serial=f"emulator-{self.adb_port}")
57+
58+
self._is_streaming = False
59+
60+
@abc.abstractmethod
61+
def _start_stream(self):
62+
"""
63+
Start the screen capture stream internally, to be handled from the concrete instances.
64+
"""
65+
66+
def start_stream(self):
67+
"""
68+
Start the screen capture stream.
69+
"""
70+
self._start_stream()
71+
self._is_streaming = True
72+
73+
@abc.abstractmethod
74+
def _stop_stream(self):
75+
"""
76+
Stop the screen capture stream internally, to be handled from the concrete instances.
77+
"""
78+
79+
def stop_stream(self):
80+
"""
81+
Stop the screen capture stream.
82+
"""
83+
self._is_streaming = False
84+
self._stop_stream()
85+
86+
def is_streaming(self):
87+
"""
88+
Return whether the agent is currently streaming.
89+
"""
90+
return self._is_streaming
91+
92+
def _is_resolution_valid(self):
93+
"""
94+
Check if the resolution is valid.
95+
"""
96+
# Check if the resolution is a tuple of two integers
97+
return isinstance(self.resolution, tuple) and len(self.resolution) == 2 and all(
98+
isinstance(x, int) for x in self.resolution)
99+
100+
def _is_bitrate_valid(self):
101+
"""
102+
Check if the bitrate is valid.
103+
"""
104+
# Check if the bitrate is a positive integer
105+
return isinstance(self.bitrate, int) and self.bitrate > 0
106+
107+
def _is_max_fps_valid(self):
108+
"""
109+
Check if the max_fps is valid.
110+
"""
111+
# Check if the max_fps is a positive integer
112+
return isinstance(self.max_fps, int) and self.max_fps > 0
113+
114+
def _is_queue_size_valid(self):
115+
"""
116+
Check if the queue_size is valid.
117+
"""
118+
# Check if the queue_size is a positive integer
119+
return isinstance(self.queue_size, int) and self.queue_size > 0
120+
121+
def _insert_in_frame_buffer(self, frame):
122+
"""
123+
Insert a frame into the frame buffer.
124+
"""
125+
self.frame_buffer.append(frame)
126+
127+
def _connect_adb(self):
128+
"""
129+
Connect to ADB.
130+
"""
131+
# Check if ADB is installed
132+
if not self._is_adb_installed():
133+
raise FileNotFoundError("ADB not found. Please install ADB and add it to the system PATH.")
134+
135+
def _is_adb_installed(self):
136+
"""
137+
Check if ADB is installed.
138+
"""
139+
return os.system(f"{self.adb_path} --version") == 0
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from .base import BaseAgent
2+
3+
4+
class MediaProjectionAgent(BaseAgent):
5+
"""
6+
Concrete implementation of BaseAgent using MediaProjection.
7+
"""
8+
9+
def __init__(self,
10+
adb_path: str = "adb",
11+
resolution: tuple[int, int] = None,
12+
bitrate: int = 8000000,
13+
max_fps: int = 30,
14+
queue_size: int = 3):
15+
super().__init__(adb_path, resolution, bitrate, max_fps, queue_size)
16+
raise NotImplementedError("MediaProjectionAgent is not yet implemented.")
17+
18+
def _start_stream(self):
19+
"""
20+
Start the MediaProjection stream.
21+
"""
22+
raise NotImplementedError("MediaProjectionAgent is not yet implemented.")
23+
24+
def _stop_stream(self):
25+
"""
26+
Stop the minicap stream.
27+
"""
28+
raise NotImplementedError("MediaProjectionAgent is not yet implemented.")
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from .base import BaseAgent
2+
3+
4+
class MinicapAgent(BaseAgent):
5+
"""
6+
Concrete implementation of BaseAgent using minicap.
7+
"""
8+
9+
def __init__(self,
10+
adb_path: str = "adb",
11+
resolution: tuple[int, int] = None,
12+
bitrate: int = 8000000,
13+
max_fps: int = 30,
14+
queue_size: int = 3):
15+
super().__init__(adb_path, resolution, bitrate, max_fps, queue_size)
16+
raise NotImplementedError("MinicapAgent is not yet implemented.")
17+
18+
def _start_stream(self):
19+
"""
20+
Start the minicap stream.
21+
"""
22+
raise NotImplementedError("MinicapAgent is not yet implemented.")
23+
24+
def _stop_stream(self):
25+
"""
26+
Stop the minicap stream.
27+
"""
28+
raise NotImplementedError("MinicapAgent is not yet implemented.")
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import threading
2+
import time
3+
from .base import BaseAgent
4+
from scrcpy import Client
5+
6+
class ScrcpyAgent(BaseAgent):
7+
"""
8+
Concrete implementation of BaseAgent using scrcpy.
9+
"""
10+
11+
def __init__(self,
12+
adb_path: str = "adb",
13+
adb_serial: int = 5555,
14+
resolution: tuple[int, int] = None,
15+
bitrate: int = 8000000,
16+
max_fps: int = 30,
17+
queue_size: int = 3):
18+
super().__init__(adb_path, adb_serial, resolution, bitrate, max_fps, queue_size)
19+
self.client = None
20+
self.thread = None
21+
22+
def _stream_loop(self):
23+
# Instantiate the scrcpy client with desired parameters.
24+
# TODO: (Additional scrcpy options can be added as needed.)
25+
self.client = Client(device=self.adb_device, bitrate=self.bitrate, max_fps=self.max_fps)
26+
self.client.add_listener("frame", self._insert_in_frame_buffer)
27+
self.client.start(threaded=True)
28+
# Continue streaming until signaled to stop.
29+
while self._is_streaming:
30+
time.sleep(0.1)
31+
self.client.stop()
32+
33+
def _start_stream(self):
34+
"""
35+
Start the scrcpy stream.
36+
"""
37+
self.thread = threading.Thread(target=self._stream_loop, daemon=True)
38+
self.thread.start()
39+
40+
def _stop_stream(self):
41+
"""
42+
Stop the scrcpy stream.
43+
"""
44+
if self.thread:
45+
self.thread.join()
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import time
2+
from collections import deque
3+
from threading import Thread
4+
5+
import numpy as np
6+
7+
from BlueStacksAgent.actuators.base import BaseActuator
8+
from BlueStacksAgent.agents.base import BaseAgent
9+
10+
11+
class BlueStacksAgent:
12+
"""
13+
BlueStacksAgent ties together a screen capture agent (e.g., scrcpy, minicap, or mediaprojection)
14+
and an actuator that processes captured frames.
15+
16+
This class instantiates the proper capture agent based on a provided stream type and registers
17+
an actuator to process each frame.
18+
"""
19+
20+
def __init__(self, stream_agent: BaseAgent = None, actuator: BaseActuator = None, **kwargs):
21+
"""
22+
:param stream_agent: An instance of a subclass of BaseAgent that implements start_stream(callback).
23+
:param actuator: An instance of a subclass of BaseActuator that implements on_frame(frame).
24+
:param kwargs: Additional keyword arguments passed to the underlying capture agent's constructor.
25+
"""
26+
if stream_agent is None:
27+
raise ValueError("stream_agent must be provided.")
28+
if actuator is None:
29+
raise ValueError("actuator must be provided.")
30+
31+
self.stream_agent: BaseAgent = stream_agent
32+
self.actuator: BaseActuator = actuator
33+
self._error = None
34+
self.thread = None
35+
self.error_thread = None
36+
self.is_processing = False
37+
38+
def _process_loop(self):
39+
"""
40+
Continuously processes frames from the stream agent.
41+
:return:
42+
"""
43+
while self.is_processing:
44+
self._error = None
45+
if self.stream_agent.frame_buffer:
46+
frames = self._convert_to_3d_array(self.stream_agent.frame_buffer)
47+
try:
48+
if frames.shape[0] > 0:
49+
self.actuator.process(frames)
50+
except Exception as e:
51+
self._error = e
52+
break
53+
else:
54+
time.sleep(0.1)
55+
56+
def _error_loop(self):
57+
"""
58+
Continuously checks for errors in the processing loop.
59+
:return:
60+
"""
61+
while self.is_processing:
62+
if self._error:
63+
print(f"Error in processing loop: {self._error}")
64+
self.thread.join()
65+
break
66+
time.sleep(0.1)
67+
68+
def start(self):
69+
"""
70+
Start the capture stream and processing loop.
71+
"""
72+
self.is_processing = True
73+
self.thread = Thread(target=self._process_loop, daemon=True)
74+
self.thread.start()
75+
self.error_thread = Thread(target=self._error_loop, daemon=True)
76+
self.error_thread.start()
77+
self.stream_agent.start_stream()
78+
79+
def stop(self):
80+
"""
81+
Stop the capture stream.
82+
"""
83+
self.is_processing = False
84+
self.stream_agent.stop_stream()
85+
if self.thread:
86+
self.thread.join()
87+
if self.error_thread:
88+
self.error_thread.join()
89+
90+
@staticmethod
91+
def _convert_to_3d_array(frame_buffer: deque) -> np.ndarray:
92+
"""
93+
Convert the frame buffer to a 3D NumPy array.
94+
:param frame_buffer: A deque of frames.
95+
:return: A 3D NumPy array of frames.
96+
"""
97+
frame_buffer_copy = frame_buffer.copy()
98+
return np.array([f for f in frame_buffer_copy if f is not None])

0 commit comments

Comments
 (0)