Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pydevices-desktop.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/_src_signal.py" = "/lib/multimer/_src_signal.py"
"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/_src_wasm.py" = "/lib/multimer/_src_wasm.py"
"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/_ticks.py" = "/lib/multimer/_ticks.py"
"https://raw.githubusercontent.com/PyDevices/pydevices/main/utils/pngio.py" = "/lib/pngio.py"
"https://raw.githubusercontent.com/PyDevices/pydevices/main/utils/usdl2.py" = "/lib/usdl2.py"
"https://raw.githubusercontent.com/PyDevices/pydevices/main/utils/uwin32.py" = "/lib/uwin32.py"
"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/wifi.py" = "/lib/wifi.py"
58 changes: 58 additions & 0 deletions tests/test_pngio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""pngio.py (pydevices-desktop): MicroPython's pngio API over Pillow.

The full round-trip test is micropython-pydevices' modules/pngio/tests/
test_pngio.py, which runs unchanged on CPython against this file. What is here
is the CPython-only part: exact RGB565 round trips, and the message a desktop
without Pillow gets.
"""

import importlib
import sys
import unittest

import _env # noqa: F401

try:
import PIL # noqa: F401
except ImportError:
PIL = None


@unittest.skipIf(PIL is None, "Pillow isn't installed")
class RoundTrip(unittest.TestCase):
def test_rgb565_out_and_back(self):
import pngio

w, h = 37, 11
buf = bytearray((i * 97 + 13) & 255 for i in range(w * h * 2))
png = pngio.PngEncoder().encode(buf, w, h)
self.assertEqual(png[:8], b"\x89PNG\r\n\x1a\n")
d = pngio.PngDecoder()
self.assertEqual(d.open(png), (w, h))
back = bytearray(w * h * 2)
d.decode(back)
self.assertEqual(back, buf)

def test_decode_needs_open(self):
import pngio

with self.assertRaises(RuntimeError):
pngio.PngDecoder().decode(bytearray(8))


class WithoutPillow(unittest.TestCase):
def test_the_message_says_how_to_install_it(self):
saved = {k: sys.modules.pop(k) for k in list(sys.modules) if k == "pngio" or k.startswith("PIL")}
sys.modules["PIL"] = None # import PIL now raises ImportError
try:
with self.assertRaises(ImportError) as cm:
importlib.import_module("pngio")
self.assertIn("pip install pillow", str(cm.exception))
finally:
sys.modules.pop("PIL", None)
sys.modules.pop("pngio", None)
sys.modules.update(saved)


if __name__ == "__main__":
unittest.main()
1 change: 1 addition & 0 deletions utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ in `pydevices-desktop.toml`. Utilities do not publish as separate packages.
| `mip.py` | Portable `mip` for CPython, CircuitPython, and Pyodide |
| `micropython.py` | CPython compatibility shim for common MicroPython decorators and helpers |
| `frame_recorder.py` | FFmpeg recording for desktop displays |
| `pngio.py` | PNG encode and decode, MicroPython's `pngio` API over Pillow; says how to install Pillow when it's missing |
| `usdl2.py` | Pure-Python SDL2 FFI fallback |
| `uwin32.py` | Pure-Python Win32 FFI fallback |

Expand Down
147 changes: 147 additions & 0 deletions utils/pngio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""pngio on CPython: PNG encode and decode, the same API as MicroPython's
built-in pngio (micropython-pydevices' modules/pngio), over Pillow.

png = pngio.PngEncoder(level=1).encode(fb, 480, 270) # bytes
d = pngio.PngDecoder(); w, h = d.open(png); d.decode(buf) # RGB565 into buf

An app imports pngio and doesn't care which runtime it's on. Pixels are
native-order (little-endian) RGB565 by default, as displaydev framebuffers
hold them; RGB565 widens by bit replication (31 -> 255) and narrows by
truncation, as the C module does, so a round trip is exact.
"""

import io

try:
from PIL import Image, ImageChops
except ImportError as e: # pragma: no cover - the message is the point
raise ImportError(
"pngio needs Pillow on CPython. Install it with: pip install pillow"
) from e

RGB565 = 0
GS8 = 1
RGB888 = 2

_IN_BPP = {RGB565: 2, GS8: 1, RGB888: 3}


