Skip to content

Commit

Permalink
add partial update support
Browse files Browse the repository at this point in the history
  • Loading branch information
adriankeenan committed Dec 30, 2024
1 parent b78c834 commit 543c787
Show file tree
Hide file tree
Showing 6 changed files with 97 additions and 75 deletions.
35 changes: 20 additions & 15 deletions readme.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# rpi-epaper-api

A rest API for setting the display image on a [Waveshare 4.26](https://www.waveshare.com/4.26inch-e-paper-hat.htm)
A rest API for setting the display image on a [Waveshare 4.26"](https://www.waveshare.com/4.26inch-e-paper-hat.htm)
eink HAT connected to a Raspberry Pi. Supports scaling and rotating the input image. Endpoints are also provided for
fetching display contents and clearing the display.

Expand All @@ -20,7 +20,7 @@ Build and run the container:
```commandline
git clone https://github.com/adriankeenan/rpi-epaper-api.git
docker build -t rpi-eink-api .
docker run -p 5000:5000 rpi-eink-api --restart=always --privileged
docker run -p 5000:5000 --restart=always --privileged rpi-eink-api
```

Now you're ready to send images to the display!
Expand All @@ -31,31 +31,38 @@ Now you're ready to send images to the display!

`POST http://rpi:5000`

Set image from file
```commandline
curl --form [email protected] --form resize=FIT --form background=WHITE http://rpi:5000 | jq
curl --form [email protected] --form resize=FIT --form background=WHITE http://rpi:5000
```

Set image from pipe
```commandline
cat image.jpg | curl --form image=@- --form resize=FIT --form background=WHITE http://rpi:5000
```


Data should be sent form-encoded.

| Field | Description | Default | Required |
|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|----------|
| `image` | Image file to display. Any image type supported by the Python PIL library should work. | No | Yes |
| `resize` | `FIT` Resize keeping aspect ratio, without cropping<br>`CROP` Resize keeping aspect ratio, with cropping<br>`STRETCH` fill display, ignoring aspect ratio<br>`NONE` Display without any scaling, pixels drawn 1:1. | `FIT` | No |
| `rotate` | Number of degrees to rotate counter-clockwise, supports increments of 90 degrees. | 0 | No |
| `background` | Background colour to use if the image doesn't fill the display. Either `WHITE` or `BLACK`. | `WHITE` | No |
| Field | Description | Default | Required |
|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|----------|
| `image` | Image file to display. Any image type supported by the Python PIL library should work. | N/A | Yes |
| `mode` | Display mode, corresponding to the `display_XXX` method on the `epd` object.<br>`FAST` - uses `display_Fast` for a complete screen refresh.<br>`PARTIAL` - uses `display_Partial` for incremental updates (which may cause ghosting). | `FAST` | No |
| `dither` | Whether to enable dithering when converting image to mono chromatic. `true` or `1` to enable. | `true` | No |
| `resize` | `FIT` Resize keeping aspect ratio, without cropping.<br>`CROP` Resize keeping aspect ratio, with cropping.<br>`STRETCH` fill display, ignoring aspect ratio.<br>`NONE` Display without any scaling, pixels drawn 1:1. | `FIT` | No |
| `rotate` | Number of degrees to rotate counter-clockwise, supports increments of 90 degrees. | `0` | No |
| `background` | Background colour to use if the image doesn't fill the display. Either `WHITE` or `BLACK`. | `WHITE` | No |

Expect this call to take ~9 seconds.
Expect this call to take ~5 seconds.

Display update will be skipped if the resulting image is the same as the last request (see `updated` field in the response).

This endpoint only supports a single concurrent call in order to prevent simultaneous instructions being sent to the hardware.

### Fetching the current image

`GET http://rpi:5000`

```commandline
curl http://rpi:5000/img.png
curl http://rpi:5000 -o img.png
```

Returns the last image (as PNG) that was successfully sent to the display. This is the exact framebuffer sent to the device (eg post-scaling).
Expand All @@ -79,8 +86,6 @@ Reverts all pixels to the off (white) state.

- Although this is project is intended only for the 4.26" e-paper display, it's likely that other displays can be supported
by changing the imported EDP object.
- It should be possible to update only part of the display if you are sending images where only a small amount of content changes
each time. Currently, only clearing and refreshing the entire display is supported.

# License

Expand Down
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,4 @@ rpi.gpio
spidev
gpiozero
Flask~=3.0.3
filelock~=3.15.4
pillow~=10.4.0
38 changes: 18 additions & 20 deletions src/epd_utils.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,36 @@
import logging
from contextlib import contextmanager
from sys import path

from PIL import Image
from flask import Response, jsonify
from filelock import Timeout as FileLockTimeout, FileLock

from models import Mode

path.append('lib')
# noinspection PyUnresolvedReferences
from waveshare_epd import epd4in26

@contextmanager
def get_epd_lock():
lock = FileLock('epd.lock', timeout=10)
with lock.acquire():
yield epd4in26.EPD()

def display_clear():
with get_epd_lock() as epd:
epd.init()
epd.Clear()
epd.sleep()
def get_epd():
return epd4in26.EPD()


def display_clear(epd):
epd.init()
epd.Clear()
epd.sleep()

def display_img(image: Image):
with get_epd_lock() as epd:

def display_img(epd, image: Image, mode: Mode):
if mode == Mode.PARTIAL:
epd.init()
epd.Clear()
for i in range(2):
epd.display_Partial(epd.getbuffer(image))
else:
epd.init_Fast()
epd.display_Fast(epd.getbuffer(image))
epd.sleep()
epd.sleep()


def handle_epd_error(e: Exception) -> tuple[Response, int]:
if isinstance(e, IOError):
Expand All @@ -39,9 +40,6 @@ def handle_epd_error(e: Exception) -> tuple[Response, int]:
logging.info('Display write interrupted due to KeyboardInterrupt')
epd4in26.epdconfig.module_exit(cleanup=True)
return jsonify(message='Cancelled'), 500
elif isinstance(e, FileLockTimeout):
logging.info('Could not obtain device lock')
return jsonify(message='Device busy, please wait for current request to complete'), 409
else:
logging.error(f'Unexpected error occurred - {str(e)}')
return jsonify(message='Unexpected error occurred'), 500
return jsonify(message='Unexpected error occurred'), 500
12 changes: 11 additions & 1 deletion src/img_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

from models import Resolution, Rotation, Resize, BackgroundColour

def resize_img(img: Image, rotation: Rotation, resize: Resize, background: BackgroundColour, display_res: Resolution) -> Image:

def resize_img(img: Image, dither: bool, rotation: Rotation, resize: Resize, background: BackgroundColour,
display_res: Resolution) -> Image:
# Rotate
out_img = img.rotate(angle=rotation, expand=True)

Expand All @@ -19,6 +21,9 @@ def resize_img(img: Image, rotation: Rotation, resize: Resize, background: Backg

scaled_image = out_img.resize(scaled_resolution)

dither_setting = Image.Dither.FLOYDSTEINBERG if dither else Image.Dither.NONE
scaled_image = scaled_image.convert('1', dither=dither_setting)

# Add scaled image to full size canvas
bg_colour = 255 if background == BackgroundColour.WHITE else 0
x = int((display_res.width - scaled_image.width) / 2)
Expand All @@ -27,8 +32,13 @@ def resize_img(img: Image, rotation: Rotation, resize: Resize, background: Backg
canvas.paste(scaled_image, (x, y))
return canvas


def get_resize_scale(img: Image, crop: bool, display_res: Resolution) -> Tuple[int, int]:
width_scale = display_res.width / img.width
height_scale = display_res.height / img.height
scale = max(width_scale, height_scale) if crop else min(width_scale, height_scale)
return int(img.width * scale), int(img.height * scale)


def image_changed(existing_image: Image, new_image: Image) -> bool:
return list(new_image.getdata()) != list(existing_image.getdata())
10 changes: 9 additions & 1 deletion src/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,26 @@

Resolution = namedtuple("Resolution", "width height")


class Rotation(IntEnum):
ROTATE_0 = 0
ROTATE_90 = 90
ROTATE_180 = 180
ROTATE_270 = 270


class Resize(StrEnum):
FIT = 'FIT'
STRETCH = 'STRETCH'
CROP = 'CROP'
NONE = 'NONE'


class BackgroundColour(StrEnum):
BLACK = 'BLACK'
WHITE = 'WHITE'
WHITE = 'WHITE'


class Mode(StrEnum):
FAST = 'FAST'
PARTIAL = 'PARTIAL'
76 changes: 39 additions & 37 deletions src/server.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
import json
from pathlib import Path

from flask import Flask, request, jsonify, send_file, Response
from PIL import Image, UnidentifiedImageError

import logging

from models import Resolution, Rotation, Resize, BackgroundColour
from models import Resolution, Rotation, Resize, BackgroundColour, Mode

from img_utils import resize_img
from epd_utils import get_epd_lock, handle_epd_error, display_clear, display_img
from img_utils import resize_img, image_changed
from epd_utils import handle_epd_error, display_clear, display_img, get_epd

IMG_PATH = 'img.png'
DISPLAY_RESOLUTION = Resolution(800, 480)

def get_last_set_image() -> Response | tuple[Response, int]:
logging.basicConfig(level=logging.DEBUG)

epd = get_epd()

app = Flask(__name__)


@app.route("/", methods=['GET'])
def get_last_set_image() -> Response | tuple[Response, int]:
if not Path(IMG_PATH).exists():
logging.error(f'Existing image file doesn\'t exist')
return jsonify(message='Framebuffer image not found'), 404
Expand All @@ -25,22 +33,12 @@ def get_last_set_image() -> Response | tuple[Response, int]:
logging.error(f'Unable to load last set image - {str(e)}')
return jsonify(message='Unknown error fetching file'), 500

def image_changed(new_image: Image) -> bool:
try:
existing_image = Image.open(IMG_PATH)
except FileNotFoundError:
return True
except Exception as e:
logging.error(e)
return True

return list(new_image.getdata()) != list(existing_image.getdata())

@app.route("/", methods=['POST'])
def show_image() -> tuple[Response, int]:

try:
img_file = request.files['image'].stream
image = Image.open(img_file)
img_file = request.files['image']
image = Image.open(img_file.stream)
except UnidentifiedImageError:
return jsonify(message='"image" does not appear to be valid'), 422
except Exception as e:
Expand All @@ -62,38 +60,42 @@ def show_image() -> tuple[Response, int]:
except ValueError:
return jsonify(message=f'"background" invalid, must be one of {", ".join([x for x in BackgroundColour])}'), 422

image_to_display = resize_img(image, rotate, resize, background, DISPLAY_RESOLUTION)
try:
mode = Mode(request.form.get('mode', Mode.FAST.value))
except ValueError:
return jsonify(message=f'"mode" invalid, must be one of {", ".join([x for x in Mode])}'), 422

dither = request.form.get('dither', 'true') in ['true', '1']

loc = locals()
image_settings = {i: loc[i] for i in ('mode', 'dither', 'rotate', 'resize', 'background')}
image_settings['image'] = img_file.filename
image_settings['image_resolution'] = [image.width, image.height]
logging.debug(f'Creating an image with the following settings: {json.dumps(image_settings)}')

image_to_display = resize_img(image, dither, rotate, resize, background, DISPLAY_RESOLUTION)

try:
update_image = image_changed(Image.open(IMG_PATH), image_to_display)
except Exception as e:
logging.warning(f'Unable to determine image difference - {str(e)}')
update_image = True

update_image = image_changed(image_to_display)
if update_image:
try:
display_img(image_to_display)
display_img(epd, image_to_display, mode)
image_to_display.save(IMG_PATH)
except Exception as e:
return handle_epd_error(e)

return jsonify(message='Success', updated=update_image), 200


@app.route("/", methods=['DELETE'])
def clear_image():
try:
display_clear()
display_clear(epd)
Image.new('1', DISPLAY_RESOLUTION, 255).save(IMG_PATH)
return jsonify(message='Success'), 200
except Exception as e:
return handle_epd_error(e)


logging.basicConfig(level=logging.DEBUG)

app = Flask(__name__)

@app.route("/", methods=['GET', 'POST', 'DELETE'])
def img():
if request.method == 'GET':
return get_last_set_image()

if request.method == 'POST':
return show_image()

if request.method == 'DELETE':
return clear_image()

0 comments on commit 543c787

Please sign in to comment.