diff --git a/src/py/cam/cam.py b/src/py/cam/cam.py
index 967a72f5..50f0f8d6 100755
--- a/src/py/cam/cam.py
+++ b/src/py/cam/cam.py
@@ -387,6 +387,7 @@ def main():
     parser.add_argument('--list-controls', action='store_true', help='List cameras controls')
     parser.add_argument('-I', '--info', action='store_true', help='Display information about stream(s)')
     parser.add_argument('-R', '--renderer', default='null', help='Renderer (null, kms, qt, qtgl)')
+    parser.add_argument('--rargs', default='', help='Arguments passed to the renderer (pass --help to see help)')
 
     # per camera options
     parser.add_argument('-C', '--capture', nargs='?', type=int, const=1000000, action=CustomAction, help='Capture until interrupted by user or until CAPTURE frames captured')
@@ -449,6 +450,9 @@ def main():
         elif args.renderer == 'qtgl':
             import cam_qtgl
             renderer = cam_qtgl.QtRenderer(state)
+        elif args.renderer == 'tx':
+            import cam_tx
+            renderer = cam_tx.TxRenderer(state, args.rargs)
         else:
             print('Bad renderer', args.renderer)
             return -1
diff --git a/src/py/cam/cam_tx.py b/src/py/cam/cam_tx.py
new file mode 100644
index 00000000..3d31c6ef
--- /dev/null
+++ b/src/py/cam/cam_tx.py
@@ -0,0 +1,94 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+# Copyright (C) 2023, Tomi Valkeinen <tomi.valkeinen@ideasonboard.com>
+
+import argparse
+import libcamera
+import libcamera.utils
+import selectors
+import socket
+import struct
+import sys
+
+PORT = 43242
+
+# ctx-idx, width, height, format, num-planes, plane1, plane2, plane3, plane4
+struct_fmt = struct.Struct('<III12pI4I')
+
+
+class TxRenderer:
+    def __init__(self, state, ropts):
+        parser = argparse.ArgumentParser(prog='TxRenderer')
+        parser.add_argument('host', default='localhost', help='Address')
+        args = parser.parse_args(ropts.split(' '))
+
+        self.host = args.host
+
+        self.state = state
+
+        self.cm = state.cm
+        self.contexts = state.contexts
+
+        self.running = False
+
+    def setup(self):
+        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        sock.connect((self.host, PORT))
+        self.sock = sock
+
+        buf_mmap_map = {}
+
+        for ctx in self.contexts:
+            for stream in ctx.streams:
+                for buf in ctx.allocator.buffers(stream):
+                    mfb = libcamera.utils.MappedFrameBuffer(buf).mmap()
+                    buf_mmap_map[buf] = mfb
+
+        self.buf_mmap_map = buf_mmap_map
+
+    def run(self):
+        print('Capturing...')
+
+        self.running = True
+
+        sel = selectors.DefaultSelector()
+        sel.register(self.cm.event_fd, selectors.EVENT_READ, self.readcam)
+        sel.register(sys.stdin, selectors.EVENT_READ, self.readkey)
+
+        print('Press enter to exit')
+
+        while self.running:
+            events = sel.select()
+            for key, _ in events:
+                callback = key.data
+                callback(key.fileobj)
+
+        print('Exiting...')
+
+    def readcam(self, fd):
+        self.running = self.state.event_handler()
+
+    def readkey(self, fileobj):
+        sys.stdin.readline()
+        self.running = False
+
+    def request_handler(self, ctx, req):
+        buffers = req.buffers
+
+        for stream, fb in buffers.items():
+            mfb = self.buf_mmap_map[fb]
+
+            plane_sizes = [len(p) for p in mfb.planes] + [0] * (4 - len(mfb.planes))
+
+            stream_config = stream.configuration
+
+            hdr = struct_fmt.pack(ctx.idx,
+                                  stream_config.size.width, stream_config.size.height,
+                                  bytes(str(stream_config.pixel_format), 'ascii'),
+                                  len(mfb.planes), *plane_sizes)
+
+            self.sock.sendall(hdr)
+
+            for p in mfb.planes:
+                self.sock.sendall(p)
+
+        self.state.request_processed(ctx, req)
diff --git a/src/py/examples/cam-rx.py b/src/py/examples/cam-rx.py
new file mode 100755
index 00000000..a53d59c8
--- /dev/null
+++ b/src/py/examples/cam-rx.py
@@ -0,0 +1,155 @@
+#!/usr/bin/env python3
+
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (C) 2023, Tomi Valkeinen <tomi.valkeinen@ideasonboard.com>
+
+from cam_rx_helpers import data_to_pix
+from PyQt5 import QtCore, QtWidgets
+from PyQt5.QtCore import Qt
+import PyQt5.QtNetwork
+import struct
+import sys
+import traceback
+
+PORT = 43242
+receivers = []
+
+struct_fmt = struct.Struct('<III12pI4I')
+
+
+# Loading MJPEG to a QPixmap produces corrupt JPEG data warnings. Ignore these.
+def qt_message_handler(msg_type, msg_log_context, msg_string):
+    if msg_string.startswith("Corrupt JPEG data"):
+        return
+
+    # For some reason qInstallMessageHandler returns None, so we won't
+    # call the old handler
+    if old_msg_handler is not None:
+        old_msg_handler(msg_type, msg_log_context, msg_string)
+    else:
+        print(msg_string)
+
+
+old_msg_handler = QtCore.qInstallMessageHandler(qt_message_handler)
+
+
+class Receiver(QtWidgets.QWidget):
+    def __init__(self, socket: PyQt5.QtNetwork.QTcpSocket):
+        super().__init__()
+
+        self.name = '{}:{}'.format(socket.peerAddress().toString(), socket.peerPort())
+
+        print('[{}] Accepted new connection'.format(self.name))
+
+        self.socket = socket
+
+        self.socket.readyRead.connect(self.on_ready_read)
+        self.socket.disconnected.connect(self.on_disconnected)
+        self.socket.error.connect(self.on_error)
+
+        self.header_buffer = bytearray()
+        self.data_buffer = bytearray()
+        self.data_size = 0
+
+        self.state = 0
+
+        self.resize(1000, 600)
+        self.setAttribute(Qt.WA_ShowWithoutActivating)
+        self.setWindowFlag(Qt.WindowStaysOnTopHint, True)
+
+        self.gridLayout = QtWidgets.QGridLayout()
+        self.setLayout(self.gridLayout)
+
+        self.labels = {}
+
+        self.show()
+        print("done")
+
+    def on_ready_read(self):
+        while self.socket.bytesAvailable():
+            if self.state == 0:
+                data = self.socket.read(struct_fmt.size - len(self.header_buffer))
+                self.header_buffer.extend(data)
+
+                if len(self.header_buffer) == struct_fmt.size:
+                    self.on_header()
+            else:
+                data = self.socket.read(self.data_size - len(self.data_buffer))
+                self.data_buffer.extend(data)
+
+                if len(self.data_buffer) == self.data_size:
+                    try:
+                        self.on_buffers()
+                    except Exception:
+                        print(traceback.format_exc())
+                        qApp.exit(-1)
+                        return
+
+    def on_header(self):
+        self.header_tuple = struct_fmt.unpack_from(self.header_buffer)
+        idx, w, h, fmtstr, num_planes, p0, p1, p2, p3 = self.header_tuple
+        self.data_size = p0 + p1 + p2 + p3
+        self.header_buffer = bytearray()
+
+        self.state = 1
+
+    def on_buffers(self):
+        idx, w, h, fmtstr, num_planes, p0, p1, p2, p3 = self.header_tuple
+        fmt = fmtstr.decode('ascii')
+
+        print('[{}] cam{} {}x{}-{}'.format(self.name, idx, w, h, fmt))
+
+        if idx not in self.labels:
+            label = QtWidgets.QLabel()
+            label.setSizePolicy(QtWidgets.QSizePolicy.Ignored, QtWidgets.QSizePolicy.Ignored)
+            self.labels[idx] = label
+            self.gridLayout.addWidget(label, self.gridLayout.count() // 2, self.gridLayout.count() % 2)
+
+        label = self.labels[idx]
+
+        pix = data_to_pix(fmt, w, h, self.data_buffer)
+
+        pix = pix.scaled(label.width(), label.height(), Qt.AspectRatioMode.KeepAspectRatio,
+                         Qt.TransformationMode.FastTransformation)
+
+        label.setPixmap(pix)
+
+        self.data_buffer = bytearray()
+
+        self.state = 0
+
+    def on_disconnected(self):
+        print('[{}] Disconnected'.format(self.name))
+        self.close()
+        receivers.remove(self)
+
+    def on_error(self):
+        print('[{}] Error: {}'.format(self.name, self.socket.errorString()))
+
+
+def new_connection(tcpServer):
+    clientConnection: PyQt5.QtNetwork.QTcpSocket = tcpServer.nextPendingConnection()
+    w = Receiver(clientConnection)
+    receivers.append(w)
+
+
+def readkey():
+    global qApp
+    sys.stdin.readline()
+    qApp.quit()
+
+
+if __name__ == '__main__':
+    global qApp
+
+    qApp = QtWidgets.QApplication(sys.argv)
+    qApp.setQuitOnLastWindowClosed(False)
+
+    keynotif = QtCore.QSocketNotifier(sys.stdin.fileno(), QtCore.QSocketNotifier.Read)
+    keynotif.activated.connect(readkey)
+
+    tcpServer = PyQt5.QtNetwork.QTcpServer(qApp)
+    tcpServer.listen(PyQt5.QtNetwork.QHostAddress('0.0.0.0'), PORT)
+    tcpServer.newConnection.connect(lambda: new_connection(tcpServer))
+
+    sys.exit(qApp.exec_())
diff --git a/src/py/examples/cam_rx_helpers.py b/src/py/examples/cam_rx_helpers.py
new file mode 100644
index 00000000..293eb63d
--- /dev/null
+++ b/src/py/examples/cam_rx_helpers.py
@@ -0,0 +1,223 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (C) 2023, Tomi Valkeinen <tomi.valkeinen@ideasonboard.com>
+#
+# Debayering code based on PiCamera documentation
+
+from numpy.lib.stride_tricks import as_strided
+from PyQt5 import QtGui
+import numpy as np
+
+
+def demosaic(data, r0, g0, g1, b0):
+    # Separate the components from the Bayer data to RGB planes
+
+    rgb = np.zeros(data.shape + (3,), dtype=data.dtype)
+    rgb[1::2, 0::2, 0] = data[r0[1]::2, r0[0]::2]  # Red
+    rgb[0::2, 0::2, 1] = data[g0[1]::2, g0[0]::2]  # Green
+    rgb[1::2, 1::2, 1] = data[g1[1]::2, g1[0]::2]  # Green
+    rgb[0::2, 1::2, 2] = data[b0[1]::2, b0[0]::2]  # Blue
+
+    # Below we present a fairly naive de-mosaic method that simply
+    # calculates the weighted average of a pixel based on the pixels
+    # surrounding it. The weighting is provided by a byte representation of
+    # the Bayer filter which we construct first:
+
+    bayer = np.zeros(rgb.shape, dtype=np.uint8)
+    bayer[1::2, 0::2, 0] = 1  # Red
+    bayer[0::2, 0::2, 1] = 1  # Green
+    bayer[1::2, 1::2, 1] = 1  # Green
+    bayer[0::2, 1::2, 2] = 1  # Blue
+
+    # Allocate an array to hold our output with the same shape as the input
+    # data. After this we define the size of window that will be used to
+    # calculate each weighted average (3x3). Then we pad out the rgb and
+    # bayer arrays, adding blank pixels at their edges to compensate for the
+    # size of the window when calculating averages for edge pixels.
+
+    output = np.empty(rgb.shape, dtype=rgb.dtype)
+    window = (3, 3)
+    borders = (window[0] - 1, window[1] - 1)
+    border = (borders[0] // 2, borders[1] // 2)
+
+    rgb = np.pad(rgb, [
+        (border[0], border[0]),
+        (border[1], border[1]),
+        (0, 0),
+    ], 'constant')
+    bayer = np.pad(bayer, [
+        (border[0], border[0]),
+        (border[1], border[1]),
+        (0, 0),
+    ], 'constant')
+
+    # For each plane in the RGB data, we use a nifty numpy trick
+    # (as_strided) to construct a view over the plane of 3x3 matrices. We do
+    # the same for the bayer array, then use Einstein summation on each
+    # (np.sum is simpler, but copies the data so it's slower), and divide
+    # the results to get our weighted average:
+
+    for plane in range(3):
+        p = rgb[..., plane]
+        b = bayer[..., plane]
+
+        pview = as_strided(p, shape=(
+            p.shape[0] - borders[0],
+            p.shape[1] - borders[1]) + window, strides=p.strides * 2)
+        bview = as_strided(b, shape=(
+            b.shape[0] - borders[0],
+            b.shape[1] - borders[1]) + window, strides=b.strides * 2)
+        psum = np.einsum('ijkl->ij', pview)
+        bsum = np.einsum('ijkl->ij', bview)
+        output[..., plane] = psum // bsum
+
+    return output
+
+
+def convert_raw(data, w, h, fmt):
+    bayer_pattern = fmt[1:5]
+    bitspp = int(fmt[5:])
+
+    if bitspp == 8:
+        data = data.reshape((h, w))
+        data = data.astype(np.uint16)
+    elif bitspp in [10, 12]:
+        data = data.view(np.uint16)
+        data = data.reshape((h, w))
+    else:
+        raise Exception('Bad bitspp:' + str(bitspp))
+
+    idx = bayer_pattern.find('R')
+    assert(idx != -1)
+    r0 = (idx % 2, idx // 2)
+
+    idx = bayer_pattern.find('G')
+    assert(idx != -1)
+    g0 = (idx % 2, idx // 2)
+
+    idx = bayer_pattern.find('G', idx + 1)
+    assert(idx != -1)
+    g1 = (idx % 2, idx // 2)
+
+    idx = bayer_pattern.find('B')
+    assert(idx != -1)
+    b0 = (idx % 2, idx // 2)
+
+    rgb = demosaic(data, r0, g0, g1, b0)
+    rgb = (rgb >> (bitspp - 8)).astype(np.uint8)
+
+    return rgb
+
+
+def convert_yuv444_to_rgb(yuv):
+    m = np.array([
+        [1.0, 1.0, 1.0],
+        [-0.000007154783816076815, -0.3441331386566162, 1.7720025777816772],
+        [1.4019975662231445, -0.7141380310058594, 0.00001542569043522235]
+    ])
+
+    rgb = np.dot(yuv, m)
+    rgb[:, :, 0] -= 179.45477266423404
+    rgb[:, :, 1] += 135.45870971679688
+    rgb[:, :, 2] -= 226.8183044444304
+    rgb = rgb.astype(np.uint8)
+
+    return rgb
+
+
+def convert_yuyv(data, w, h):
+    # YUV422
+    yuyv = data.reshape((h, w // 2 * 4))
+
+    # YUV444
+    yuv = np.empty((h, w, 3), dtype=np.uint8)
+    yuv[:, :, 0] = yuyv[:, 0::2]                    # Y
+    yuv[:, :, 1] = yuyv[:, 1::4].repeat(2, axis=1)  # U
+    yuv[:, :, 2] = yuyv[:, 3::4].repeat(2, axis=1)  # V
+
+    return convert_yuv444_to_rgb(yuv)
+
+
+def convert_uyvy(data, w, h):
+    # YUV422
+    yuyv = data.reshape((h, w // 2 * 4))
+
+    # YUV444
+    yuv = np.empty((h, w, 3), dtype=np.uint8)
+    yuv[:, :, 0] = yuyv[:, 1::2]                    # Y
+    yuv[:, :, 1] = yuyv[:, 0::4].repeat(2, axis=1)  # U
+    yuv[:, :, 2] = yuyv[:, 2::4].repeat(2, axis=1)  # V
+
+    return convert_yuv444_to_rgb(yuv)
+
+
+def convert_nv12(data, w, h):
+    plane1 = data[:w * h]
+    plane2 = data[w * h:]
+
+    y = plane1.reshape((h, w))
+    uv = plane2.reshape((h // 2, w // 2, 2))
+
+    # YUV444
+    yuv = np.empty((h, w, 3), dtype=np.uint8)
+    yuv[:, :, 0] = y[:, :]                    # Y
+    yuv[:, :, 1] = uv[:, :, 0].repeat(2, axis=0).repeat(2, axis=1)  # U
+    yuv[:, :, 2] = uv[:, :, 1].repeat(2, axis=0).repeat(2, axis=1)  # V
+
+    return convert_yuv444_to_rgb(yuv)
+
+
+def to_rgb(fmt, w, h, data):
+    if fmt == 'YUYV':
+        return convert_yuyv(data, w, h)
+
+    if fmt == 'UYVY':
+        return convert_uyvy(data, w, h)
+
+    elif fmt == 'NV12':
+        return convert_nv12(data, w, h)
+
+    elif fmt == 'RGB888':
+        rgb = data.reshape((h, w, 3))
+        rgb[:, :, [0, 1, 2]] = rgb[:, :, [2, 1, 0]]
+
+    elif fmt == 'BGR888':
+        rgb = data.reshape((h, w, 3))
+
+    elif fmt in ['ARGB8888', 'XRGB8888']:
+        rgb = data.reshape((h, w, 4))
+        rgb = np.flip(rgb, axis=2)
+        # drop alpha component
+        rgb = np.delete(rgb, np.s_[0::4], axis=2)
+
+    elif fmt.startswith('S'):
+        return convert_raw(data, w, h, fmt)
+
+    else:
+        raise Exception('Unsupported format ' + fmt)
+
+    return rgb
+
+
+def data_to_rgb(fmt, w, h, data):
+    data = np.frombuffer(data, dtype=np.uint8)
+    rgb = to_rgb(fmt, w, h, data)
+    return rgb
+
+
+def rgb_to_pix(rgb):
+    w = rgb.shape[1]
+    h = rgb.shape[0]
+    qim = QtGui.QImage(rgb, w, h, QtGui.QImage.Format.Format_RGB888)
+    pix = QtGui.QPixmap.fromImage(qim)
+    return pix
+
+
+def data_to_pix(fmt, w, h, data):
+    if fmt == 'MJPEG':
+        pix = QtGui.QPixmap(w, h)
+        pix.loadFromData(data)
+    else:
+        rgb = data_to_rgb(fmt, w, h, data)
+        pix = rgb_to_pix(rgb)
+
+    return pix
