Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ST7789 / PicoGraphics rewrite - Support for 4-bit, 8-bit and 16-bit framebuffers and more. #373

Merged
merged 84 commits into from Jun 17, 2022

Conversation

Gadgetoid
Copy link
Member

@Gadgetoid Gadgetoid commented May 25, 2022

ℹ️ Latest test builds - https://github.com/pimoroni/pimoroni-pico/actions/runs/2514970828 (now with MicroPython v1.19, PIO accelerated ST7789 parallel output, performance tweaks, Tufty2040 build, updated 17 Jun, 11:00 GMT)

A 340x240 16-bit framebuffer uses a whopping 150k of RAM. This is relatively okay in C++ but causes issues in MicroPython where the gc_heap is only 192k.

This set of changes allow you to use a native 16-bit, smaller 8-bit, or tiny 4-bit framebuffer for most of our SPI displays. This lets you balance memory usage with available colours and display performance.

This change renames the st7789 module to picographics in MicroPython.

Bringup

Here's an example bringup for the 160x80 ST7735 LCD in true-colour RGB565 mode:

from picographics import PicoGraphics, DISPLAY_LCD_160X80, PEN_RGB565

display = PicoGraphics(display=DISPLAY_LCD_160X80, pen_type=PEN_RGB565)

The above uses 25K of RAM and supports 65K colours.

from picographics import PicoGraphics, DISPLAY_LCD_160X80, PEN_P4

display = PicoGraphics(display=DISPLAY_LCD_160X80, pen_type=PEN_P4)

The above uses 6.25K of RAM and supports 8 colours!

Custom Pins

You can use your own pins for supported SPI or Parallel displays.

You must construct an SPIBus or ParallelBus object using the pimoroni_bus module:

from pimoroni_bus import SPIBus
from picographics import PicoGraphics, DISPLAY_PICO_EXPLORER, PEN_RGB332

spibus = SPIBus(cs=17, dc=16, sck=18, mosi=19)

display = PicoGraphics(display=DISPLAY_PICO_EXPLORER, bus=spibus, pen_type=PEN_RGB332)

Saving More RAM / Doing Weird Things

If you need to temporarily claim back RAM from PicoDisplay, or use multiple framebuffers, you can use set_framebuffer to set/clear the region of memory it uses internally.

⚠️ DO NOT attempt to draw when no framebuffer is set. You'll have a bad time!

from picographics import PicoGraphics, get_buffer_size, DISPLAY_LCD_160X80, PEN_P4

# Big enough for 160 * 80 at 4-bits per pixel
buf = bytearray(get_buffer_size(DISPLAY_LCD_160X80, PEN_P4))

display = PicoGraphics(display=DISPLAY_LCD_160X80, pen_type=PEN_P4,  buffer=buf)

# Detach the framebuffer from PicoGraphics
display.set_framebuffer(None)

# Set a different framebuffer
buf2 = bytearray(int(160 * 80 / 2))
display.set_framebuffer(buf2)

Displaying JPEG files

Thanks to JPEGDEC - https://github.com/bitbank2/JPEGDEC - you can load and display JPEG files via PicoGraphics, like so:

import picographics
import jpegdec

lcd = picographics.PicoGraphics(display=picographics.DISPLAY_PICO_EXPLORER, rotate=0, pen_type=picographics.PEN_RGB565)

# Create a new JPEG decoder for our PicoGraphics
j = jpegdec.JPEG(lcd)

# Open the JPEG file
j.open_file("240.jpeg")

# Decode the JPEG
j.decode(0, 0, 0)

# Display the result
lcd.update()

The arguments to "decode" are x-offset, y-offset and scale. Value scale values are:

JPEG_SCALE_HALF = 2
JPEG_SCALE_QUARTER = 4
JPEG_SCALE_EIGHTH = 8

When you call decode the jpeg decoder makes a best effort to draw into your PicoGraphics surface in a sensible pixel format.

Sprites

Pico Graphics has very basic support for 128x128 spritesheets in PEN_RGB332 mode:

from picographics import PicoGraphics, DISPLAY_TUFTY_2040, PEN_RGB332
import time

lcd = PicoGraphics(DISPLAY_TUFTY_2040, pen_type=PEN_RGB332)

# Reserve a large enough buffer
sprites = bytearray(128 * 128)

