diff --git a/.gitmodules b/.gitmodules
index a21dc049a14f22cdefc6a9cd09fc38c3a542404c..94ecfc21545420f03dd994fcaac512cbe85bac7a 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -10,3 +10,6 @@
 [submodule "lib/crypto/SHA256"]
 	path = lib/crypto/SHA256
 	url = https://github.com/ilvn/SHA256
+[submodule "lib/lodepng/lodepng"]
+	path = lib/lodepng/lodepng
+	url = https://github.com/lvandeve/lodepng
diff --git a/Documentation/conf.py b/Documentation/conf.py
index a09ee94ed0c473d97e1e1f216f6338df00294105..3722ebea58b6e2b2d3d21bca019eed18f2b289fc 100644
--- a/Documentation/conf.py
+++ b/Documentation/conf.py
@@ -124,6 +124,7 @@ autodoc_mock_imports = [
     "sys_leds",
     "sys_max30001",
     "sys_max86150",
+    "sys_png",
     "sys_config",
     "ucollections",
     "uerrno",
diff --git a/Documentation/index.rst b/Documentation/index.rst
index 2816dcd17ed6f00868c18b0b8b6ef83c21c9cf9f..7df2a217e5f770971aeea3a4f03fcbf363a386a5 100644
--- a/Documentation/index.rst
+++ b/Documentation/index.rst
@@ -36,6 +36,7 @@ Last but not least, if you want to start hacking the lower-level firmware, the
    pycardium/light-sensor
    pycardium/os
    pycardium/personal_state
+   pycardium/png
    pycardium/power
    pycardium/pride
    pycardium/simple_menu
diff --git a/Documentation/pycardium/png.rst b/Documentation/pycardium/png.rst
new file mode 100644
index 0000000000000000000000000000000000000000..d4a4aa41c4a9860e77da4b1ae137c49c779e93fd
--- /dev/null
+++ b/Documentation/pycardium/png.rst
@@ -0,0 +1,7 @@
+``png`` - PNG Decoder
+===============
+The ``png`` module provides functions to decode PNG files into raw pixel data
+which can be displayed using the card10's display or its LEDs.
+
+.. automodule:: png
+   :members:
diff --git a/bootloader/bootloader-display.c b/bootloader/bootloader-display.c
index d04db551d6833aca9391cd5aad80139792ecfdf4..9b4b66fb4a9ca9de09028bea3aeccf49667a14e4 100644
--- a/bootloader/bootloader-display.c
+++ b/bootloader/bootloader-display.c
@@ -8,13 +8,12 @@
 
 static void bootloader_display_splash(void)
 {
-	gfx_copy_region(
+	gfx_copy_region_rle_mono(
 		&display_screen,
 		0,
 		0,
 		160,
 		80,
-		GFX_RLE_MONO,
 		sizeof(splash),
 		(const void *)(splash)
 	);
diff --git a/epicardium/epicardium.h b/epicardium/epicardium.h
index 2cbe812e426020c21a25afe6c1b6043444a80a95..33edc9e61289f3dab3f1453c2ce371c55b0de773 100644
--- a/epicardium/epicardium.h
+++ b/epicardium/epicardium.h
@@ -1652,6 +1652,16 @@ enum disp_font_name {
 	DISP_FONT24 = 4,
 };
 
+/*
+ * Image data type
+ */
+enum epic_rgb_format {
+	EPIC_RGB8     = 0,
+	EPIC_RGBA8    = 1,
+	EPIC_RGB565   = 2,
+	EPIC_RGBA5551 = 3,
+ };
+
 /**
  * Prints a string into the display framebuffer with font type selectable
  *
@@ -1699,22 +1709,22 @@ API(API_DISP_PIXEL, int epic_disp_pixel(
 ));
 
 /**
- * Blit an image buffer to display
+ * Blits an image buffer to the display
  *
  * :param x: x position
  * :param y: y position
- * :param w: image width
- * :param h: image height
- * :param img: image data (rgb565)
- * :param alpha: 8 bit alpha channel. Currently unused.
+ * :param w: Image width
+ * :param h: Image height
+ * :param img: Image data
+ * :param format: Format of the image data. One of :c:type:`epic_rgb_format`.
  */
 API(API_DISP_BLIT, int epic_disp_blit(
 	int16_t x,
 	int16_t y,
 	int16_t w,
 	int16_t h,
-	uint16_t *img,
-	uint8_t *alpha
+	void *img,
+	enum epic_rgb_format format
 ));
 
 /**
diff --git a/epicardium/main.c b/epicardium/main.c
index 270f2b0a831765333f3cf0ea1acb0d5c497a106b..f4c7d9d94f3d135acc3d8de757e3143553829a72 100644
--- a/epicardium/main.c
+++ b/epicardium/main.c
@@ -48,14 +48,7 @@ int main(void)
 	epic_disp_clear(0x0000);
 
 	gfx_copy_region(
-		&display_screen,
-		0,
-		0,
-		160,
-		80,
-		GFX_RAW,
-		sizeof(version_splash),
-		version_splash
+		&display_screen, 0, 0, 160, 80, GFX_RGB565, version_splash
 	);
 
 	if (strcmp(CARD10_VERSION, "v1.16") != 0) {
diff --git a/epicardium/modules/display.c b/epicardium/modules/display.c
index d7607a2027735db1813c86fc49c8a1718756d0b3..05b0eaec1d4a920a3e3f3dbfb3bf2054542371f7 100644
--- a/epicardium/modules/display.c
+++ b/epicardium/modules/display.c
@@ -92,61 +92,81 @@ int epic_disp_blit(
 	int16_t pos_y,
 	int16_t width,
 	int16_t height,
-	uint16_t *img,
-	uint8_t *alpha
+	void *img,
+	enum epic_rgb_format format
 ) {
-	/* TODO: alpha is not supported yet */
 	int cl = check_lock();
 	if (cl < 0) {
 		return cl;
-	} else {
-		int16_t offset_x = (pos_x < 0) ? -pos_x : 0;
-		int16_t count_x  = width - offset_x;
-		int16_t offset_y = (pos_y < 0) ? -pos_y : 0;
-		int16_t count_y  = height - offset_y;
+	}
 
-		if (pos_x + width >= 160) {
-			count_x -= (pos_x + width) % 160;
-		}
-		if (pos_y + height >= 80) {
-			count_y -= (pos_y + height) % 80;
-		}
+	int16_t offset_x = (pos_x < 0) ? -pos_x : 0;
+	int16_t count_x  = width - offset_x;
+	int16_t offset_y = (pos_y < 0) ? -pos_y : 0;
+	int16_t count_y  = height - offset_y;
+
+	if (pos_x + width >= 160) {
+		count_x -= (pos_x + width) % 160;
+	}
+	if (pos_y + height >= 80) {
+		count_y -= (pos_y + height) % 80;
+	}
+
+	size_t bpp;
+	enum gfx_encoding encoding;
+
+	switch (format) {
+	case EPIC_RGB565:
+		bpp      = 2;
+		encoding = GFX_RGB565;
+		break;
+	case EPIC_RGBA5551:
+		bpp      = 2;
+		encoding = GFX_RGBA5551;
+		break;
+	case EPIC_RGB8:
+		bpp      = 3;
+		encoding = GFX_RGB8;
+		break;
+	case EPIC_RGBA8:
+		bpp      = 4;
+		encoding = GFX_RGBA8;
+		break;
+	default:
+		return -1;
+		break;
+	}
 
-		if (offset_x == 0 && offset_y == 0 && count_x == width &&
-		    count_y == height) {
-			/* Simply copy full image, no cropping or alpha blending */
+	if (offset_x == 0 && offset_y == 0 && count_x == width &&
+	    count_y == height) {
+		/* Copy full image. No cropping.*/
+		gfx_copy_region(
+			&display_screen,
+			pos_x,
+			pos_y,
+			width,
+			height,
+			encoding,
+			img
+		);
+	} else {
+		/* Copy cropped image line by line. */
+		int16_t curr_y;
+		for (curr_y = offset_y; curr_y < offset_y + count_y; curr_y++) {
+			uint8_t *line = img + (curr_y * width + offset_x) * bpp;
 			gfx_copy_region(
 				&display_screen,
-				pos_x,
-				pos_y,
-				width,
-				height,
-				GFX_RAW,
-				width * height * 2,
-				img
+				pos_x + offset_x,
+				pos_y + curr_y,
+				count_x,
+				1,
+				encoding,
+				line
 			);
-		} else {
-			/* Copy cropped image line by line */
-			for (int16_t curr_y = offset_y;
-			     curr_y < offset_y + count_y;
-			     curr_y++) {
-				uint16_t *curr_img =
-					img + (curr_y * width + offset_x);
-				gfx_copy_region(
-					&display_screen,
-					pos_x + offset_x,
-					pos_y + curr_y,
-					count_x,
-					1,
-					GFX_RAW,
-					count_x * 2,
-					curr_img
-				);
-			}
 		}
-
-		return 0;
 	}
+
+	return 0;
 }
 
 int epic_disp_line(
diff --git a/epicardium/modules/panic.c b/epicardium/modules/panic.c
index ed4575a5611a164145c762efdb86a46a6e39ee4f..b365b223db9cf75bc72c8866c77e9d82c9b4c502 100644
--- a/epicardium/modules/panic.c
+++ b/epicardium/modules/panic.c
@@ -120,13 +120,12 @@ static void faultsplash(const char *msg)
 {
 	LCD_SetBacklight(100);
 
-	gfx_copy_region(
+	gfx_copy_region_rle_mono(
 		&display_screen,
 		0,
 		0,
 		160,
 		80,
-		GFX_RLE_MONO,
 		sizeof(faultsplash_rle),
 		faultsplash_rle
 	);
diff --git a/hw-tests/dual-core/main.c b/hw-tests/dual-core/main.c
index 30026b8051f1e64f775dbcee8df46fd7b1d8615b..3de7f47152e0d9386c0bbb2212022c90cba91195 100644
--- a/hw-tests/dual-core/main.c
+++ b/hw-tests/dual-core/main.c
@@ -28,8 +28,7 @@ int main(void)
 		0,
 		160,
 		80,
-		GFX_RAW,
-		sizeof(Heart),
+		GFX_RGB565,
 		(const void *)(Heart)
 	);
 	gfx_update(&display_screen);
diff --git a/hw-tests/hello-world/main.c b/hw-tests/hello-world/main.c
index 5f2a82aad5b14a52cfa94ee3e3f6acf891a3dfa3..39a75e35f8027920692fa62a460d739bf720498d 100644
--- a/hw-tests/hello-world/main.c
+++ b/hw-tests/hello-world/main.c
@@ -36,8 +36,7 @@ int main(void)
 		0,
 		160,
 		80,
-		GFX_RAW,
-		sizeof(Heart),
+		GFX_RGB565,
 		(const void *)(Heart)
 	);
 	gfx_update(&display_screen);
diff --git a/hw-tests/ips/main.c b/hw-tests/ips/main.c
index b638a7832310f0d681c57f979001b595fe1e140c..e1ff5fa16a674efc17cc7880bf63115cf03fc8ef 100644
--- a/hw-tests/ips/main.c
+++ b/hw-tests/ips/main.c
@@ -43,8 +43,7 @@ int main(void)
 		0,
 		40,
 		40,
-		GFX_RAW,
-		40 * 40 * 2,
+		GFX_RGB565,
 		(const void *)(gImage_40X40)
 	);
 	gfx_copy_region(
@@ -53,8 +52,7 @@ int main(void)
 		0,
 		160,
 		80,
-		GFX_RAW,
-		160 * 80 * 2,
+		GFX_RGB565,
 		(const void *)(gImage_160X80)
 	);
 	gfx_update(&display_screen);
diff --git a/lib/gfx/framebuffer.c b/lib/gfx/framebuffer.c
index 6c8dc3aac453214494b1758a6771a0ff3959ee8f..314f3e2f0a530a8083cfb0863df0428aa89dd192 100644
--- a/lib/gfx/framebuffer.c
+++ b/lib/gfx/framebuffer.c
@@ -99,3 +99,21 @@ void fb_setpixel(struct framebuffer *fb, int x, int y, Color c)
 		pixel[0] = color[1];
 	}
 }
+
+Color fb_getpixel(struct framebuffer *fb, int x, int y)
+{
+	uint8_t *pixel = fb_pixel(fb, x, y);
+	if (pixel == NULL)
+		return 0;
+
+	Color c;
+	uint8_t *color   = (uint8_t *)(&c);
+	const size_t bpp = fb_bytes_per_pixel(fb);
+	switch (bpp) {
+	default:
+	case 2:
+		color[0] = pixel[1];
+		color[1] = pixel[0];
+	}
+	return c;
+}
diff --git a/lib/gfx/framebuffer.h b/lib/gfx/framebuffer.h
index e2358c73464e7af26cc287c2348c537e5da15bcd..0bd0a593af0bb060d92318aa896cde8165f6775f 100644
--- a/lib/gfx/framebuffer.h
+++ b/lib/gfx/framebuffer.h
@@ -27,6 +27,7 @@ struct framebuffer {
 size_t fb_bytes_per_pixel(const struct framebuffer *fb);
 void *fb_pixel(struct framebuffer *fb, int x, int y);
 void fb_setpixel(struct framebuffer *fb, int x, int y, Color c);
+Color fb_getpixel(struct framebuffer *fb, int x, int y);
 void fb_clear_to_color(struct framebuffer *fb, Color c);
 void fb_clear(struct framebuffer *fb);
 Color fb_encode_color_rgb(struct framebuffer *fb, int r, int g, int b);
diff --git a/lib/gfx/gfx.c b/lib/gfx/gfx.c
index 886ecdeeb99b6054ca718d05342438ade33eb25e..172dc9079851b93fb19aed37d3cbaf1107fedf26 100644
--- a/lib/gfx/gfx.c
+++ b/lib/gfx/gfx.c
@@ -13,6 +13,42 @@ const struct gfx_color_rgb gfx_colors_rgb[COLORS] = {
 	{ 255, 255, 0 }    /* YELLOW */
 };
 
+static inline uint16_t rgb8_to_rgb565(const uint8_t *bytes)
+{
+	return ((bytes[0] & 0b11111000) << 8) | ((bytes[1] & 0b11111100) << 3) |
+	       (bytes[2] >> 3);
+}
+
+static inline void rgb565_to_rgb8(uint16_t c, uint8_t *bytes)
+{
+	bytes[0] = (c & 0b1111100000000000) >> 8;
+	bytes[1] = (c & 0b0000011111100000) >> 3;
+	bytes[2] = (c & 0b0000000000011111) << 3;
+}
+
+static inline uint16_t rgba5551_to_rgb565(const uint8_t *bytes)
+{
+	return (bytes[1] << 8) | (bytes[0] & 0b11000000) |
+	       ((bytes[0] & 0b00111110) >> 1);
+}
+
+static inline uint8_t apply_alpha(uint8_t in, uint8_t bg, uint8_t alpha)
+{
+	/* Not sure if it is worth (or even a good idea) to have
+	 * the special cases here. */
+	if (bg == 0) {
+		return (in * alpha) / 255;
+	}
+
+	uint8_t beta = 255 - alpha;
+
+	if (bg == 255) {
+		return ((in * alpha) / 255) + beta;
+	}
+
+	return (in * alpha + bg * beta) / 255;
+}
+
 void gfx_setpixel(struct gfx_region *r, int x, int y, Color c)
 {
 	if (x < 0 || y < 0)
@@ -23,6 +59,25 @@ void gfx_setpixel(struct gfx_region *r, int x, int y, Color c)
 	fb_setpixel(r->fb, r->x + x, r->y + y, c);
 }
 
+void gfx_setpixel_rgba(struct gfx_region *r, int x, int y, const uint8_t *c)
+{
+	if (x < 0 || y < 0)
+		return;
+	if ((size_t)x >= r->width || (size_t)y >= r->height)
+		return;
+
+	uint8_t pixel[3];
+	Color p = fb_getpixel(r->fb, r->x + x, r->y + y);
+	rgb565_to_rgb8(p, pixel);
+
+	uint8_t alpha = c[3];
+	pixel[0]      = apply_alpha(c[0], pixel[0], alpha);
+	pixel[1]      = apply_alpha(c[1], pixel[1], alpha);
+	pixel[2]      = apply_alpha(c[2], pixel[2], alpha);
+
+	fb_setpixel(r->fb, r->x + x, r->y + y, rgb8_to_rgb565(pixel));
+}
+
 struct gfx_region gfx_screen(struct framebuffer *fb)
 {
 	struct gfx_region r = { .fb     = fb,
@@ -270,61 +325,48 @@ Color gfx_color(struct gfx_region *reg, enum gfx_color color)
 	return gfx_color_rgb(reg, c->r, c->g, c->b);
 }
 
-static void gfx_copy_region_raw(
+void gfx_copy_region(
 	struct gfx_region *reg,
 	int x,
 	int y,
 	int w,
 	int h,
-	size_t size,
-	const void *p
+	enum gfx_encoding encoding,
+	const uint8_t *p
 ) {
-	size_t bpp = size / (w * h);
-
 	for (int y_ = 0; y_ < h; y_++) {
 		for (int x_ = 0; x_ < w; x_++) {
 			Color c;
+			uint8_t alpha;
 
-			switch (bpp) {
-			default:
-			case 2:
+			switch (encoding) {
+			case GFX_RGB565:
+				/* Assuming alignment here */
 				c = *(const uint16_t *)(p);
+				gfx_setpixel(reg, x + x_, y + y_, c);
+				p += 2;
+				break;
+			case GFX_RGB8:
+				c = rgb8_to_rgb565(p);
+				gfx_setpixel(reg, x + x_, y + y_, c);
+				p += 3;
+				break;
+			case GFX_RGBA5551:
+				c     = rgba5551_to_rgb565(p);
+				alpha = (*p) & 1;
+				if (alpha) {
+					gfx_setpixel(reg, x + x_, y + y_, c);
+				}
+				p += 2;
+				break;
+			case GFX_RGBA8:
+				gfx_setpixel_rgba(reg, x + x_, y + y_, p);
+				p += 4;
+				break;
+			default:
+				return;
 				break;
 			}
-
-			gfx_setpixel(reg, x + x_, y + y_, c);
-			p += bpp;
-		}
-	}
-}
-
-static void gfx_copy_region_mono(
-	struct gfx_region *reg,
-	int x,
-	int y,
-	int w,
-	int h,
-	size_t size,
-	const void *p
-) {
-	const char *bp = p;
-	int bit        = 0;
-	Color white    = gfx_color(reg, WHITE);
-	Color black    = gfx_color(reg, BLACK);
-
-	for (int y_ = 0; y_ < h; y_++) {
-		for (int x_ = 0; x_ < w; x_++) {
-			int value = *bp & (1 << bit);
-			if (++bit >= 8) {
-				bp++;
-				bit %= 8;
-
-				if ((const void *)(bp) >= (p + size))
-					return;
-			}
-
-			Color c = value ? white : black;
-			gfx_setpixel(reg, x + x_, y + y_, c);
 		}
 	}
 }
@@ -336,7 +378,7 @@ static void gfx_copy_region_mono(
  * significant bit determines the color, the remaining 7 bits determine the
  * amount.
  */
-static void gfx_copy_region_rle_mono(
+void gfx_copy_region_rle_mono(
 	struct gfx_region *reg,
 	int x,
 	int y,
@@ -363,31 +405,6 @@ static void gfx_copy_region_rle_mono(
 	}
 }
 
-void gfx_copy_region(
-	struct gfx_region *reg,
-	int x,
-	int y,
-	int w,
-	int h,
-	enum gfx_encoding encoding,
-	size_t size,
-	const void *p
-) {
-	switch (encoding) {
-	case GFX_RAW:
-		gfx_copy_region_raw(reg, x, y, w, h, size, p);
-		break;
-	case GFX_MONO:
-		gfx_copy_region_mono(reg, x, y, w, h, size, p);
-		break;
-	case GFX_RLE_MONO:
-		gfx_copy_region_rle_mono(reg, x, y, w, h, size, p);
-		break;
-	default:
-		break;
-	}
-}
-
 void gfx_copy_raw(struct gfx_region *reg, const void *p, size_t size)
 {
 	fb_copy_raw(reg->fb, p, size);
diff --git a/lib/gfx/gfx.h b/lib/gfx/gfx.h
index 4c9a16e508fef1ac4d7346d7adeeee62d71d5c1f..c785b73d8202ba8fd62b7a88ee09bf9c3d937c9e 100644
--- a/lib/gfx/gfx.h
+++ b/lib/gfx/gfx.h
@@ -44,9 +44,12 @@ enum gfx_color {
 };
 
 enum gfx_encoding {
-	GFX_RAW,
+	GFX_RGB565,
 	GFX_MONO,
-	GFX_RLE_MONO
+	GFX_RLE_MONO,
+	GFX_RGB8,
+	GFX_RGBA8,
+	GFX_RGBA5551
 };
 
 struct gfx_color_rgb {
@@ -57,9 +60,10 @@ struct gfx_color_rgb {
 
 Color gfx_color(struct gfx_region *reg, enum gfx_color color);
 
-void gfx_copy_region(struct gfx_region *reg, int x, int y, int w, int h,
-				enum gfx_encoding encoding, size_t size,
-							 const void *p);
+void gfx_copy_region_rle_mono(struct gfx_region *reg, int x, int y, int w, int h,
+						size_t size, const void *p);
+void gfx_copy_region( struct gfx_region *reg, int x, int y, int w, int h,
+						enum gfx_encoding encoding, const uint8_t *p);
 void gfx_copy_raw(struct gfx_region *reg, const void *p, size_t size);
 
 #endif
diff --git a/lib/lodepng/lodepng b/lib/lodepng/lodepng
new file mode 160000
index 0000000000000000000000000000000000000000..7fdcc96a5e5864eee72911c3ca79b1d9f0d12292
--- /dev/null
+++ b/lib/lodepng/lodepng
@@ -0,0 +1 @@
+Subproject commit 7fdcc96a5e5864eee72911c3ca79b1d9f0d12292
diff --git a/lib/lodepng/lodepng.c b/lib/lodepng/lodepng.c
new file mode 120000
index 0000000000000000000000000000000000000000..c5f0d591106af0a8f58d21b0c5194e8759ae05ed
--- /dev/null
+++ b/lib/lodepng/lodepng.c
@@ -0,0 +1 @@
+lodepng/lodepng.cpp
\ No newline at end of file
diff --git a/lib/lodepng/meson.build b/lib/lodepng/meson.build
new file mode 100644
index 0000000000000000000000000000000000000000..5d50b024b932005afaecaa7eab4994700cfd481f
--- /dev/null
+++ b/lib/lodepng/meson.build
@@ -0,0 +1,19 @@
+includes = include_directories(
+  './lodepng',
+)
+
+sources = files(
+  './lodepng.c',
+)
+
+lib = static_library(
+  'lodepng',
+  sources,
+  include_directories: includes,
+  c_args: ['-O3', '-w', '-DLODEPNG_NO_COMPILE_ENCODER', '-DLODEPNG_NO_COMPILE_DISK', '-DLODEPNG_NO_COMPILE_ALLOCATORS'],
+)
+
+lodepng = declare_dependency(
+  include_directories: includes,
+  link_with: lib,
+)
diff --git a/lib/meson.build b/lib/meson.build
index 86d05cc0678947c81845e658b983d7a8c023d4d5..7f094776ff8feb56a9faa009ac06cf64df2b6804 100644
--- a/lib/meson.build
+++ b/lib/meson.build
@@ -17,3 +17,4 @@ subdir('./crypto/')
 subdir('./card10/')
 subdir('./mx25lba/')
 subdir('./ff13/')
+subdir('./lodepng/')
diff --git a/pycardium/meson.build b/pycardium/meson.build
index 8621e66128ca22cecdd3d53b021f5ee03d1fab1d..38d5521e1989983fead5aeca90a42101e81b4482 100644
--- a/pycardium/meson.build
+++ b/pycardium/meson.build
@@ -21,6 +21,7 @@ modsrc = files(
   'modules/sys_bme680.c',
   'modules/sys_display.c',
   'modules/sys_leds.c',
+  'modules/sys_png.c',
   'modules/utime.c',
   'modules/vibra.c',
   'modules/ws2812.c',
@@ -99,7 +100,7 @@ elf = executable(
   mp_headers,
   version_hdr,
   include_directories: micropython_includes,
-  dependencies: [max32665_startup_core1, periphdriver, rd117, api_caller],
+  dependencies: [max32665_startup_core1, periphdriver, rd117, api_caller, lodepng],
   link_with: upy,
   link_whole: [max32665_startup_core1_lib, api_caller_lib],
   link_args: [
diff --git a/pycardium/modules/py/display.py b/pycardium/modules/py/display.py
index 1c94d47b35844db5272447265c94389c2eac918b..4884b1e7d1be63a8487f2088b219ca641035dcb7 100644
--- a/pycardium/modules/py/display.py
+++ b/pycardium/modules/py/display.py
@@ -8,6 +8,11 @@ FONT16 = 2
 FONT20 = 3
 FONT24 = 4
 
+RGB8 = 0
+RGBA8 = 1
+RGB565 = 2
+RGBA5551 = 3
+
 
 class Display:
     """
@@ -120,7 +125,7 @@ class Display:
         sys_display.pixel(x, y, col)
         return self
 
-    def blit(self, x, y, w, h, img, rgb565=False, alpha=None):
+    def blit(self, x, y, w, h, img, format=RGB8):
         """
         Draws an image on the display.
 
@@ -128,16 +133,20 @@ class Display:
         :param y: Y coordinate
         :param w: Image width
         :param h: Image height
-        :param img: Buffer with pixel data. Default format is RGB with 8 bits per channel.
-        :param alpha: Alpha mask for `img`
-        :param rgb565: Set to `True` if the data supplied is in rgb565 format instead of 8 bit RGB.
+        :param img: Buffer with pixel data
+        :param format: Format of the RGB data. One of ``display.RGB8``, ``
+
+           - ``display.RGB8``: 24 bit RGB.
+           - ``display.RGBA8``: 24 bit RGB + 8 bit alpha.
+           - ``display.RGB565``: 16 bit RGB. This consumes 1 byte less RAM per pixel than ``display.RGB8``.
+           - ``display.RGBA5551``: 15 bit RGB + 1 bit alpha.
+
+           Default is ``display.RGB8``.
 
-        .. note::
-           Alpha mask support is not yet implemented.
 
         .. versionadded:: 1.17
 
-        **Example with RGB data:**
+        **Example with RGB8 data:**
 
         .. code-block:: python
 
@@ -153,7 +162,7 @@ class Display:
                d.update()
 
 
-        **Example with rgb565 data:**
+        **Example with RGB565 data:**
 
         .. code-block:: python
 
@@ -165,7 +174,7 @@ class Display:
            img = array.array('H', [0x07E0 for x in range(32 * 20)])
            with display.open() as d:
                d.clear()
-               d.blit(10, 10, 32, 20, img, rgb565=True)
+               d.blit(10, 10, 32, 20, img, format=display.RGB565)
                d.update()
 
 
@@ -180,20 +189,13 @@ class Display:
            f = framebuf.FrameBuffer(bytearray(160 * 80 * 2), 160, 80, framebuf.RGB565)
            with display.open() as d:
                f.text("Hello World", 0, 0, 0xF800) # red
-               d.blit(0, 0, 160, 80, f, rgb565=True)
+               d.blit(0, 0, 160, 80, f, format=display.RGB565)
                d.update()
 
 
         """
 
-        # TODO: alpha is not yet supported by epicardium
-        if alpha is not None:
-            raise ValueError("alpha not yet supported")
-
-        if alpha is None:
-            sys_display.blit(x, y, w, h, img, rgb565)
-        else:
-            sys_display.blit(x, y, w, h, img, rgb565, alpha)
+        sys_display.blit(x, y, w, h, img, format)
 
         return self
 
diff --git a/pycardium/modules/py/meson.build b/pycardium/modules/py/meson.build
index 0130c75b8407fb77535b33bff087ab6160c3b5ff..6f0fccbb0dfd0339bc5ecd761dc0668562e7ea1c 100644
--- a/pycardium/modules/py/meson.build
+++ b/pycardium/modules/py/meson.build
@@ -10,6 +10,7 @@ python_modules = files(
   'leds.py',
   'max30001.py',
   'max86150.py',
+  'png.py',
   'pride.py',
   'simple_menu.py',
 
diff --git a/pycardium/modules/py/png.py b/pycardium/modules/py/png.py
new file mode 100644
index 0000000000000000000000000000000000000000..a5c460690f867a11266b69886777cb3aac2d2343
--- /dev/null
+++ b/pycardium/modules/py/png.py
@@ -0,0 +1,81 @@
+import sys_png
+import color
+
+RGB8 = 0
+RGBA8 = 1
+RGB565 = 2
+RGBA5551 = 3
+
+
+def decode(png_data, format="RGB", bg=color.BLACK):
+    """
+    Decode a PNG image and return raw pixel data.
+
+    :param str format: The intended output format:
+
+       - ``png.RGB8``: 24 bit RGB.
+       - ``png.RGBA8``: 24 bit RGB + 8 bit alpha.
+       - ``png.RGB565``: 16 bit RGB. This consumes 1 byte less RAM per pixel than ``png.RGB8``.
+       - ``png.RGBA5551``: 15 bit RGB + 1 bit alpha.
+
+       Default is ``png.RGB8``.
+
+    :param Color bg: Background color.
+
+      If the PNG contains an alpha channel but no alpha
+      channel is requested in the output (``png.RGB8`` or ``png.RGB565``)
+      this color will be used as the background color.
+
+      Default is ``Color.BLACK``.
+
+    :returns: Typle ``(width, height, data)``
+
+    .. versionadded:: 1.17
+
+    **Example with RGBA8 data:**
+
+    .. code-block:: python
+
+       import display
+       import png
+
+       # Draw a PNG file to the display
+       f = open("example.png")
+       w, h, img = png.decode(f.read(), png.RGBA8)
+       f.close()
+       with display.open() as d:
+           d.clear()
+           d.blit(0, 0, w, h, img, display.RGBA8)
+           d.update()
+
+
+    **Example with RGB565 data:**
+
+    .. code-block:: python
+
+       import display
+       import png
+
+       # Draw a PNG file to the display
+       f = open("example.png")
+       w, h, img = png.decode(f.read(), png.RGB565)
+       f.close()
+       with display.open() as d:
+           d.clear()
+           d.blit(0, 0, w, h, img, True, display.RGB565)
+           d.update()
+
+    """
+
+    formats = (RGB8, RGBA8, RGB565, RGBA5551)
+    if format not in formats:
+        raise ValueError("Output format not supported")
+
+    if format == RGB8:
+        return sys_png.decode(png_data, 1, 0, bg)
+    if format == RGBA8:
+        return sys_png.decode(png_data, 1, 1, bg)
+    if format == RGB565:
+        return sys_png.decode(png_data, 0, 0, bg)
+    if format == RGBA5551:
+        return sys_png.decode(png_data, 0, 1, bg)
diff --git a/pycardium/modules/qstrdefs.h b/pycardium/modules/qstrdefs.h
index 58f4f80d59d9b8ab97b9687a0dcf462f701b81a6..ad36658088e1d9c89126d7247acdfd439cb24ae8 100644
--- a/pycardium/modules/qstrdefs.h
+++ b/pycardium/modules/qstrdefs.h
@@ -220,3 +220,7 @@ Q(send_report)
 /* SpO2 */
 Q(spo2_algo)
 Q(maxim_rd117)
+
+/* PNG */
+Q(sys_png)
+Q(decode)
diff --git a/pycardium/modules/sys_display.c b/pycardium/modules/sys_display.c
index 37011cae8bc7ccf389815cf5dfacd5bccfbc0b4b..60a99aadd85aa895250e8ab5ea5a0af1b2f02961 100644
--- a/pycardium/modules/sys_display.c
+++ b/pycardium/modules/sys_display.c
@@ -97,14 +97,11 @@ static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(
 /* blit image to display */
 static mp_obj_t mp_display_blit(size_t n_args, const mp_obj_t *args)
 {
-	/* Required arguments: posx, posy (on display),
-	                       width, height (of image),
-						   buffer (rgb data of image) */
-	int pos_x   = mp_obj_get_int(args[0]);
-	int pos_y   = mp_obj_get_int(args[1]);
-	int width   = mp_obj_get_int(args[2]);
-	int height  = mp_obj_get_int(args[3]);
-	bool rgb565 = mp_obj_is_true(args[5]);
+	int pos_x                   = mp_obj_get_int(args[0]);
+	int pos_y                   = mp_obj_get_int(args[1]);
+	int width                   = mp_obj_get_int(args[2]);
+	int height                  = mp_obj_get_int(args[3]);
+	enum epic_rgb_format format = mp_obj_get_int(args[5]);
 	mp_buffer_info_t img;
 
 	int res = 0;
@@ -114,46 +111,7 @@ static mp_obj_t mp_display_blit(size_t n_args, const mp_obj_t *args)
 		mp_raise_TypeError("'img' does not support buffer protocol.");
 	}
 
-	int bpp = rgb565 ? 2 : 3;
-	if ((int)img.len < width * height * bpp) {
-		mp_raise_ValueError("'img' is too small.");
-	}
-
-	uint16_t *buf = NULL;
-	if (rgb565) {
-		buf = (uint16_t *)img.buf;
-	} else {
-		/* Will raise an exception if out of memory: */
-		buf = m_malloc(width * height * bpp);
-		for (int i = 0; i < width * height; i++) {
-			buf[i] = rgb888_to_rgb565(((uint8_t *)img.buf) + 3 * i);
-		}
-	}
-
-	if (n_args > 6) {
-		mp_buffer_info_t alpha;
-
-		/* Load alpha buffer and check size */
-		if (!mp_get_buffer(args[6], &alpha, MP_BUFFER_READ)) {
-			mp_raise_TypeError(
-				"'alpha' does not support buffer protocol."
-			);
-		}
-		if ((int)alpha.len < width * height) {
-			mp_raise_ValueError("'alpha' is too small.");
-		}
-
-		res = epic_disp_blit(
-			pos_x, pos_y, width, height, buf, (uint8_t *)alpha.buf
-		);
-	} else {
-		res = epic_disp_blit(pos_x, pos_y, width, height, buf, NULL);
-	}
-
-	if (!rgb565) {
-		/* Do not free rgb565 data. It is owned by the caller */
-		m_free(buf);
-	}
+	res = epic_disp_blit(pos_x, pos_y, width, height, img.buf, format);
 
 	if (res < 0) {
 		mp_raise_OSError(-res);
@@ -162,7 +120,7 @@ static mp_obj_t mp_display_blit(size_t n_args, const mp_obj_t *args)
 	return mp_const_none;
 }
 static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(
-	display_blit_obj, 5, 6, mp_display_blit
+	display_blit_obj, 6, 6, mp_display_blit
 );
 
 /* set display backlight brightness */
diff --git a/pycardium/modules/sys_png.c b/pycardium/modules/sys_png.c
new file mode 100644
index 0000000000000000000000000000000000000000..c6dded0987e0ec6103b4a43caf30d94c9ce844e0
--- /dev/null
+++ b/pycardium/modules/sys_png.c
@@ -0,0 +1,193 @@
+#include "epicardium.h"
+
+#define LODEPNG_NO_COMPILE_ENCODER
+#define LODEPNG_NO_COMPILE_DISK
+#define LODEPNG_NO_COMPILE_ALLOCATORS
+#include "lodepng.h"
+
+#include "py/builtin.h"
+#include "py/binary.h"
+#include "py/obj.h"
+#include "py/objarray.h"
+#include "py/runtime.h"
+#include "py/gc.h"
+
+void *lodepng_malloc(size_t size)
+{
+	return m_malloc(size);
+}
+
+void *lodepng_realloc(void *ptr, size_t new_size)
+{
+	return m_realloc(ptr, new_size);
+}
+
+void lodepng_free(void *ptr)
+{
+	m_free(ptr);
+}
+
+static void lode_raise(unsigned int status)
+{
+	if (status) {
+		nlr_raise(mp_obj_new_exception_msg_varg(
+			&mp_type_ValueError, lodepng_error_text(status))
+		);
+	}
+}
+
+static inline uint16_t rgba8_to_rgba5551(uint8_t *bytes)
+{
+	uint16_t c = ((bytes[0] & 0b11111000) << 8) |
+		     ((bytes[1] & 0b11111000) << 3) |
+		     ((bytes[2] & 0b11111000) >> 2);
+	if (bytes[3] > 127) {
+		c |= 1;
+	}
+	return c;
+}
+
+static inline uint16_t rgb8_to_rgb565(uint8_t *bytes)
+{
+	return ((bytes[0] & 0b11111000) << 8) | ((bytes[1] & 0b11111100) << 3) |
+	       (bytes[2] >> 3);
+}
+
+static inline uint8_t apply_alpha(uint8_t in, uint8_t bg, uint8_t alpha)
+{
+	/* Not sure if it is worth (or even a good idea) to have
+	 * the special cases here. */
+	if (bg == 0) {
+		return (in * alpha) / 255;
+	}
+
+	uint8_t beta = 255 - alpha;
+
+	if (bg == 255) {
+		return ((in * alpha) / 255) + beta;
+	}
+
+	return (in * alpha + bg * beta) / 255;
+}
+
+static mp_obj_t mp_png_decode(size_t n_args, const mp_obj_t *args)
+{
+	mp_buffer_info_t png_info;
+
+	mp_obj_t png       = args[0];
+	mp_obj_t rgb8_out  = args[1];
+	mp_obj_t alpha_out = args[2];
+	mp_obj_t bg        = args[3];
+
+	/* Load buffer and ensure it contains enough data */
+	if (!mp_get_buffer(png, &png_info, MP_BUFFER_READ)) {
+		mp_raise_TypeError("png does not support buffer protocol.");
+	}
+
+	if (mp_obj_get_int(mp_obj_len(bg)) < 3) {
+		mp_raise_ValueError("bg must have 3 elements.");
+	}
+
+	int i, j;
+
+	unsigned int w, h;
+	uint8_t *raw;
+	int raw_len;
+	int raw_len_original;
+
+	LodePNGState state;
+	lodepng_state_init(&state);
+	state.decoder.ignore_crc = 1;
+	lode_raise(lodepng_inspect(&w, &h, &state, png_info.buf, png_info.len));
+
+	unsigned alpha_in = lodepng_can_have_alpha(&(state.info_png.color));
+
+	/* Do we need to consider an alpha channel? */
+	if (alpha_in || mp_obj_is_true(alpha_out)) {
+		lode_raise(lodepng_decode32(
+			&raw, &w, &h, png_info.buf, png_info.len)
+		);
+		raw_len = w * h * 4;
+	} else {
+		lode_raise(lodepng_decode24(
+			&raw, &w, &h, png_info.buf, png_info.len)
+		);
+		raw_len = w * h * 4;
+	}
+
+	raw_len_original = raw_len;
+
+	/* User did not ask for alpha, but input might contain alpha.
+	 * Remove alpha using provided background color. */
+	if (alpha_in && !mp_obj_is_true(alpha_out)) {
+		uint8_t bg_red = mp_obj_get_int(
+			mp_obj_subscr(bg, mp_obj_new_int(0), MP_OBJ_SENTINEL)
+		);
+		uint8_t bg_green = mp_obj_get_int(
+			mp_obj_subscr(bg, mp_obj_new_int(1), MP_OBJ_SENTINEL)
+		);
+		uint8_t bg_blue = mp_obj_get_int(
+			mp_obj_subscr(bg, mp_obj_new_int(2), MP_OBJ_SENTINEL)
+		);
+
+		for (i = 0, j = 0; i < raw_len; i += 4, j += 3) {
+			uint8_t alpha = raw[i + 3];
+			raw[j]        = apply_alpha(raw[i], bg_red, alpha);
+			raw[j + 1] = apply_alpha(raw[i + 1], bg_green, alpha);
+			raw[j + 2] = apply_alpha(raw[i + 2], bg_blue, alpha);
+		}
+		raw_len = w * h * 3;
+	}
+
+	if (!mp_obj_is_true(rgb8_out)) {
+		if (mp_obj_is_true(alpha_out)) {
+			for (i = 0, j = 0; i < raw_len; i += 4, j += 2) {
+				uint16_t c = rgba8_to_rgba5551(&raw[i]);
+				raw[j]     = c & 0xFF;
+				raw[j + 1] = c >> 8;
+				raw[j + 2] = raw[i + 3];
+			}
+		} else {
+			for (i = 0, j = 0; i < raw_len; i += 3, j += 2) {
+				uint16_t c = rgb8_to_rgb565(&raw[i]);
+				raw[j]     = c & 0xFF;
+				raw[j + 1] = c >> 8;
+			}
+		}
+		raw_len = w * h * 2;
+	}
+
+	if (raw_len != raw_len_original) {
+		/* Some conversion shrank the buffer.
+		 * Reallocate to free the unneeded RAM. */
+		m_realloc(raw, raw_len);
+	}
+
+	mp_obj_t mp_w   = MP_OBJ_NEW_SMALL_INT(w);
+	mp_obj_t mp_h   = MP_OBJ_NEW_SMALL_INT(h);
+	mp_obj_t mp_raw = mp_obj_new_memoryview(
+		MP_OBJ_ARRAY_TYPECODE_FLAG_RW | BYTEARRAY_TYPECODE,
+		raw_len,
+		raw
+	);
+	mp_obj_t tup[] = { mp_w, mp_h, mp_raw };
+
+	return mp_obj_new_tuple(3, tup);
+}
+static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(decode, 4, 4, mp_png_decode);
+
+static const mp_rom_map_elem_t png_module_globals_table[] = {
+	{ MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_sys_png) },
+	{ MP_ROM_QSTR(MP_QSTR_decode), MP_ROM_PTR(&decode) },
+};
+static MP_DEFINE_CONST_DICT(png_module_globals, png_module_globals_table);
+
+// Define module object.
+const mp_obj_module_t png_module = {
+	.base    = { &mp_type_module },
+	.globals = (mp_obj_dict_t *)&png_module_globals,
+};
+
+/* Register the module to make it available in Python */
+/* clang-format off */
+MP_REGISTER_MODULE(MP_QSTR_sys_png, png_module, MODULE_PNG_ENABLED);
diff --git a/pycardium/mpconfigport.h b/pycardium/mpconfigport.h
index 7e2a56ec31356d50f45d5511560a8188daa19a87..fbe33070bc8425a91f7a12cfe6ca35a16ea71a0f 100644
--- a/pycardium/mpconfigport.h
+++ b/pycardium/mpconfigport.h
@@ -78,6 +78,7 @@ int mp_hal_csprng_read_int(void);
 #define MODULE_WS2812_ENABLED               (1)
 #define MODULE_CONFIG_ENABLED               (1)
 #define MODULE_BLE_ENABLED                  (1)
+#define MODULE_PNG_ENABLED                  (1)
 
 #define MICROPY_BLUETOOTH_CARD10            (1)
 /*
diff --git a/tools/code-style.sh b/tools/code-style.sh
index 3256654a089239c56da6c24bf427ad8ab2140943..311df9bea8f01ccab1497ccd3fdace27a54a89a7 100755
--- a/tools/code-style.sh
+++ b/tools/code-style.sh
@@ -41,6 +41,7 @@ formatter_blacklist=(
     lib/ff13/
     lib/FreeRTOS/
     lib/FreeRTOS-Plus/
+    lib/lodepng/
     lib/micropython/
     lib/mx25lba/
     lib/sdk/