Skip to content

Commit 8a20bcc

Browse files
committed
refactor: drop all the fps handling and render scheduling logic in favor of Canvas's Callback instruction which gets called only when new drawing is done
1 parent 8626a75 commit 8a20bcc

File tree

9 files changed

+75
-203
lines changed

9 files changed

+75
-203
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,5 +78,8 @@ headless_kivy/_version.py
7878
# logs
7979
*.log
8080

81+
# debug
82+
*.raw
83+
8184
# demo
8285
demo.png

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## Upcoming
4+
5+
- refactor: drop all the fps handling and render scheduling logic in favor of `Canvas`'s `Callback` instruction which gets called only when new drawing is done
6+
37
## Version 0.10.1
48

59
- chore: set dependencies for `[test]` extra

README.md

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,10 @@ the last rendered frame.
1414
pip install headless-kivy
1515
```
1616

17-
To work on a non-RPi environment, run this:
17+
To use its test tools, you can install it with the following command:
1818

1919
```sh
20-
# pip:
2120
pip install headless-kivy[dev]
22-
# poetry:
23-
poetry --group dev headless-kivy
2421
```
2522

2623
## 🛠 Usage
@@ -32,14 +29,11 @@ poetry --group dev headless-kivy
3229

3330
```python
3431
setup_headless(
35-
min_fps=1,
36-
max_fps=30,
3732
width=240,
3833
height=240,
3934
is_debug_mode=False,
4035
display_class=ST7789,
4136
double_buffering=True,
42-
automatic_fps=True,
4337
)
4438
```
4539

@@ -86,14 +80,6 @@ and debugging purposes.
8680
It always runs in a new thread, the previous thread is provided so that it can call
8781
its `join` if desired.
8882

89-
#### `min_fps`
90-
91-
Minimum frames per second for when the Kivy application is idle.
92-
93-
#### `max_fps`
94-
95-
Maximum frames per second for the Kivy application.
96-
9783
#### `width`
9884

9985
The width of the display in pixels.
@@ -111,12 +97,6 @@ If set to True, the application will print debug information, including FPS.
11197
Is set to `True`, it will let Kivy generate the next frame while sending the last
11298
frame to the display.
11399

114-
#### `automatic_fps`
115-
116-
If set to `True`, it will monitor the hash of the screen data, if this hash changes,
117-
it will increase the fps to the maximum and if the hash doesn't change for a while,
118-
it will drop the fps to the minimum.
119-
120100
#### `rotation`
121101

122102
The rotation of the display. It will be multiplied by 90 degrees.
@@ -131,13 +111,12 @@ If set to `True`, it will flip the display vertically.
131111

132112
## 🤝 Contributing
133113

134-
You need to have [Poetry](https://python-poetry.org/) installed on your machine.
114+
You need to have [uv](https://github.com/astral-sh/uv) installed on your machine.
135115

136-
After having poetry, to install the required dependencies, run the following command
137-
in the root directory of the project:
116+
To install the required dependencies, run the following command in the root directory of the project:
138117

139118
```sh
140-
poetry install
119+
uv sync
141120
```
142121

143122
## ⚠️ Important Note

demo.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,10 @@ def render(
2525
*,
2626
rectangle: tuple[int, int, int, int],
2727
data: NDArray[np.uint8],
28-
data_hash: int,
2928
last_render_thread: Thread,
3029
) -> None:
3130
"""Render the data to a png file."""
32-
_ = rectangle, data_hash, last_render_thread
31+
_ = rectangle, last_render_thread
3332
with Path('demo.png').open('wb') as file:
3433
png.Writer(
3534
alpha=True,

headless_kivy/__init__.py

Lines changed: 60 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,6 @@
55
66
A Kivy widget rendered in memory which doesn't create any window in any display manager
77
(a.k.a "headless").
8-
9-
When no animation is running, you can drop fps to `min_fps` by calling
10-
`activate_low_fps_mode`.
11-
12-
To increase fps to `max_fps` call `activate_high_fps_mode`.
138
"""
149

1510
from __future__ import annotations
@@ -18,25 +13,20 @@
1813
from pathlib import Path
1914
from queue import Empty, Queue
2015
from threading import Thread
21-
from typing import TYPE_CHECKING
16+
from typing import TYPE_CHECKING, ClassVar
2217

2318
import numpy as np
24-
from kivy.app import App
25-
from kivy.clock import Clock
26-
from kivy.graphics.context_instructions import Color
2719
from kivy.graphics.fbo import Fbo
2820
from kivy.graphics.gl_instructions import ClearBuffers, ClearColor
29-
from kivy.graphics.instructions import Canvas
21+
from kivy.graphics.instructions import Callback, Canvas
3022
from kivy.graphics.vertex_instructions import Rectangle
3123
from kivy.metrics import dp
32-
from kivy.properties import ObjectProperty
3324
from kivy.uix.widget import Widget
3425

3526
from headless_kivy import config
3627
from headless_kivy.logger import logger
3728

3829
if TYPE_CHECKING:
39-
from kivy.graphics.texture import Texture
4030
from numpy._typing import NDArray
4131

4232

@@ -52,28 +42,22 @@ def apply_tranformations(data: NDArray[np.uint8]) -> NDArray[np.uint8]:
5242
class HeadlessWidget(Widget):
5343
"""A Kivy widget that renders everything in memory."""
5444

55-
_is_setup_headless_called: bool = False
56-
should_ignore_hash: bool = False
57-
texture = ObjectProperty(None, allownone=True)
58-
5945
last_second: int
6046
rendered_frames: int
6147
skipped_frames: int
6248
pending_render_threads: Queue[Thread]
63-
last_hash: int
64-
fps: int
6549

50+
previous_data: NDArray[np.uint8] | None = None
51+
previous_frame: NDArray[np.uint8] | None = None
6652
fbo: Fbo
67-
fbo_rect: Rectangle
53+
fbo_rectangle: Rectangle
6854

69-
raw_data: NDArray[np.uint8]
55+
raw_data: ClassVar[NDArray[np.uint8]]
7056

7157
def __init__(self: HeadlessWidget, **kwargs: dict[str, object]) -> None:
7258
"""Initialize a `HeadlessWidget`."""
7359
config.check_initialized()
7460

75-
self.should_ignore_hash = False
76-
7761
__import__('kivy.core.window')
7862

7963
if config.is_debug_mode():
@@ -82,38 +66,22 @@ def __init__(self: HeadlessWidget, **kwargs: dict[str, object]) -> None:
8266
self.skipped_frames = 0
8367

8468
self.pending_render_threads = Queue(2 if config.double_buffering() else 1)
85-
self.last_hash = 0
86-
self.last_change = time.time()
87-
self.fps = config.max_fps()
88-
8969
self.canvas = Canvas()
70+
9071
with self.canvas:
9172
self.fbo = Fbo(size=self.size, with_stencilbuffer=True)
92-
self.fbo_color = Color(1, 1, 1, 1)
93-
self.fbo_rect = Rectangle()
73+
if config.is_debug_mode():
74+
self.fbo_rectangle = Rectangle(size=self.size, texture=self.fbo.texture)
9475

95-
with self.fbo:
76+
with self.fbo.before:
9677
ClearColor(0, 0, 0, 0)
9778
ClearBuffers()
9879

99-
self.texture = self.fbo.texture
80+
with self.fbo.after:
81+
Callback(self.render_on_display)
10082

10183
super().__init__(**kwargs)
10284

103-
self.render_trigger = Clock.create_trigger(
104-
self.render_on_display,
105-
1 / self.fps,
106-
interval=True,
107-
)
108-
self.render_trigger()
109-
app = App.get_running_app()
110-
111-
def clear(*_: object) -> None:
112-
self.render_trigger.cancel()
113-
114-
if app:
115-
app.bind(on_stop=clear)
116-
11785
def add_widget(
11886
self: HeadlessWidget,
11987
*args: object,
@@ -143,60 +111,23 @@ def on_size(
143111
) -> None:
144112
"""Update size of `fbo` and size of `fbo_rect` when widget's size changes."""
145113
self.fbo.size = value
146-
self.texture = self.fbo.texture
147-
self.fbo_rect.size = value
114+
if config.is_debug_mode():
115+
self.fbo_rectangle.size = value
148116

149117
def on_pos(
150118
self: HeadlessWidget,
151119
_: HeadlessWidget,
152120
value: tuple[int, int],
153121
) -> None:
154122
"""Update position of `fbo_rect` when widget's position changes."""
155-
self.fbo_rect.pos = value
156-
157-
def on_texture(self: HeadlessWidget, _: HeadlessWidget, value: Texture) -> None:
158-
"""Update texture of `fbo_rect` when widget's texture changes."""
159-
self.fbo_rect.texture = value
160-
161-
def on_alpha(self: HeadlessWidget, _: HeadlessWidget, value: float) -> None:
162-
"""Update alpha value of `fbo_rect` when widget's alpha value changes."""
163-
self.fbo_color.rgba = (1, 1, 1, value)
164-
165-
def render(self: HeadlessWidget) -> None:
166-
"""Schedule a force render."""
167-
if not self:
168-
return
169-
Clock.schedule_once(self.render_on_display, 0)
170-
171-
def _activate_high_fps_mode(self: HeadlessWidget) -> None:
172-
"""Increase fps to `max_fps`."""
173-
if not self:
174-
return
175-
logger.info('Activating high fps mode, setting FPS to `max_fps`')
176-
self.fps = config.max_fps()
177-
self.render_trigger.timeout = 1.0 / self.fps
178-
self.last_hash = 0
179-
180-
def activate_high_fps_mode(self: HeadlessWidget) -> None:
181-
"""Schedule increasing fps to `max_fps`."""
182-
self.render()
183-
Clock.schedule_once(lambda _: self._activate_high_fps_mode(), 0)
184-
185-
def _activate_low_fps_mode(self: HeadlessWidget) -> None:
186-
"""Drop fps to `min_fps`."""
187-
logger.info('Activating low fps mode, dropping FPS to `min_fps`')
188-
self.fps = config.min_fps()
189-
self.render_trigger.timeout = 1.0 / self.fps
190-
191-
def activate_low_fps_mode(self: HeadlessWidget) -> None:
192-
"""Schedule dropping fps to `min_fps`."""
193-
self.render()
194-
Clock.schedule_once(lambda _: self._activate_low_fps_mode(), 0)
123+
if config.is_debug_mode():
124+
self.fbo_rectangle.pos = value
195125

196126
def render_on_display(self: HeadlessWidget, *_: object) -> None: # noqa: C901
197127
"""Render the current frame on the display."""
198128
# Log the number of skipped and rendered frames in the last second
199129
if config.is_debug_mode():
130+
self.fbo_rectangle.texture = self.fbo.texture
200131
# Increment rendered_frames/skipped_frames count every frame and reset their
201132
# values to zero every second.
202133
current_second = int(time.time())
@@ -210,46 +141,45 @@ def render_on_display(self: HeadlessWidget, *_: object) -> None: # noqa: C901
210141
self.rendered_frames = 0
211142
self.skipped_frames = 0
212143

213-
data = np.frombuffer(self.texture.pixels, dtype=np.uint8)
214-
data_hash = hash(data.data.tobytes())
215-
if data_hash == self.last_hash and not self.should_ignore_hash:
216-
# Only drop FPS when the screen has not changed for at least one second
217-
if (
218-
config.automatic_fps()
219-
and time.time() - self.last_change > 1
220-
and self.fps != config.min_fps()
221-
):
222-
logger.debug('Frame content has not changed for 1 second')
223-
self.activate_low_fps_mode()
224-
225-
# Considering the content has not changed, this frame can safely be ignored
144+
data = np.frombuffer(self.fbo.texture.pixels, dtype=np.uint8)
145+
if self.previous_data is not None and np.array_equal(data, self.previous_data):
146+
if config.is_debug_mode():
147+
self.skipped_frames += 1
226148
return
227-
228-
self.should_ignore_hash = False
229-
230-
self.last_change = time.time()
231-
self.last_hash = data_hash
232-
if config.automatic_fps() and self.fps != config.max_fps():
233-
logger.debug('Frame content has changed')
234-
self.activate_high_fps_mode()
235-
149+
self.previous_data = data
236150
# Render the current frame on the display asynchronously
237151
try:
238152
last_thread = self.pending_render_threads.get(False)
239153
except Empty:
240154
last_thread = None
241155

242-
height = int(min(self.texture.height, dp(config.height()) - self.y))
243-
width = int(min(self.texture.width, dp(config.width()) - self.x))
156+
height = int(min(self.fbo.texture.height, dp(config.height()) - self.y))
157+
width = int(min(self.fbo.texture.width, dp(config.width()) - self.x))
244158

245-
data = data.reshape(int(self.texture.height), int(self.texture.width), -1)
159+
data = data.reshape(
160+
int(self.fbo.texture.height),
161+
int(self.fbo.texture.width),
162+
-1,
163+
)
246164
data = data[:height, :width, :]
247165
data = apply_tranformations(data)
248166
x, y = int(self.x), int(dp(config.height()) - self.y - self.height)
249167

168+
mask = np.any(data != self.previous_frame, axis=2)
169+
alpha_mask = np.repeat(mask[:, :, np.newaxis], 4, axis=2)
170+
self.previous_frame = data
171+
if config.rotation() % 2 == 0:
172+
HeadlessWidget.raw_data[y : y + height, x : x + width, :][alpha_mask] = (
173+
data[alpha_mask]
174+
)
175+
else:
176+
HeadlessWidget.raw_data[x : x + width, y : y + height, :][alpha_mask] = (
177+
data[alpha_mask]
178+
)
179+
250180
if config.is_debug_mode():
251181
self.rendered_frames += 1
252-
raw_file_path = Path('headless_kivy_buffer.raw')
182+
raw_file_path = Path(f'headless_kivy_buffer-{self.x}_{self.y}.raw')
253183

254184
if not raw_file_path.exists():
255185
with raw_file_path.open('wb') as file:
@@ -261,17 +191,29 @@ def render_on_display(self: HeadlessWidget, *_: object) -> None: # noqa: C901
261191
file.seek(int((x + (y + i) * dp(config.width())) * 4))
262192
file.write(bytes(data[i, :, :].flatten().tolist()))
263193

264-
if config.rotation() % 2 == 0:
265-
HeadlessWidget.raw_data[y : y + height, x : x + width, :] = data
266-
else:
267-
HeadlessWidget.raw_data[x : x + width, y : y + height, :] = data
194+
raw_file_path = Path('headless_kivy_buffer.raw')
195+
196+
if not raw_file_path.exists():
197+
with raw_file_path.open('wb') as file:
198+
file.write(
199+
b'\x00' * int(dp(config.width()) * dp(config.height()) * 4),
200+
)
201+
with raw_file_path.open('r+b') as file:
202+
for i in range(height):
203+
file.seek(int((x + (y + i) * dp(config.width())) * 4))
204+
file.write(
205+
bytes(
206+
HeadlessWidget.raw_data[y + i, x : x + width, :]
207+
.flatten()
208+
.tolist(),
209+
),
210+
)
268211

269212
thread = Thread(
270213
target=config.callback(),
271214
kwargs={
272-
'rectangle': (self.x, self.y, width, height),
215+
'rectangle': (x, y, x + width - 1, y + height - 1),
273216
'data': data,
274-
'data_hash': data_hash,
275217
'last_render_thread': last_thread,
276218
},
277219
daemon=True,

0 commit comments

Comments
 (0)