# Load a spritesheet into the buffer
open("s4m_ur4i-dingbads.rgb332", "rb").readinto(sprites)
#open("s4m_ur4i-pirate-characters.rgb332", "rb").readinto(sprites)

# Tell PicoGraphics to use this new spritesheet
lcd.set_spritesheet(sprites)

# Draw a sprite
lcd.sprite(0, 0, 10, 10, 255)

The function sprite takes:

  • the X/Y index of a sprite in the spritesheet. These should be between 0-15.
  • the X/Y coordinates to draw the sprite on screen.
  • the colour to treat as transparent

Supported Displays

The current list of supported displays is:

  • DISPLAY_ENVIRO_PLUS
  • DISPLAY_LCD_160X80
  • DISPLAY_LCD_240X240
  • DISPLAY_PICO_DISPLAY
  • DISPLAY_PICO_DISPLAY_2
  • DISPLAY_PICO_EXPLORER
  • DISPLAY_ROUND_LCD_240X240
  • DISPLAY_TUFTY_2040

Supported Pen Types

  • PEN_P4 - 4-bit packed, with an 8 colour palette. This is commonly used for 7/8-colour e-ink displays or driving large displays with few colours.
  • PEN_P8 - 8-bit, with a 256 colour palette. Great balance of memory usage versus available colours. You can replace palette entries on the fly.
  • PEN_RGB332 - 8-bit, with a fixed 256 colour RGB332 palette. Great for quickly porting an RGB565 app to use less RAM. Limits your colour choices, but is easier to grok.
  • PEN_RGB565 - 16-bit, 65K "True Colour." Great for rainbows, gradients and images but comes at the cost of RAM!

TODO

  • Add some new documentation here!
  • Handle a failure to allocate a palette colour (return -1 if we've run out of palette space?)
  • Provide MicroPython bindings for palette put/get methods
  • Provide a way to initialize a specific display type's args automatically, eg: ST7789(display=SPI_LCD_240X240)
  • Update all examples to remove set_pen(r, g, b) since this is meaningless and deprecated
  • Write a migration guide
  • Update documentation
  • Remove obsolete functions from Pico Display & Pico Explorer classes- should be header-only defines of pins
  • Create a C++ "Buzzer" driver to mirror the MicroPython one and replace "pico_explorer.set_tone()"

TO TEST

  • Pico Display 2.0 - All rotations
  • Pico Display - All rotations
  • SPI LCD 240 x 240 - All rotations
  • Pico Explorer - All rotations

@Gadgetoid
Copy link
Member Author

@alphanumeric007 this may be relevant to your interests!

@alphanumeric007
Copy link

Yes, that may be a big advantage for me. I pretty well stick to the basic colors. Red, Yellow, Green, and Blue. And on occasion Orange and Violet. I have a PICO Breakout Garden Base, and a couple of the 1.5" st7789 LCD Breakouts I can use for testing if that helps?

@Gadgetoid
Copy link
Member Author

No great need to test in depth, but it should get you back about 75K of RAM in your dual display setup. Or let you have separate framebuffers if you really want!

Your limited use of colour should make it pretty much a drop-in replacement and you wont notice much. To use your specified colours you can trash the default palette:

st7789.flush_palette()

And specify not to truncate new colours to RGB332 on creation:

st7789.create_pen(255, 0, 255, truncate=False)

Or create all your colours up-front, which returns an 0-255 palette index:

GREEN = st7789.create_pen(0, 255, 0, truncate=False)
st7789.set_pen(GREEN)

I might actually force this behavior at the expense of upsetting a few people who've used set_pen(r, g, b) since it will make those function calls a little faster.

I hate breaking compatibility with user code, but we've got some bad design choices to fix!

@Doukakis
Copy link

Nice idea! It makes the above board far more useful in MicroPython.

@Doukakis
Copy link

You can also add a way to detach and free the FB temporarily. Since the display contains its own RAM, if we TEMPORARILY need lots of RAM (eg. to load an image to prepare a printout to a thermal printer, calculate a QR code) it would be of great help to do the following:
a) Free the framebuffer + garbage collect (gain 75K of RAM)
b) The previous display image remains on the LCD, backed-up by the controller RAM
c) Do the calculations that need RAM - cannot display anything at that point
d) Free memory, garbage collect, re-create Framebuffer and continue.
e) We just have to repaint the full frame

