Skip to content

Commit 5f2bfb3

Browse files
davidfdrclaude
andcommitted
Turing USB: coalesce widget updates into rate-limited full-frame sends
The Turing USB protocol only supports full-screen image uploads, so every widget refresh (each text value, bar, ...) encoded and sent the whole framebuffer. With a typical theme refreshing ~20 widgets per second, and PNG compress_level=9 costing ~540 ms CPU per 1920x480 photographic frame, the monitor pinned 3-4 CPU cores. Instead of sending from every DisplayPILImage call, only paste the widget into current_state (under a lock) and mark the frame dirty. A dedicated daemon thread sends the composed frame at most every 0.5 s. A device lock serializes USB access, which was previously unsynchronized across the stat refresh threads. Also encode streamed frames with PNG compress_level=1 (~25 ms, still lossless). Frames must stay RGBA: the device firmware does not render RGB PNGs correctly. On an 8-core Ryzen with the 9.2" model this drops CPU usage from ~340% to ~5% of one core with no visible change on the display. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent cf0f1db commit 5f2bfb3

1 file changed

Lines changed: 47 additions & 21 deletions

File tree

library/lcd/lcd_comm_turing_usb.py

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import struct
2626
import subprocess
2727
import sys
28+
import threading
2829
import time
2930
from io import BytesIO
3031
from pathlib import Path
@@ -113,8 +114,11 @@ def _encode_jpeg_under_limit(image: Image.Image, *, max_bytes: int, quality: int
113114

114115

115116
def 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

Comments
 (0)