55
66A 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
1510from __future__ import annotations
1813from pathlib import Path
1914from queue import Empty , Queue
2015from threading import Thread
21- from typing import TYPE_CHECKING
16+ from typing import TYPE_CHECKING , ClassVar
2217
2318import numpy as np
24- from kivy .app import App
25- from kivy .clock import Clock
26- from kivy .graphics .context_instructions import Color
2719from kivy .graphics .fbo import Fbo
2820from kivy .graphics .gl_instructions import ClearBuffers , ClearColor
29- from kivy .graphics .instructions import Canvas
21+ from kivy .graphics .instructions import Callback , Canvas
3022from kivy .graphics .vertex_instructions import Rectangle
3123from kivy .metrics import dp
32- from kivy .properties import ObjectProperty
3324from kivy .uix .widget import Widget
3425
3526from headless_kivy import config
3627from headless_kivy .logger import logger
3728
3829if 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]:
5242class 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