def _rows(buffer, width, height, stride, bpp):
"""The image's bytes, tight, from a buffer whose rows are stride pixels apart."""
data = memoryview(buffer).cast("B")
if stride == width:
return bytes(data[: width * height * bpp])
row, step = width * bpp, stride * bpp
return b"".join(bytes(data[y * step : y * step + row]) for y in range(height))


class PngEncoder:
"""``PngEncoder(level=1)``: level 1 (fastest) to 9 (smallest)."""

def __init__(self, level=1):
if not 1 <= level <= 9:
raise ValueError("level must be 1..9")
self.level = level

def encode(self, buffer, width, height, *, format=RGB565, stride=None, swap=False):
if format not in _IN_BPP:
raise ValueError("format must be RGB565, GS8 or RGB888")
if not (1 <= width <= 32767 and 1 <= height <= 32767):
raise ValueError("width and height must be 1..32767")
stride = width if stride is None else stride
if stride < width:
raise ValueError("stride must be at least width")
bpp = _IN_BPP[format]
need = (stride * (height - 1) + width) * bpp
have = memoryview(buffer).nbytes
if have < need:
raise ValueError("buffer has %d bytes; %dx%d needs %d" % (have, width, height, need))
data = _rows(buffer, width, height, stride, bpp)
if format == RGB565:
if swap:
b = bytearray(data)
b[0::2], b[1::2] = data[1::2], data[0::2]
data = bytes(b)
# Pillow's BGR;16 is native little-endian 5-6-5, widened by replication
im = Image.frombuffer("RGB", (width, height), data, "raw", "BGR;16", 0, 1)
elif format == GS8:
im = Image.frombuffer("L", (width, height), data, "raw", "L", 0, 1)
else:
im = Image.frombuffer("RGB", (width, height), data, "raw", "RGB", 0, 1)
out = io.BytesIO()
im.save(out, "PNG", compress_level=self.level)
return out.getvalue()


class PngDecoder:
"""``PngDecoder()``: ``open(source)``, then ``decode(target, x, y)``."""

def __init__(self):
self._image = None
self._size = None

@property
def width(self):
if self._size is None:
raise RuntimeError("width needs a successful open()")
return self._size[0]

@property
def height(self):
if self._size is None:
raise RuntimeError("height needs a successful open()")
return self._size[1]

def open(self, source):
"""``source``: bytes-like, a path, or a binary stream. Returns (width, height)."""
self._image = None
if isinstance(source, str):
with open(source, "rb") as f:
data = f.read()
elif hasattr(source, "read"):
data = source.read()
else:
data = bytes(memoryview(source).cast("B"))
try:
im = Image.open(io.BytesIO(data))
if im.format != "PNG":
raise ValueError
if im.info.get("interlace"):
raise ValueError("interlaced PNG isn't supported")
except ValueError as e:
raise ValueError("pngio: %s" % (e or "not a PNG")) from None
except Exception:
raise ValueError("pngio: not a PNG") from None
self._image = im
self._size = im.size
return im.size

def decode(self, target, x=0, y=0, *, stride=None):
"""Native-order RGB565 into ``target``, the image's top-left at (x, y)."""
if self._image is None:
raise RuntimeError("decode() without open()")
w, h = self._size
stride = w if stride is None else stride
if x < 0 or y < 0 or x + w > stride:
raise ValueError("the image doesn't fit across the target at that x and stride")
out = memoryview(target).cast("B")
need = ((y + h - 1) * stride + x + w) * 2
if out.nbytes < need:
raise ValueError("target has %d bytes; %dx%d at (%d, %d) with stride %d needs %d"
% (out.nbytes, w, h, x, y, stride, need))
try:
# the pixels are read here, as the C module inflates them in decode()
rgb = self._image.convert("RGB")
except Exception:
self._image = None
raise ValueError("pngio: PNG image data is corrupt") from None
r, g, b = rgb.split()
# the two bytes of each pixel, built from disjoint bits so add() never clips
hi = ImageChops.add(r.point(lambda v: v & 0xF8), g.point(lambda v: v >> 5))
lo = ImageChops.add(g.point(lambda v: (v & 0x1C) << 3), b.point(lambda v: v >> 3))
px = Image.merge("LA", (lo, hi)).tobytes()
row = w * 2
for j in range(h):
at = ((y + j) * stride + x) * 2
out[at:at + row] = px[j * row:(j + 1) * row]
self._image = None
Loading