@Gadgetoid
Copy link
Member Author

Gadgetoid commented May 27, 2022

Right now you can pass a buffer into the library which it'll hold a reference to. It should be possible to provide methods to force that to a nullptr so you can GC the buffer.

Maybe something like:

st7789.set_buffer(None)

vs

st7789.set_buffer(some_byte_array)

I'm currently rewriting swathes of this code to make the duality of RGB332 default palettes and RGB565 user palettes actually workable so I'll see if I can squeeze this in.

@Doukakis
Copy link

Thanks a lot!

@Gadgetoid
Copy link
Member Author

Done as described above. Don't uh... try to draw while the framebuffer is unset 😆 the library will not check.

@alphanumeric007
Copy link

Thanks Phil =), very much appreciated. I will tinker with this over the weekend. Just poured myself a grog of Captain Morgan's Dark Rum. It's the Pirate thing to do on a Friday evening. ;)

@Gadgetoid
Copy link
Member Author

Gadgetoid commented May 28, 2022

I've updated the Pico Display Python examples to demonstrate some of the changes.

To use custom colours:

display.set_palette_mode(st7789.PALETTE_USER)
BLACK = display.create_pen(0, 0, 0)
CUSTOM = BLACK + 1
display.set_palette(CUSTOM, st7789.RGB565(255, 0, 255)) # Set the CUSTOM entry to RED
display.set_pen(CUSTOM)

To easy init a display:

display = st7789.ST7789(st7789.DISPLAY_PICO_DISPLAY, rotate=0)

I am trying to support all rotations, but getting the settings right is tricky 😆 much testomg and tweaking to do.

@alphanumeric007
Copy link

alphanumeric007 commented May 28, 2022

One wrinkle for me is I have display1 and display2.
frame_buffer = bytearray(240 * 320 * 2)
display1 = st7789.ST7789(width=240, height=320, buffer=frame_buffer, slot=0)
display2 = st7789.ST7789(width=240, height=320, buffer=frame_buffer, slot=1)

So I'll have to do something like the following.
BLACK1 = display1.create_pen(0, 0, 0)
BLACK2= display2.create_pen(0, 0, 0)

BLACK = display.create_pen(0, 0, 0)
display1.set_pen(BLACK)
gets me a display not defined error. Which was why I went with
display1.set_pen(0, 0, 0)

I'm thinking I may have to go with two 8 bit display buffers? This isn't a complaint, just a FYI from the guy that doesn't do things the normal way. ;)

@Gadgetoid
Copy link
Member Author

@alphanumeric007 in the default RGB332 any colour you create on one display will be the same on the other, so you can create them once and use them on both. Internally it just crushes that colour to its RGB332 representation, then looks up and returns its index into the palette.

@Gadgetoid
Copy link
Member Author

Gadgetoid commented May 28, 2022

Latest build, slot param - and most others - have gone.

All SPI or Parallel pins must be supplied as a "pimoroni_bus". This has a helper factory to create pins for slots, see below:

import st7789
import pimoroni_bus

BUF = bytearray(320 * 240)

BUS_BG_FRONT = pimoroni_bus.SPISlot(0)
BUS_BG_BACK = pimoroni_bus.SPISlot(1)

display = st7789.ST7789(st7789.DISPLAY_PICO_DISPLAY_2, rotate=90, buf=BUF, bus=BUS_BG_FRONT)
display = st7789.ST7789(st7789.DISPLAY_PICO_DISPLAY_2, rotate=90, buf=BUF, bus=BUS_BG_BACK)

rotate is intended to work properly on all displays, but probably doesn't yet. This replaces flipping the WIDTH/HEIGHT (which you no longer supply) and also the rotate180 nonsense.

To get the WIDTH/HEIGHT of a display - for portable code - use:

WIDTH, HEIGHT = display.get_bounds()

I've finally arrived at something I'm more or less happy with for the Python API. Hopefully it wont change much from here. Albeit there are certainly some considerable DMA performance gains to be had for the palette-based display refresh.

My sinuses are screaming at me to stick a steam train up each nostril. Will clean this up for release this coming week.

@alphanumeric007
Copy link

Ok, so how do I create the color once, and use it on both displays, code wise? Looks up at very dark light bulb hovering over head.

