diff --git a/IMAGE_DISPLAY.md b/IMAGE_DISPLAY.md index 8b3f6a7..0b90a02 100644 --- a/IMAGE_DISPLAY.md +++ b/IMAGE_DISPLAY.md @@ -1,15 +1,15 @@ # 1. Displaying photo images -The display drivers in this repo were primarily designed for displaying geometric shapes -and fonts. With a minor update they may also be used for image display. The method used is -ideal for full screen images however with suitable user code smaller images may be -rendered. It is also possible to overlay an image with GUI controls, although transparency -is not supported. +The display drivers in this repo were originally designed for displaying +geometric shapes and fonts. With a minor update they may also be used for image +display. The method used is ideal for full screen images however with suitable +user code smaller images may be rendered. It is also possible to overlay an +image with GUI controls, although transparency is not supported. -The following notes apply -[nanogui](https://github.com/peterhinch/micropython-nano-gui) -[micro-gui](https://github.com/peterhinch/micropython-micro-gui) and -[micropython-touch](https://github.com/peterhinch/micropython-touch). +GUI references: +[nanogui](https://github.com/peterhinch/micropython-nano-gui) +[micro-gui](https://github.com/peterhinch/micropython-micro-gui) +[micropython-touch](https://github.com/peterhinch/micropython-touch) Images for display should be converted to a [netpbm format](https://en.wikipedia.org/wiki/Netpbm), namely a `.pgm` file for a monochrome image or `.ppm` for color. This may be @@ -19,21 +19,21 @@ greyscale to enable a monochrome image to display on a 4-bit driver. This is done using a CPython utility `img_cvt.py` documented below. An updated driver has a `greyscale` method enabling the frame buffer contents to -be interpreted at show time as either color or greyscale. This +be interpreted at show time as either color or greyscale. ## 1.2 Supported drivers -Currently only gc9a01 drivers are supported. +Currently gc9a01, ili948x, ili9341 and st7789 drivers are supported. ## 1.3 Monochrome images These may be displayed using 8-bit or 16-bit drivers by treating it as if it -were color: exporting the image from the graphics program as a `.ppm` color +were color: by exporting the image from the graphics program as a `.ppm` color image and using `img_cvt.py` to convert it to the correct color mode. On 4-bit drivers the image should be exported as a `.pgm` greyscale; -`img_cvt.py` will convert it to 4-bit format. In testing this produced good -results. +`img_cvt.py` will convert it to 4-bit format. This utility uses error diffusion +(dithering) to avoid banding and produced good results in testing. ## 1.4 Color images @@ -79,3 +79,6 @@ size. In other cases the rows and cols values must be used to populate a subset of the frame buffer pixels or to display a subset of the image pixels. Secondly the built-in flash of some platforms can be slow. If there is a visible pause in displaying the image this is likely to be the cause. + + +## doc to be continued diff --git a/drivers/st7789/st7789_4bit.py b/drivers/st7789/st7789_4bit.py index 0261980..8f13f85 100644 --- a/drivers/st7789/st7789_4bit.py +++ b/drivers/st7789/st7789_4bit.py @@ -37,15 +37,22 @@ WAVESHARE_13 = (0, 0, 16) # Waveshare 1.3" 240x240 LCD contributed by Aaron Mit @micropython.viper -def _lcopy(dest: ptr16, source: ptr8, lut: ptr16, length: int): +def _lcopy(dest: ptr16, source: ptr8, lut: ptr16, length: int, gscale: bool): # rgb565 - 16bit/pixel n: int = 0 x: int = 0 while length: c = source[x] - dest[n] = lut[c >> 4] # current pixel - n += 1 - dest[n] = lut[c & 0x0F] # next pixel + p = c >> 4 # current pixel + q = c & 0x0F # next pixel + if gscale: + dest[n] = (p >> 1 | p << 4 | p << 9 | ((p & 0x01) << 15)) ^ 0xFFFF + n += 1 + dest[n] = (q >> 1 | q << 4 | q << 9 | ((q & 0x01) << 15)) ^ 0xFFFF + else: + dest[n] = lut[p] # current pixel + n += 1 + dest[n] = lut[q] # next pixel n += 1 x += 1 length -= 1 @@ -88,11 +95,12 @@ class ST7789(framebuf.FrameBuffer): orientation = display[2] # where x, y is the RAM offset self._spi_init = init_spi # Possible user callback self._lock = asyncio.Lock() + self._gscale = False # Interpret buffer as index into color LUT mode = framebuf.GS4_HMSB # Use 4bit greyscale. self.palette = BoolPalette(mode) gc.collect() buf = bytearray(height * -(-width // 2)) # Ceiling division for odd widths - self._mvb = memoryview(buf) + self.mvb = memoryview(buf) super().__init__(buf, width, height, mode) self._linebuf = bytearray(self.width * 2) # 16 bit color out self._init(disp_mode, orientation) @@ -206,6 +214,11 @@ class ST7789(framebuf.FrameBuffer): # Row address set self._wcd(b"\x2b", int.to_bytes((ys << 16) + ye, 4, "big")) + def greyscale(self, gs=None): + if gs is not None: + self._gscale = gs + return self._gscale + # @micropython.native # Made virtually no difference to timing. def show(self): # Blocks for 83ms @60MHz SPI # Blocks for 60ms @30MHz SPI on TTGO in PORTRAIT mode @@ -215,7 +228,8 @@ class ST7789(framebuf.FrameBuffer): wd = -(-self.width // 2) # Ceiling division for odd number widths end = self.height * wd lb = memoryview(self._linebuf) - buf = self._mvb + cm = self._gscale # color False, greyscale True + buf = self.mvb if self._spi_init: # A callback was passed self._spi_init(self._spi) # Bus may be shared self._dc(0) @@ -223,7 +237,7 @@ class ST7789(framebuf.FrameBuffer): self._spi.write(b"\x2c") # RAMWR self._dc(1) for start in range(0, end, wd): - _lcopy(lb, buf[start:], clut, wd) # Copy and map colors + _lcopy(lb, buf[start:], clut, wd, cm) # Copy and map colors self._spi.write(lb) self._cs(1) # print(ticks_diff(ticks_us(), ts)) @@ -237,7 +251,8 @@ class ST7789(framebuf.FrameBuffer): clut = ST7789.lut wd = -(-self.width // 2) lb = memoryview(self._linebuf) - buf = self._mvb + cm = self._gscale # color False, greyscale True + buf = self.mvb line = 0 for n in range(split): if self._spi_init: # A callback was passed @@ -247,7 +262,7 @@ class ST7789(framebuf.FrameBuffer): self._spi.write(b"\x3c" if n else b"\x2c") # RAMWR/Write memory continue self._dc(1) for start in range(wd * line, wd * (line + lines), wd): - _lcopy(lb, buf[start:], clut, wd) # Copy and map colors + _lcopy(lb, buf[start:], clut, wd, cm) # Copy and map colors self._spi.write(lb) line += lines self._cs(1) diff --git a/img_cvt.py b/img_cvt.py new file mode 100755 index 0000000..929a68a --- /dev/null +++ b/img_cvt.py @@ -0,0 +1,213 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + +# File formats: https://en.wikipedia.org/wiki/Netpbm +# Dithering: +# https://en.wikipedia.org/wiki/Floyd%E2%80%93Steinberg_dithering +# https://tannerhelland.com/2012/12/28/dithering-eleven-algorithms-source-code.html + +# The dithering implementation is designed for genarality rather than efficiency as +# this ia a PC utility. Major speed and RAM usage improvements are possible if +# dithering is restricted to one algorithm and is to run on a microcontroller. See +# above refernced docs. + +import argparse +import sys +import os + +# Dithering data. Divisor followed by 3-tuples comprising +# row-offset, col-offset, multiplier +FS = (16, (0, 1, 7), (1, -1, 3), (1, 0, 5), (1, 1, 1)) # Floyd-Steinberg +BURKE = (32, (0, 1, 8), (0, 2, 4), (1, -2, 2), (1, -1, 4), (1, 0, 8), (1, 1, 4), (1, 2, 2)) +ATKINSON = (8, (0, 1, 1), (0, 2, 1), (1, -1, 1), (1, 0, 1), (1, 1, 1), (2, 0, 1)) +SIERRA = ( + 32, + (0, 1, 5), + (0, 2, 3), + (1, -2, 2), + (1, -1, 4), + (1, 0, 5), + (1, 1, 4), + (1, 2, 3), + (2, -1, 2), + (2, 0, 3), + (2, 1, 2), +) + +dither_options = {"FS": FS, "Burke": BURKE, "Atkinson": ATKINSON, "Sierra": SIERRA, "None": None} +# Empirical results creating RRRGGGBB images: Dithering substantially improves some images. +# Differences between the algorithms are subtle. + +# Apply dithering to an integer pixel array. Initially this contains raw integer +# vaues of a color, assumed to be left shifted by at least 4 bits. On each call +# the error e is added to forward pixels scaled according to the dithering tuple. +def dither(arr, ra, row, col, rows, cols, e): + if arr is not None: + div = arr[0] + for dr, dc, mul in arr[1:]: + r = row + dr + c = col + dc + if r < rows and 0 <= c < cols: + factor = round(e * mul / div) + if ra[r][c] + factor < 0xFFFF: # Only apply if it won't cause + ra[r][c] += factor # overflow. + + +# Convert a stream of 8-bit greyscale values to 4-bit values with Burke dithering +def convgs(arr, rows, cols, si, so): + ra = [] # Greyscale values + nibbles = [0, 0] + for row in range(rows): + ra.append([0] * cols) + for row in range(rows): + for col in range(cols): + ra[row][col] = int.from_bytes(si.read(1), "big") << 8 # 16 bit greyscale + for row in range(rows): + col = 0 + while col < cols: + for n in range(2): + c = ra[row][col] + nibbles[n] = c >> 12 + e = c & 0x0FFF # error + dither(arr, ra, row, col, rows, cols, e) # Adjust forward pixels + col += 1 + # print(row, col, nibbles) + so.write(int.to_bytes((nibbles[0] << 4) | nibbles[1], 1, "big")) + + +# Convert a stream of RGB888 data to rrrgggbb +def convrgb(arr, rows, cols, si, so, bits): + red = [] + grn = [] + blu = [] + for row in range(rows): + red.append([0] * cols) + grn.append([0] * cols) + blu.append([0] * cols) + for row in range(rows): + for col in range(cols): + red[row][col] = int.from_bytes(si.read(1), "big") << 8 # 16 bit values + grn[row][col] = int.from_bytes(si.read(1), "big") << 8 + blu[row][col] = int.from_bytes(si.read(1), "big") << 8 + if bits == 8: + for row in range(rows): + for col in range(cols): + r = red[row][col] & 0xE000 + err = red[row][col] - r + dither(arr, red, row, col, rows, cols, err) + g = grn[row][col] & 0xE000 + err = grn[row][col] - g + dither(arr, grn, row, col, rows, cols, err) + b = blu[row][col] & 0xC000 + err = blu[row][col] - b + dither(arr, blu, row, col, rows, cols, err) + op = (red[row][col] >> 8) & 0xE0 + op |= (grn[row][col] >> 11) & 0x1C + op |= (blu[row][col] >> 14) & 0x03 + so.write(int.to_bytes(op, 1, "big")) + else: # RGB565 + for row in range(rows): + for col in range(cols): + r = red[row][col] & 0xF800 + err = red[row][col] - r + dither(arr, red, row, col, rows, cols, err) + g = grn[row][col] & 0xFC00 + err = grn[row][col] - g + dither(arr, grn, row, col, rows, cols, err) + b = blu[row][col] & 0xF800 + err = blu[row][col] - b + dither(arr, blu, row, col, rows, cols, err) + op = (red[row][col]) & 0xF800 + op |= (grn[row][col] >> 5) & 0x07E0 + op |= (blu[row][col] >> 11) & 0x001F + # Color mappings checked on display + so.write(int.to_bytes(op, 2, "big")) # Red first + + +def conv(arr, fni, fno, height, width, color_mode): + with open(fno, "wb") as fo: + with open(fni, "rb") as fi: + fmt = fi.readline() # Get file format + txt = fi.readline() + while txt.startswith(b"#"): # Ignore comments + txt = fi.readline() + cols, rows = txt.split(b" ") + cols = int(cols) + rows = int(rows) + cdepth = int(fi.readline()) + fail = (fmt[:2] != b"P6") if color_mode else (fmt[:2] != b"P5") + if fail: + quit("Source file contents do not match file extension.") + fo.write(b"".join((rows.to_bytes(2, "big"), cols.to_bytes(2, "big")))) + if height is not None and width is not None: + if not (cols == width and rows == height): + print( + f"Warning: Specified dimensions {width}x{height} do not match those in source file {cols}x{rows}" + ) + print(f"Writing file, dimensions rows = {rows}, cols = {cols}") + if not color_mode: + convgs(arr, rows, cols, fi, fo) + mode = "4-bit greyscale" + elif color_mode == 1: + convrgb(arr, rows, cols, fi, fo, 16) + mode = "16-bit color RGB565" + elif color_mode == 2: + convrgb(arr, rows, cols, fi, fo, 8) + mode = "8-bit color RRRGGGBB" + print(f"File {fno} written in {mode}.") + + +# **** Parse command line arguments **** + + +def quit(msg): + print(msg) + sys.exit(1) + + +DESC = """Convert a graphics Netpbm graphics file into a binary form for use with +MicroPython FrameBuffer based display drivers. Source files should be in raw +binary format. If pixel dimensions are passed they will be compared with those +stored in the source file. A warning will be output if they do not match. Output +file dimensions will always be those of the input file. + +A color ppm graphics fle is output as an RRRGGGBB map unless the --rgb565 arg is +passed, in which case it is RRRR RGGG GGGB BBBB. +A greyscale pgm file is output in 4-bit greyscale. +By default the Atkinson dithering algorithm is used. Other options are FS +(Floyd–Steinberg), Burke, Sierra and None. +""" + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + __file__, description=DESC, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument("infile", type=str, help="Input file path") + parser.add_argument("outfile", type=str, help="Path and name of output file") + parser.add_argument("-r", "--rows", type=int, help="Image height (rows) in pixels") + parser.add_argument("-c", "--cols", type=int, help="Image width (cols) in pixels") + parser.add_argument( + "-d", + "--dither", + action="store", + default="Atkinson", + choices=["Atkinson", "Burke", "Sierra", "FS", "None"], + ) + parser.add_argument("--rgb565", action="store_true", help="Create 16-bit RGB565 file.") + args = parser.parse_args() + # print(args.dither) + # quit("Done") + if not os.path.isfile(args.infile): + quit("Source image filename does not exist") + + extension = os.path.splitext(args.infile)[1].upper() + if extension == ".PPM": + cmode = 1 if args.rgb565 else 2 # Color image + elif extension == ".PGM": + if args.rgb565: + quit("--rgb565 arg can only be used with color images.") + cmode = 0 # Greyscale image + else: + quit("Source image file should be a ppm or pgm file.") + arr = dither_options[args.dither] + conv(arr, args.infile, args.outfile, args.rows, args.cols, cmode)