2525import struct
2626import subprocess
2727import sys
28+ import threading
2829import time
2930from io import BytesIO
3031from pathlib import Path
@@ -113,8 +114,11 @@ def _encode_jpeg_under_limit(image: Image.Image, *, max_bytes: int, quality: int
113114
114115
115116def send_pil_image_auto (dev , image : Image .Image , * , max_bytes : int = MAX_IMAGE_PAYLOAD_DEFAULT , ) -> None :
116- # First try PNG (preferred)
117- png = _encode_png (image )
117+ # This runs up to 2x/sec: use fast PNG compression, the device expects RGBA PNGs
118+ # (compress_level=9 takes ~0.5s CPU per frame on photographic themes, level 1 ~25ms)
119+ buffer = BytesIO ()
120+ image .save (buffer , format = "PNG" , compress_level = 1 )
121+ png = buffer .getvalue ()
118122 if len (png ) <= max_bytes :
119123 send_image (dev , png )
120124 return
@@ -939,17 +943,47 @@ def __init__(self, com_port: str = "AUTO", display_width: int = 480, display_hei
939943 self .display_width , self .display_height = PRODUCT_ID [self .dev_pid ]
940944 # Store the current screen state as an image that will be continuously updated and sent
941945 self .current_state = Image .new ("RGBA" , (self .get_width (), self .get_height ()), (0 , 0 , 0 , 0 ))
946+ # The protocol only supports full-screen frames (no partial update), so encoding and
947+ # sending the whole frame once per widget refresh pins several CPU cores. Widgets only
948+ # paste into current_state; a single thread sends the composed frame at a capped rate.
949+ self ._state_lock = threading .Lock ()
950+ self ._dev_lock = threading .Lock ()
951+ self ._frame_dirty = threading .Event ()
952+ self ._frame_send_period = 0.5
953+ threading .Thread (target = self ._frame_sender_loop , name = "TuringUSB_Sender" , daemon = True ).start ()
954+
955+ def _frame_sender_loop (self ):
956+ while True :
957+ self ._frame_dirty .wait ()
958+ self ._frame_dirty .clear ()
959+ with self ._state_lock :
960+ frame = self .current_state .copy ()
961+ # Rotate image before sending to screen: all images sent to the screen are in portrait mode
962+ if self .orientation == Orientation .LANDSCAPE :
963+ frame = frame .transpose (Image .Transpose .ROTATE_270 )
964+ elif self .orientation == Orientation .REVERSE_LANDSCAPE :
965+ frame = frame .transpose (Image .Transpose .ROTATE_90 )
966+ elif self .orientation == Orientation .PORTRAIT :
967+ frame = frame .transpose (Image .Transpose .ROTATE_180 )
968+ try :
969+ with self ._dev_lock :
970+ send_pil_image_auto (self .dev , frame , max_bytes = MAX_IMAGE_PAYLOAD_DEFAULT )
971+ except Exception :
972+ logger .exception ("Failed to send frame to Turing USB display" )
973+ time .sleep (self ._frame_send_period )
942974
943975 def InitializeComm (self ):
944- send_sync_command (self .dev )
976+ with self ._dev_lock :
977+ send_sync_command (self .dev )
945978
946979 def Reset (self ):
947980 # Do not enable the reset command for now on Turing USB models
948981 # send_restart_device_command(self.dev)
949982 pass
950983
951984 def Clear (self ):
952- clear_image (self .dev )
985+ with self ._dev_lock :
986+ clear_image (self .dev )
953987
954988 def ScreenOff (self ):
955989 # Turing USB models do not implement a "screen off" command (that we know of): use SetBrightness(0) instead
@@ -963,12 +997,14 @@ def ScreenOn(self):
963997 def SetBrightness (self , level : int = 25 ):
964998 assert 0 <= level <= 100 , 'Brightness level must be [0-100]'
965999 converted = int (level / 100 * 102 )
966- send_brightness_command (self .dev , converted )
1000+ with self ._dev_lock :
1001+ send_brightness_command (self .dev , converted )
9671002
9681003 def SetOrientation (self , orientation : Orientation ):
9691004 self .orientation = orientation
9701005 # Recreate new state with correct width/height now that screen orientation has changed
971- self .current_state = Image .new ("RGBA" , (self .get_width (), self .get_height ()), (0 , 0 , 0 , 0 ))
1006+ with self ._state_lock :
1007+ self .current_state = Image .new ("RGBA" , (self .get_width (), self .get_height ()), (0 , 0 , 0 , 0 ))
9721008
9731009 def DisplayPILImage (self , image : Image .Image , x : int = 0 , y : int = 0 , image_width : int = 0 , image_height : int = 0 ):
9741010 # If the image height/width isn't provided, use the native image size
@@ -985,18 +1021,8 @@ def DisplayPILImage(self, image: Image.Image, x: int = 0, y: int = 0, image_widt
9851021 if image_width != image .size [0 ] or image_height != image .size [1 ]:
9861022 image = image .crop ((0 , 0 , image_width , image_height ))
9871023
988- # Paste new image over existing screen state
989- self .current_state .paste (image , (x , y ))
990-
991- # Rotate image before sending to screen: all images sent to the screen are in portrait mode
992- if self .orientation == Orientation .LANDSCAPE :
993- base_image = self .current_state .transpose (Image .Transpose .ROTATE_270 )
994- elif self .orientation == Orientation .REVERSE_LANDSCAPE :
995- base_image = self .current_state .transpose (Image .Transpose .ROTATE_90 )
996- elif self .orientation == Orientation .PORTRAIT :
997- base_image = self .current_state .transpose (Image .Transpose .ROTATE_180 )
998- else : # Orientation.REVERSE_PORTRAIT is initial screen orientation
999- base_image = self .current_state
1000-
1001- # Send image data (auto JPEG fallback when payload exceeds device limit)
1002- send_pil_image_auto (self .dev , base_image , max_bytes = MAX_IMAGE_PAYLOAD_DEFAULT )
1024+ # Paste new image over existing screen state; the sender thread pushes the composed
1025+ # frame to the display at a capped rate (full-frame encode+send per widget is too slow)
1026+ with self ._state_lock :
1027+ self .current_state .paste (image , (x , y ))
1028+ self ._frame_dirty .set ()
0 commit comments