Skip to content

Commit 0830116

Browse files
authored
Merge pull request #7 from sparkfun/imshow
Add imshow() and waitKey()
2 parents ec05574 + a2bf9f2 commit 0830116

File tree

8 files changed

+628
-0
lines changed

8 files changed

+628
-0
lines changed

drivers/display/st7789_spi.py

Lines changed: 373 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
1+
# Modified from:
2+
# https://github.com/easytarget/st7789-framebuffer/blob/main/st7789_purefb.py
3+
4+
import struct
5+
from time import sleep_ms
6+
from machine import Pin, SPI
7+
from ulab import numpy as np
8+
import cv2
9+
10+
# ST7789 commands
11+
_ST7789_SWRESET = b"\x01"
12+
_ST7789_SLPIN = b"\x10"
13+
_ST7789_SLPOUT = b"\x11"
14+
_ST7789_NORON = b"\x13"
15+
_ST7789_INVOFF = b"\x20"
16+
_ST7789_INVON = b"\x21"
17+
_ST7789_DISPOFF = b"\x28"
18+
_ST7789_DISPON = b"\x29"
19+
_ST7789_CASET = b"\x2a"
20+
_ST7789_RASET = b"\x2b"
21+
_ST7789_RAMWR = b"\x2c"
22+
_ST7789_VSCRDEF = b"\x33"
23+
_ST7789_COLMOD = b"\x3a"
24+
_ST7789_MADCTL = b"\x36"
25+
_ST7789_VSCSAD = b"\x37"
26+
_ST7789_RAMCTL = b"\xb0"
27+
28+
# MADCTL bits
29+
_ST7789_MADCTL_MY = const(0x80)
30+
_ST7789_MADCTL_MX = const(0x40)
31+
_ST7789_MADCTL_MV = const(0x20)
32+
_ST7789_MADCTL_ML = const(0x10)
33+
_ST7789_MADCTL_BGR = const(0x08)
34+
_ST7789_MADCTL_MH = const(0x04)
35+
_ST7789_MADCTL_RGB = const(0x00)
36+
37+
RGB = 0x00
38+
BGR = 0x08
39+
40+
# 8 basic color definitions
41+
BLACK = const(0x0000)
42+
BLUE = const(0x001F)
43+
RED = const(0xF800)
44+
GREEN = const(0x07E0)
45+
CYAN = const(0x07FF)
46+
MAGENTA = const(0xF81F)
47+
YELLOW = const(0xFFE0)
48+
WHITE = const(0xFFFF)
49+
50+
_ENCODE_POS = const(">HH")
51+
52+
# Rotation tables
53+
# (madctl, width, height, xstart, ystart)[rotation % 4]
54+
55+
_DISPLAY_240x320 = (
56+
(0x00, 240, 320, 0, 0),
57+
(0x60, 320, 240, 0, 0),
58+
(0xc0, 240, 320, 0, 0),
59+
(0xa0, 320, 240, 0, 0))
60+
61+
_DISPLAY_170x320 = (
62+
(0x00, 170, 320, 35, 0),
63+
(0x60, 320, 170, 0, 35),
64+
(0xc0, 170, 320, 35, 0),
65+
(0xa0, 320, 170, 0, 35))
66+
67+
_DISPLAY_240x240 = (
68+
(0x00, 240, 240, 0, 0),
69+
(0x60, 240, 240, 0, 0),
70+
(0xc0, 240, 240, 0, 80),
71+
(0xa0, 240, 240, 80, 0))
72+
73+
_DISPLAY_135x240 = (
74+
(0x00, 135, 240, 52, 40),
75+
(0x60, 240, 135, 40, 53),
76+
(0xc0, 135, 240, 53, 40),
77+
(0xa0, 240, 135, 40, 52))
78+
79+
_DISPLAY_128x128 = (
80+
(0x00, 128, 128, 2, 1),
81+
(0x60, 128, 128, 1, 2),
82+
(0xc0, 128, 128, 2, 1),
83+
(0xa0, 128, 128, 1, 2))
84+
85+
# Supported displays (physical width, physical height, rotation table)
86+
_SUPPORTED_DISPLAYS = (
87+
(240, 320, _DISPLAY_240x320),
88+
(170, 320, _DISPLAY_170x320),
89+
(240, 240, _DISPLAY_240x240),
90+
(135, 240, _DISPLAY_135x240),
91+
(128, 128, _DISPLAY_128x128))
92+
93+
# init tuple format (b'command', b'data', delay_ms)
94+
_ST7789_INIT_CMDS = (
95+
( b'\x11', b'\x00', 120), # Exit sleep mode
96+
( b'\x13', b'\x00', 0), # Turn on the display
97+
( b'\xb6', b'\x0a\x82', 0), # Set display function control
98+
( b'\x3a', b'\x55', 10), # Set pixel format to 16 bits per pixel (RGB565)
99+
( b'\xb2', b'\x0c\x0c\x00\x33\x33', 0), # Set porch control
100+
( b'\xb7', b'\x35', 0), # Set gate control
101+
( b'\xbb', b'\x28', 0), # Set VCOMS setting
102+
( b'\xc0', b'\x0c', 0), # Set power control 1
103+
( b'\xc2', b'\x01\xff', 0), # Set power control 2
104+
( b'\xc3', b'\x10', 0), # Set power control 3
105+
( b'\xc4', b'\x20', 0), # Set power control 4
106+
( b'\xc6', b'\x0f', 0), # Set VCOM control 1
107+
( b'\xd0', b'\xa4\xa1', 0), # Set power control A
108+
# Set gamma curve positive polarity
109+
( b'\xe0', b'\xd0\x00\x02\x07\x0a\x28\x32\x44\x42\x06\x0e\x12\x14\x17', 0),
110+
# Set gamma curve negative polarity
111+
( b'\xe1', b'\xd0\x00\x02\x07\x0a\x28\x31\x54\x47\x0e\x1c\x17\x1b\x1e', 0),
112+
( b'\x21', b'\x00', 0), # Enable display inversion
113+
( b'\x29', b'\x00', 120) # Turn on the display
114+
)
115+
116+
class ST7789_SPI():
117+
"""
118+
OpenCV SPI driver for ST7789 displays
119+
120+
Args:
121+
width (int): display width **Required**
122+
height (int): display height **Required**
123+
spi_id (int): SPI bus ID
124+
spi_baudrate (int): SPI baudrate, default 24MHz
125+
pin_sck (pin): SCK pin number
126+
pin_mosi (pin): MOSI pin number
127+
pin_miso (pin): MISO pin number
128+
pin_cs (pin): Chip Select pin number
129+
pin_dc (pin): Data/Command pin number
130+
rotation (int): Orientation of display
131+
- 0-Portrait, default
132+
- 1-Landscape
133+
- 2-Inverted Portrait
134+
- 3-Inverted Landscape
135+
color_order (int):
136+
- RGB: Red, Green Blue, default
137+
- BGR: Blue, Green, Red
138+
reverse_bytes_in_word (bool):
139+
- Enable if the display uses LSB byte order for color words
140+
"""
141+
def __init__(
142+
self,
143+
width,
144+
height,
145+
spi_id,
146+
spi_baudrate=24000000,
147+
pin_sck=None,
148+
pin_mosi=None,
149+
pin_miso=None,
150+
pin_cs=None,
151+
pin_dc=None,
152+
rotation=0,
153+
color_order=BGR,
154+
reverse_bytes_in_word=True,
155+
):
156+
# Store SPI arguments
157+
self.spi = SPI(spi_id, baudrate=spi_baudrate,
158+
sck=Pin(pin_sck, Pin.OUT) if pin_sck else None,
159+
mosi=Pin(pin_mosi, Pin.OUT) if pin_mosi else None,
160+
miso=Pin(pin_miso, Pin.IN) if pin_miso else None)
161+
self.cs = Pin(pin_cs, Pin.OUT, value=1) if pin_cs else None
162+
self.dc = Pin(pin_dc, Pin.OUT, value=1) if pin_dc else None
163+
# Initial dimensions and offsets; will be overridden when rotation applied
164+
self.width = width
165+
self.height = height
166+
self.xstart = 0
167+
self.ystart = 0
168+
# Check display is known and get rotation table
169+
self.rotations = self._find_rotations(width, height)
170+
if not self.rotations:
171+
supported_displays = ", ".join(
172+
[f"{display[0]}x{display[1]}" for display in _SUPPORTED_DISPLAYS])
173+
raise ValueError(
174+
f"Unsupported {width}x{height} display. Supported displays: {supported_displays}")
175+
# Colors
176+
self.color_order = color_order
177+
self.needs_swap = reverse_bytes_in_word
178+
# Reset the display
179+
self.soft_reset()
180+
# Yes, send init twice, once is not always enough
181+
self.send_init(_ST7789_INIT_CMDS)
182+
self.send_init(_ST7789_INIT_CMDS)
183+
# Initial rotation
184+
self._rotation = rotation % 4
185+
# Apply rotation
186+
self.rotation(self._rotation)
187+
# Create the framebuffer for the correct rotation
188+
self.buffer = np.zeros((self.height, self.width, 2), dtype=np.uint8)
189+
190+
def send_init(self, commands):
191+
"""
192+
Send initialisation commands to display.
193+
"""
194+
for command, data, delay in commands:
195+
self._write(command, data)
196+
sleep_ms(delay)
197+
198+
def soft_reset(self):
199+
"""
200+
Soft reset display.
201+
"""
202+
self._write(_ST7789_SWRESET)
203+
sleep_ms(150)
204+
205+
def _find_rotations(self, width, height):
206+
""" Find the correct rotation for our display or return None """
207+
for display in _SUPPORTED_DISPLAYS:
208+
if display[0] == width and display[1] == height:
209+
return display[2]
210+
return None
211+
212+
def rotation(self, rotation):
213+
"""
214+
Set display rotation.
215+
216+
Args:
217+
rotation (int):
218+
- 0-Portrait
219+
- 1-Landscape
220+
- 2-Inverted Portrait
221+
- 3-Inverted Landscape
222+
"""
223+
if ((rotation % 2) != (self._rotation % 2)) and (self.width != self.height):
224+
# non-square displays can currently only be rotated by 180 degrees
225+
# TODO: can framebuffer of super class be destroyed and re-created
226+
# to match the new dimensions? or it's width/height changed?
227+
return
228+
229+
# find rotation parameters and send command
230+
rotation %= len(self.rotations)
231+
( madctl,
232+
self.width,
233+
self.height,
234+
self.xstart,
235+
self.ystart, ) = self.rotations[rotation]
236+
if self.color_order == BGR:
237+
madctl |= _ST7789_MADCTL_BGR
238+
else:
239+
madctl &= ~_ST7789_MADCTL_BGR
240+
self._write(_ST7789_MADCTL, bytes([madctl]))
241+
# Set window for writing into
242+
self._write(_ST7789_CASET,
243+
struct.pack(_ENCODE_POS, self.xstart, self.width + self.xstart - 1))
244+
self._write(_ST7789_RASET,
245+
struct.pack(_ENCODE_POS, self.ystart, self.height + self.ystart - 1))
246+
self._write(_ST7789_RAMWR)
247+
# TODO: Can we swap (modify) framebuffer width/height in the super() class?
248+
self._rotation = rotation
249+
250+
def _get_common_roi_with_buffer(self, image):
251+
"""
252+
Get the common region of interest (ROI) between the image and the
253+
display's internal buffer.
254+
255+
Args:
256+
image (ndarray): Image to display
257+
258+
Returns:
259+
tuple: (image_roi, buffer_roi)
260+
"""
261+
# Ensure image is a NumPy ndarray
262+
if type(image) is not np.ndarray:
263+
raise TypeError("Image must be a NumPy ndarray")
264+
265+
# Determing number of rows and columns in the image
266+
image_rows = image.shape[0]
267+
if len(image.shape) < 2:
268+
image_cols = 1
269+
else:
270+
image_cols = image.shape[1]
271+
272+
# Get the common ROI between the image and the buffer
273+
row_max = min(image_rows, self.height)
274+
col_max = min(image_cols, self.width)
275+
img_roi = image[:row_max, :col_max]
276+
buffer_roi = self.buffer[:row_max, :col_max]
277+
return img_roi, buffer_roi
278+
279+
def _convert_image_to_uint8(self, image):
280+
"""
281+
Convert the image to uint8 format if necessary.
282+
283+
Args:
284+
image (ndarray): Image to convert
285+
286+
Returns:
287+
Image: Converted image
288+
"""
289+
# Check if the image is already in uint8 format
290+
if image.dtype is np.uint8:
291+
return image
292+
293+
# Convert to uint8 format. This unfortunately requires creating a new
294+
# buffer for the converted image, which takes more memory
295+
if image.dtype == np.int8:
296+
return cv2.convertScaleAbs(image, alpha=1, beta=127)
297+
elif image.dtype == np.int16:
298+
return cv2.convertScaleAbs(image, alpha=1/255, beta=127)
299+
elif image.dtype == np.uint16:
300+
return cv2.convertScaleAbs(image, alpha=1/255)
301+
elif image.dtype == np.float:
302+
# This implementation creates an additional buffer from np.clip()
303+
# TODO: Find another solution that avoids an additional buffer
304+
return cv2.convertScaleAbs(np.clip(image, 0, 1), alpha=255)
305+
else:
306+
raise ValueError(f"Unsupported image dtype: {image.dtype}")
307+
308+
def _write_image_to_buffer_bgr565(self, image_roi, buffer_roi):
309+
"""
310+
Convert the image ROI to BGR565 format and write it to the buffer ROI.
311+
312+
Args:
313+
image_roi (ndarray): Image region of interest
314+
buffer_roi (ndarray): Buffer region of interest
315+
"""
316+
# Determine the number of channels in the image
317+
if len(image_roi.shape) < 3:
318+
ch = 1
319+
else:
320+
ch = image_roi.shape[2]
321+
322+
if ch == 1: # Grayscale
323+
buffer_roi = cv2.cvtColor(image_roi, cv2.COLOR_GRAY2BGR565, buffer_roi)
324+
elif ch == 2: # Already in BGR565 format
325+
buffer_roi[:] = image_roi
326+
elif ch == 3: # BGR
327+
buffer_roi = cv2.cvtColor(image_roi, cv2.COLOR_BGR2BGR565, buffer_roi)
328+
else:
329+
raise ValueError("Image must be 1, 2 or 3 channels (grayscale, BGR565, or BGR)")
330+
331+
def imshow(self, image):
332+
"""
333+
Display a NumPy image on the screen.
334+
335+
Args:
336+
image (ndarray): Image to display
337+
"""
338+
# Get the common ROI between the image and internal display buffer
339+
image_roi, buffer_roi = self._get_common_roi_with_buffer(image)
340+
341+
# Ensure the image is in uint8 format
342+
image_roi = self._convert_image_to_uint8(image_roi)
343+
344+
# Convert the image to BGR565 format and write it to the buffer
345+
self._write_image_to_buffer_bgr565(image_roi, buffer_roi)
346+
347+
# Write buffer to display. Swap bytes if needed
348+
if self.needs_swap:
349+
self._write(None, self.buffer[:, :, ::-1])
350+
else:
351+
self._write(None, self.buffer)
352+
353+
def clear(self):
354+
"""
355+
Clear the display by filling it with black color.
356+
"""
357+
# Clear the buffer by filling it with zeros (black)
358+
self.buffer[:] = 0
359+
# Write the buffer to the display
360+
self._write(None, self.buffer)
361+
362+
def _write(self, command=None, data=None):
363+
"""SPI write to the device: commands and data."""
364+
if self.cs:
365+
self.cs.off()
366+
if command is not None:
367+
self.dc.off()
368+
self.spi.write(command)
369+
if data is not None:
370+
self.dc.on()
371+
self.spi.write(data)
372+
if self.cs:
373+
self.cs.on()

0 commit comments

Comments
 (0)