@Gadgetoid
Copy link
Member Author

@alphanumeric007 for USER palettes I think you'd need a wrapper function to create the same colour on each display.

def create_pen(r, g, b):
    display1.create_pen(r, g, b)
    return display2.create_pen(r, g, b)

There's no reason why the palettes shouldn't stay completely in sync.

For RGB323 palette mode display1.create_pen(r, g, b) will always return the same index as display2.create_pen(r, g, b) even when called out of order. The colours are fixed and you're just getting an index into the fix palette.

It stuck me last night that you can just draw into one display since they share the same back buffer, but calling display2.update() would still use the palette from display2.

@alphanumeric007
Copy link

I just now figured it out. Well one way to do it anyway. This is with the stock framebuffer, not the new 8 bit one.
I changed all my instances of display1, to just display.
white = display.create_pen(255, 255, 255)
display.set_pen(white) <<worked
display2.set_pen(white) << also worked. =)

@Gadgetoid
Copy link
Member Author

Ah with the stock framebuffer create_pen() always returns a packed 16-bit colour value, so it's always the same on any display.

Internally it just does:

uint16_t p = ((r & 0b11111000) << 8) |
             ((g & 0b11111100) << 3) |
             ((b & 0b11111000) >> 3);

return __builtin_bswap16(p);

Whereas the new 8-bit stuff - by default (in RGB332 mode) - does:

return (r & 0b11100000) | ((g & 0b11100000) >> 3) | ((b & 0b11000000) >> 6)

Either way a created pen is transferable, the only differences is how many bits of colour information are thrown away.

@alphanumeric007
Copy link

I wasn't using create_pen at all in my current code. I was doing the (255, 255, 255) thing.
I figured I'd get that working with dual displays first, with the current uf2 file.
Then try your new 8bit buffer uf2. If I then have issues it should be easier to figure out what's "now" not working. ;)

@alphanumeric007
Copy link

Just flashed the new uf2 to that Pico.
This works > display = st7789.ST7789(st7789.DISPLAY_PICO_DISPLAY_2, rotate=90)
But this doesn't > display = st7789.ST7789(st7789.DISPLAY_PICO_DISPLAY_2, rotate=90, buf=BUF, bus=BUS_BG_FRONT)
TypeError: extra keyword arguments given

I added / edited to this
import st7789
import pimoroni_bus

BUF = bytearray(320 * 240)

BUS_BG_FRONT = pimoroni_bus.SPISlot(0)
BUS_BG_BACK = pimoroni_bus.SPISlot(1)

display = st7789.ST7789(st7789.DISPLAY_PICO_DISPLAY_2, rotate=90, buf=BUF, bus=BUS_BG_FRONT)
display2 = st7789.ST7789(st7789.DISPLAY_PICO_DISPLAY_2, rotate=90, buf=BUF, bus=BUS_BG_BACK)

@Gadgetoid
Copy link
Member Author

Sorry the argument buf has been renamed to buffer!

@alphanumeric007
Copy link

alphanumeric007 commented May 30, 2022

Ah, OK, that fixed it. Working now with that latest 8bit test uf2 file. Next step is to do the 8-bit edits etc.
I'm using your latest test file, but with a normal buffer size.
`
BUF = bytearray(240 * 320)

BUS_BG_FRONT = pimoroni_bus.SPISlot(0)
BUS_BG_BACK = pimoroni_bus.SPISlot(1)

display = st7789.ST7789(st7789.DISPLAY_PICO_DISPLAY_2, rotate=270, buffer=BUF, bus=BUS_BG_FRONT)
display2 = st7789.ST7789(st7789.DISPLAY_PICO_DISPLAY_2, rotate=270, buffer=BUF, bus=BUS_BG_BACK)
`

@alphanumeric007
Copy link

I hate to be a pest, but I'm a bit lost with all the different bits of code posted above?
Not sure what bits to use?

@Gadgetoid
Copy link
Member Author

A first port of cal would be to replace any instance of:

display.set_pen(r, g, b)

With a:

COLOR = display.create_pen(r, g, b)
display.set_pen(COLOR)

Then give all your colours sensible names and move their creation up to the top of the file after display init.

You can also just opt for the easy way and do:

display.set_pen(display.create_pen(r, g, b))

