I'm using a threaded runner to stream a live video feed by converting a NumPy array to a
QImage, then to aQPixmap, and displaying it on aQLabel. But I'm frequently encountering crashes when the label is resized too quickly or the scroll area is scrolled. Could this be a problem with theQImagememory buffer getting cleared before theQPixmapcan update? Is this fixable, or is it a fundamental issue with threads in Python/Qt?
This is a common problem when working with NumPy arrays and QImage across threads. The good news is that it's fixable. The crashes come from how QImage handles the underlying memory of a NumPy array.
Why the crash happens
When you create a QImage from a NumPy array, QImage doesn't copy the data. Instead it holds a reference to the original memory buffer provided by the NumPy array.
import numpy as np
from PyQt6.QtGui import QImage
# Create a NumPy array (e.g. a video frame)
array = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8)
# QImage points to the same memory — no copy is made
image = QImage(
array.data,
array.shape[1],
array.shape[0],
array.strides[0],
QImage.Format.Format_RGB8888,
)
This is efficient, but it creates a dangerous situation in a multithreaded application. If the NumPy array is modified or goes out of scope in the worker thread while the GUI thread is still using the QImage to paint, the memory that QImage is pointing to may no longer be valid. The result is a segfault or (worse) a silent crash without any information about what has happened.
This is especially likely when frames are arriving quickly (as with a live video feed) and the GUI is being redrawn frequently — for example, during a resize or a scroll.
The fix: copy the image data
The simplest and most reliable fix is to make sure the QImage owns its own copy of the pixel data before you pass it to the GUI thread. You can do this by calling .copy() on the QImage:
image = QImage(
array.data,
array.shape[1],
array.shape[0],
array.strides[0],
QImage.Format.Format_RGB888,
).copy()
The .copy() call creates a new QImage with its own independent memory buffer. Now it doesn't matter if the original NumPy array changes or disappears — the QImage is safe to use from the GUI thread.
If you've already tried using .copy() and it hasn't worked, bear in mind that where you use the copy matters just as much as using it at all.
Where to copy matters
If you create the QImage in the worker thread and emit it via a signal, the copy needs to happen before the signal is emitted. If the signal carries a reference to the original (non-copied) QImage, the data might still be invalidated before the GUI thread processes it.
PyQt/PySide Development Services — Stuck in development hell? I'll help you get your project focused, finished and released. Benefit from years of practical experience releasing software with Python.
Here's the pattern that works:
# In the worker thread
image = QImage(
frame.data,
frame.shape[1],
frame.shape[0],
frame.strides[0],
QImage.Format.Format_RGB888,
).copy() # Copy immediately, before emitting
self.signals.result.emit(image) # Now safe to send to GUI thread
And in the main thread, connect that signal to a slot that updates the display:
def update_display(self, image):
pixmap = QPixmap.fromImage(image)
self.label.setPixmap(pixmap)
Because the QImage was copied before it crossed the thread boundary, the GUI thread has full ownership of the data and can paint it safely.
Use signals to control the update flow
Another source of crashes is calling GUI methods directly from a worker thread. In Qt, all GUI updates must happen on the main thread. If you're calling label.setPixmap(...) from inside a worker or a callback running on a background thread, that's undefined behavior and will eventually crash.
The solution is to always use signals and slots to communicate between threads. Emit a signal from the worker carrying the processed image, and connect it to a slot on the main thread that performs the GUI update.
This also gives you a natural way to throttle updates. If frames are arriving faster than the GUI can paint them, you can use a flag to skip frames that arrive while the previous one is still being displayed.
Complete working example
Here's a full example that simulates a video feed using a QRunnable and a QThreadPool. It generates random NumPy frames in a background thread and safely displays them on a QLabel. If you're new to running background tasks with QThreadPool, see our detailed guide to multithreading PyQt6 applications.
import sys
import time
import numpy as np
from PyQt6.QtCore import (
QObject,
QRunnable,
QThreadPool,
pyqtSignal,
pyqtSlot,
)
from PyQt6.QtGui import QImage, QPixmap
from PyQt6.QtWidgets import (
QApplication,
QLabel,
QMainWindow,
QScrollArea,
QVBoxLayout,
QWidget,
)
class WorkerSignals(QObject):
frame_ready = pyqtSignal(QImage)
finished = pyqtSignal()
class VideoWorker(QRunnable):
def __init__(self):
super().__init__()
self.signals = WorkerSignals()
self.running = True
@pyqtSlot()
def run(self):
while self.running:
# Simulate a video frame (e.g. from a camera or stream)
frame = np.random.randint(
0, 255, (480, 640, 3), dtype=np.uint8
)
# Create QImage and copy it immediately so it owns its data
image = QImage(
frame.data,
frame.shape[1],
frame.shape[0],
frame.strides[0],
QImage.Format.Format_RGB888,
).copy()
# Emit the safe, copied image to the main thread
self.signals.frame_ready.emit(image)
# Simulate ~30 fps
time.sleep(1 / 30)
self.signals.finished.emit()
def stop(self):
self.running = False
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Threaded Video Display")
self.label = QLabel("Waiting for frames...")
self.label.setScaledContents(True)
scroll_area = QScrollArea()
scroll_area.setWidget(self.label)
scroll_area.setWidgetResiizable(True)
container = QWidget()
layout = QVBoxLayout(container)
layout.addWidget(scroll_area)
self.setCentralWidget(container)
self.resize(700, 520)
# Set up threading
self.threadpool = QThreadPool()
self.worker = VideoWorker()
self.worker.signals.frame_ready.connect(self.update_display)
self.worker.signals.finished.connect(self.on_finished)
self.threadpool.start(self.worker)
def update_display(self, image):
"""Runs on the main thread — safe to update the GUI here."""
pixmap = QPixmap.fromImage(image)
self.label.setPixmap(pixmap)
def on_finished(self):
print("Worker finished.")
def closeEvent(self, event):
self.worker.stop()
self.threadpool.waitForDone(2000)
event.accept()
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()
When you run this, you'll see a window displaying rapidly changing random noise — a stand-in for a real video stream. You can resize the window and scroll around without crashes, because the image data is safely copied before it crosses the thread boundary, and all GUI updates happen on the main thread via signals.
Recap
When working with NumPy arrays and QImage across threads, keep these three things in mind:
-
QImagedoes not copy NumPy data. It points to the original array's memory buffer. If that buffer changes or is freed, theQImagebecomes invalid. -
Call
.copy()on theQImagebefore emitting it across threads. This gives theQImageits own memory, independent of the NumPy array. -
Always update the GUI from the main thread. Use signals to send data from background workers to slots connected on the main thread, where it's safe to call
setPixmap()and other GUI methods.
With these practices in place, you can stream video or display rapidly changing image data without encountering the mysterious crashes that come from shared memory across threads.
Packaging Python Applications with PyInstaller by Martin Fitzpatrick — This step-by-step guide walks you through packaging your own Python applications from simple examples to complete installers and signed executables.