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