@alphanumeric007
Copy link

I did flash_nuke it with the Pi Foundation uf2 file, right after posting the above.
Then dropped the older
"pimoroni-picolipo_16mb-4f4b88501a92daf32b64808fa0d4055c301cfe41-micropython.uf2" I had saved on to it. I had all the other files backed up with it. Copied the UV sensor library file I needed and a backup of an older version of my file back to it. Its being detected fine by Windows. No issues doing a stop in Thonny either. This is pre the PEN_RGB332, P8, P4 etc.

I can unplug, plug back in, turn off and back on, and no error message or any issues in Thonny. Same cables as before and same USB ports used on the PC.

I'm just about to drop the latest test uf2 onto it and put my latest file back on it. I'll post back how that goes.

@alphanumeric007
Copy link

Dropped "pimoroni-picolipo_16mb-4f4b88501a92daf32b64808fa0d4055c301cfe41-micropython.uf2" onto it and so far so good. Started and stopped it a few times with no issues. Unplugged it and plugged it back and no error message. My code is running fine with PEN_RGB332 and the buffer code added back in. P8 and P4 won't work with this uf2 though?

@alphanumeric007
Copy link

Where does one find a 16mb flash nuke file? And does it actually need to be used?

@Gadgetoid
Copy link
Member Author

I might need to avail myself of a 16MB Pico LiPo to see if I can replicate.

Latest builds are: https://github.com/pimoroni/pimoroni-pico/actions/runs/2503525236

Which may or may not have working SH1107 128x128 support.

@alphanumeric007
Copy link

I'm going to try and pick up a new USB C cable somewhere today. The weak link may be the Micro USB to USB C adapter I'm using. It's the one you guys sell. I bought it so I could power a Pi 4 with a Pi 3 power supply. Then repurposed it. You wouldn't think the version of uf2 would matter though if it was faulty? Stranger things have happened though so I figure I better get something better suited for purpose.

I went Pico Lipo because i wanted the built in battery charger, run on battery option. ;)
I could also just swap in a Pico and edit out my Battery status check code. Hmm, might just do that in a bit.

@Gadgetoid
Copy link
Member Author

Just added I2C OLED support into MicroPython. Needs some fleshing out, but it works with our 128x128 OLED:

from picographics import PicoGraphics, DISPLAY_I2C_OLED_128X128, PEN_1BIT
from pimoroni_i2c import PimoroniI2C

bus = PimoroniI2C(4, 5)

display = PicoGraphics(DISPLAY_I2C_OLED_128X128, pen_type=PEN_1BIT, bus=bus)

display.set_pen(1)
display.clear()
display.set_font("gothic")
display.set_pen(0)
display.text("Hello World", 0, 10, 0, 0.5)
display.update()

This means you can use this little display with all of our boards sporting a Stemma/Qwiic connector.

@alphanumeric007
Copy link

It looks like what ever Device Descriptor issue I'm having is at my end.
If I use the SparkFun Cerberus USB Hub Cable, no errors and Thonny connects without issue.
Use a stock straight through cable and I get the error message?
I'd say its either an issue with this PC or with Windows 10?
Tested with a brand new never used before Pi Pico with no headers.
I'm totally flummoxed by what's going on, but I have a working work around so I'll go with that for now.

Add board fixups including Pico SDK board .h and MicroPython board dir since these are not yet upstream.
@alphanumeric007
Copy link

Hey Phil, I have a tufty ;) question for you, but have lost that e-mail address you gave me.

@Gadgetoid
Copy link
Member Author

Hey Phil, I have a tufty ;) question for you, but have lost that e-mail address you gave me.

I have a.... tufty answer for you... 👀

thirdr and others added 3 commits June 17, 2022 15:15
Co-authored-by: Phil Howard <phil@pimoroni.com>
Co-authored-by: ZodiusInfuser <christopher.parrott2@gmail.com>
Co-authored-by: Phil Howard <phil@pimoroni.com>
@Gadgetoid Gadgetoid merged commit f101ffd into main Jun 17, 2022
@Gadgetoid
Copy link
Member Author

Merged and released as v1.19.0 - https://github.com/pimoroni/pimoroni-pico/releases/tag/v1.19.0

Open a new issue if you find any other bugs/have any other questions.

Thaannkk yooou to all involved!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants