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/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/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..63fa2e1a75b31227358855349e9f1ed944ad8010
--- /dev/null
+++ b/pycardium/modules/py/png.py
@@ -0,0 +1,76 @@
+import sys_png
+import color
+
+
+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:
+
+       - ``RGB``: 24 bit RGB.
+       - ``RGBA``: 24 bit RGB + 8 bit alpha.
+       - ``565``: 16 bit RGB. This consumes 1 byte less RAM per pixel than ``RGB``.
+       - ``565A``: 16 bit RGB + 8 bit alpha.
+
+       Default is ``RGB``.
+
+    :param Color bg: Background color.
+
+      If the PNG contains an alpha channel but no alpha
+      channel is requested in the output (``RGB`` or ``565``)
+      this color will be used as the background color.
+
+      Default is ``Color.BLACK``.
+
+    :returns: Typle ``(width, height, data)``
+
+    .. versionadded:: 1.17
+
+    **Example with RGB 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())
+       f.close()
+       with display.open() as d:
+           d.clear()
+           d.blit(0, 0, w, h, img)
+           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(), "565")
+       f.close()
+       with display.open() as d:
+           d.clear()
+           d.blit(0, 0, w, h, img, True)
+           d.update()
+
+    """
+
+    formats = ("RGB", "RGBA", "565", "565A")
+    if format not in formats:
+        raise ValueError("Supported formats: " + ",".join(formats))
+
+    if format == "RGB":
+        return sys_png.decode(png_data, 0, 0, bg)
+    if format == "RGBA":
+        return sys_png.decode(png_data, 0, 1, bg)
+    if format == "565":
+        return sys_png.decode(png_data, 1, 0, bg)
+    if format == "565A":
+        return sys_png.decode(png_data, 1, 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_png.c b/pycardium/modules/sys_png.c
new file mode 100644
index 0000000000000000000000000000000000000000..78c819f496b89058da7a287276927286b8f58d00
--- /dev/null
+++ b/pycardium/modules/sys_png.c
@@ -0,0 +1,183 @@
+#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 rgb888_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 rgb565_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(rgb565_out)) {
+		if (mp_obj_is_true(alpha_out)) {
+			for (i = 0, j = 0; i < raw_len; i += 4, j += 3) {
+				uint16_t c = rgb888_to_rgb565(&raw[i]);
+				raw[j]     = c & 0xFF;
+				raw[j + 1] = c >> 8;
+				raw[j + 2] = raw[i + 3];
+			}
+			raw_len = w * h * 3;
+		} else {
+			for (i = 0, j = 0; i < raw_len; i += 3, j += 2) {
+				uint16_t c = rgb888_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)
 /*