The frame
The multimedia indexer shipped with a GUI, because the whole point of the workflow was "an investigating officer, on an air-gapped station laptop, looks at a hard drive and finds things." A CLI would have been a smaller build. It also would have failed the acceptance test on day one. The Samsung PRISM 2023 continuation of the SIH 2022 project inherited that constraint: whatever we built, an officer had to be able to point at a folder, click a button, and see files scroll past with a progress bar.
We picked PyQt5. Not because it was the best choice in 2023, but because it was the choice with the most working code lying around from prior Samsung R&D projects, and the deadline was tight. The rest of this post is what I learned by picking it.
The Qt docs are good. They're just not good for Python.
The Qt5 documentation, on the C++ side, is one of the better-written framework docs I have used. Signals, slots, threading, ownership, all of it laid out with examples that compile. The PyQt5 mapping onto that is a thin wrapper by intent, and the sipbuild-generated bindings are consistent enough that most of the C++ examples translate line for line.
The problem starts where Python and C++ disagree on lifecycles. The C++ examples never mention it because in C++ you own the object. In Python, you do not, and the garbage collector will happily reap a QThread mid-run because the last Python reference to it went out of scope.
I lost most of a Friday on that one.
The first bug: the worker that vanished
Version one of the indexer scan looked like this, roughly:
def on_scan_clicked(self):
thread = QThread()
worker = ScanWorker(self.folder_path)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.progress.connect(self.on_progress)
worker.finished.connect(thread.quit)
thread.start()Click scan. Progress bar moves once. Then nothing. No error, no crash, no traceback. Just a UI that thinks a scan is running and a worker that has quietly stopped existing.
thread and worker are locals. The moment on_scan_clicked returns, Python drops its references. Qt still has the C++ side of the QThread, so the thread keeps running for one more tick, but the worker Python object is freed, and any signal that tries to reach one of its bound slots hits a deleted PyQt_PyObject. On a good day you get a segfault. On a bad day you get silence, which is what I got.
The fix is stupid and it took reading a Riverbank mailing list thread from 2015 to find:
def on_scan_clicked(self):
self._thread = QThread()
self._worker = ScanWorker(self.folder_path)
self._worker.moveToThread(self._thread)
self._thread.started.connect(self._worker.run)
self._worker.progress.connect(self.on_progress)
self._worker.finished.connect(self._thread.quit)
self._worker.finished.connect(self._worker.deleteLater)
self._thread.finished.connect(self._thread.deleteLater)
self._thread.start()Keep the references on self. Chain deleteLater on finish so you do not leak. That's it. The Qt C++ docs correctly say the parent owns the child; the PyQt5 docs correctly say nothing changes; nobody says "except that in Python your locals are the root set."
There is a note about this buried in QThread's "Detailed Description" if you know to look. I did not know to look, because I was looking at the signal-slot documentation, and the signal-slot documentation talks about connections, not ownership.
QueuedConnection vs DirectConnection: the crash you can't reproduce
The default when both sender and receiver live in the same thread is Qt.AutoConnection, which resolves to DirectConnection. When they live in different threads, it resolves to QueuedConnection. This is fine most of the time and lethal at the edges.
We had a signal that fired from the worker thread ("index complete for file N") and updated a QLabel in the UI. Auto-connection promoted it to QueuedConnection, which posts an event to the target thread's event loop and returns. Works.
Then I refactored the scan flow so the worker also called a "compute checksum" helper that was defined on the main window class. The helper ran on the main thread, mutated self.checksums, and emitted a signal on completion. Auto-connection saw sender and receiver in the same thread and resolved to DirectConnection, which is a synchronous function call. The mutation of self.checksums was now happening from the worker thread, on a dict that the main thread was iterating over for a live view refresh.
The crash was one in maybe forty runs. RuntimeError: dictionary changed size during iteration, at wildly random offsets, always in code that had nothing to do with checksums.
The fix was to pin the connection explicitly:
worker.checksum_ready.connect(
self.on_checksum,
Qt.QueuedConnection,
)Never trust AutoConnection across a thread boundary you might refactor. Pin the connection type. The doc string for Qt.ConnectionType says all of this, in one paragraph, in a place I did not read until after I had shipped the crash.
Modal dialogs eat your progress bar
QFileDialog.getOpenFileName with the native flag (which is the default on Windows) opens the OS file picker. The OS file picker runs its own event loop. Your signals still fire, but the Qt event loop that is supposed to be delivering QueuedConnection events on the main thread is parked behind the native dialog.
Effect: the officer picks a folder, the scan launches in the background, the progress signal fires immediately for the first few files, the events queue up behind the modal dialog. When the dialog closes, the progress bar teleports from 0 to 30 percent in one paint, then continues normally. On a fast SSD the entire scan can finish before the dialog is dismissed and you get a scan that appears to have taken zero seconds.
I did not fix this. It was cosmetic and the officer at the acceptance demo thought it looked fast. But the honest fix is either (a) pass QFileDialog.DontUseNativeDialog and eat the uglier Qt-native dialog, or (b) do not start the scan until after the dialog has been dismissed and the event loop has ticked once with QApplication.processEvents().
The Windows-vs-Linux part matters here too. On the Ubuntu box I developed on, the GTK native dialog behaved differently, it ran in a way that let queued events drain in the background. So the bug did not reproduce on my machine, and I first saw it on the demo laptop, which is exactly where you do not want to first see a bug.
OpenCV frames across a signal: the copy you have to write yourself
The indexer's video path decoded frames with OpenCV and shipped them to the UI's preview widget over a signal. Signal payload: a numpy.ndarray.
For the first few dozen frames this looked fine. Then the preview would freeze on a random frame, or show garbage, or show the frame from three seconds ago overlaid on the current one. It looked like a race, and it was, but not the one I expected.
OpenCV's VideoCapture.read() returns a view into a buffer that VideoCapture reuses on the next call. The signal was carrying a reference to that buffer, not a copy. By the time the main thread got around to painting the frame, the worker thread had already overwritten the underlying memory with frame N+1 or N+2.
The fix was a .copy() at the emit site:
ret, frame = self.capture.read()
if ret:
self.frame_ready.emit(frame.copy())One method call. But you have to know that the buffer is reused, and OpenCV's Python docs do not warn you about it, and PyQt5's signal marshalling does not deep-copy numpy arrays because they are not one of the meta-types it knows how to serialise. The same class of bug would have hit any tensor handed across threads from a native allocator that reuses its buffers.
The general shape: if the payload of a cross-thread signal is a view into memory owned by the sender, and the sender is going to reuse that memory, the receiver is going to render whatever the sender wrote last, not what the sender emitted.
QSettings and the Windows registry path with a space in it
QSettings on Windows defaults to the registry backend. QSettings("Samsung PRISM", "Multimedia Indexer") writes to HKEY_CURRENT_USER\Software\Samsung PRISM\Multimedia Indexer. Fine, until you want to inspect the settings from a shell script or roll them out with a .reg file for the demo laptop, and discover that the space in Samsung PRISM is fine for the registry but breaks half of the tooling that wants to parse the path.
The fix was two lines at startup:
QSettings.setDefaultFormat(QSettings.IniFormat)
QSettings.setPath(QSettings.IniFormat,
QSettings.UserScope,
os.path.join(os.path.expanduser("~"), ".prism-indexer"))This puts settings in an INI file under the user's home directory. Portable, greppable, easy to reset the demo state between runs by deleting a file. The registry backend was not helping us and it was making the "clear settings and try again" story more complicated than it needed to be.
There is a subtle second gotcha: QSettings writes are batched and flushed on destruction. If you kill the process with a hard exit (which happens when the officer alt-F4s during a scan), the last few writes never make it to disk. Call settings.sync() explicitly after any write you care about. This is documented, and I missed it, and lost a demo state because of it.
High-DPI: the demo laptop was a 4K panel
I developed at 1080p. The demo laptop was a 4K panel at 200% scaling. First launch on the demo laptop: the file list was a strip of unreadable pixel soup, the icons were microscopic, and the progress bar was a horizontal line one pixel tall.
The fix, in the order I would try today:
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)Set both, before you construct QApplication. AA_EnableHighDpiScaling makes the whole framework treat one Qt pixel as one logical pixel, letting Windows do the scaling. AA_UseHighDpiPixmaps tells the framework to load the @2x variant of your icons when they exist.
Icons is the second gotcha: you have to ship @2x variants. Qt does not synthesise them from your 32x32 PNG. If you only ship the 32x32, you get the 32x32 painted into a 64x64 slot with nearest-neighbour scaling, which looks worse than the un-scaled version did.
Qt6 fixes this by making high-DPI the default. On Qt5 you have to opt in, and the opt-in has to happen before QApplication, which means if you have any code that constructs QApplication before your main entry point (Riverbank's pyqt5-tools designer stubs are a common culprit), the setting silently does nothing.
What was actually undocumented, versus what I missed
Sorting the gotchas by which part of Qt failed me:
The QThread + Python GC interaction is genuinely underdocumented. The Riverbank list has the answer, the Qt C++ docs do not have the question, and PyQt5's own docs assume you already know. Anyone shipping their first PyQt5 background job will hit this.
AutoConnection refactoring hazards, the QSettings.sync() requirement on hard exits, and the high-DPI opt-in are documented. I missed them. The docs are fine; I did not read carefully enough.
The OpenCV-frame-across-a-signal issue is not a Qt bug at all. It is a OpenCV memory-model bug that happens to be exposed by putting a thread boundary in the middle of the pipeline. No amount of reading Qt docs would have surfaced it.
The QFileDialog native-dialog event-loop-parking is a Qt bug, arguably, or at least a leaky abstraction. It is documented sideways: the DontUseNativeDialog flag is mentioned as "use the Qt-native dialog instead," without saying why you might want to. You want to when the native one blocks your event loop on Windows and does not on GTK.
What I would use today
If I were starting the same project in 2026:
PySide6. Same API, Qt6 under it, LGPL license without the commercial-license gotchas PyQt5 has for closed-source shipping. High-DPI works by default. The Python bindings are maintained by the Qt Company directly, which matters when a bug in the bindings is the reason the app crashes on a customer laptop.
Tauri, if the UI could tolerate being a web view. A Rust process for the heavy lifting and a browser rendering surface for the UI is a strictly better architecture than "Python process with a widget framework marshalling numpy arrays across signals." The multimedia indexer's per-modality preprocessing sat on a dozen native models with Python bindings, so a full port would have been expensive; a hybrid where Tauri talked to a small Python daemon over gRPC would have been the sane split.
Not Electron. The offline, air-gapped, single-laptop constraint that the indexer had makes a full Chromium runtime an odd fit. Tauri gets you the web UI without the 200 MB base install.
The version I shipped in 2023 was PyQt5 because the constraints were tight and the parts were available. It also worked. An officer walked up to it on demo day, pointed it at a folder, and got a search box that answered questions. That is the bar. Everything above is why the bar was harder to clear than the framework's tutorial page suggests.
See also
- System design of the SIH multimedia indexer, the backend this UI sat on top of.
More on the Multimedia File Indexer, Samsung PRISM 2023 Excellence Award, Smart India Hackathon 2022 winner adopted by MP Police, is on the projects page.