From a PyQt6 license crisis to a fully animated PySide6 file browser that doesn't explode on Wayland — plus non-blocking batch processing and designing AI evasion.
Series recap: DotScramble is a desktop privacy tool that blurs, pixelates, and redacts sensitive regions in images. Part 1 covered the core image processing pipeline. Part 2 covered the licensing system using Ed25519 asymmetric signing. This post covers what happened when I tried to make the UI actually good.
The Crisis I Didn't See Coming
I was sitting with two projects — DotScramble and DotGhostBoard — both shipping under Apache 2.0. I had already migrated both once before, from Tkinter to PyQt6, and that migration wasn't optional — three things made Tkinter untenable:
- Broken RTL rendering — Arabic letters showed up disconnected and reversed. Tkinter's text engine has no real bidi support, and there's no clean fix for it.
- No real Wayland support — once distros stopped defaulting to X11, window geometry, focus handling, and basic resize behavior in Tkinter became unreliable.
- Weak drag-and-drop — Tkinter's native DnD is limited and inconsistent across platforms. Dropping a file onto the canvas was a coin flip whether it registered.
PyQt6 fixed all three. Here's the same Controls panel in both: Tkinter on the right, PySide6 (the version that came later, more on that below) on the left.
So, both projects were now using PyQt6 for the GUI layer. That migration solved the immediate problems. It also planted the seed for a different crisis entirely.
Then I read the license again.
PyQt6 → GPL v3 (copyleft)
Apache 2.0 → permissive
GPL v3 + Apache 2.0 = 💥 license conflict
GPL v3 is copyleft. That means any project that uses PyQt6 must also be GPL v3. Apache 2.0 has different distribution requirements. You can't ship both at once — they're incompatible.
My options:
| Option | Trade-off |
|---|---|
| Switch to GPL v3 | Lose permissive licensing flexibility |
| Dual license | Complicates contribution model |
| Migrate to PySide6 | LGPL v3 — compatible with Apache 2.0 ✅ |
PySide6 is the official Qt binding maintained by The Qt Company itself. LGPL v3 means closed-source and commercial use is fine as long as you dynamically link. For a desktop app, that's basically free.
The API difference turned out to be mostly naming:
# PyQt6
from PyQt6.QtCore import pyqtSignal, pyqtSlot
my_signal = pyqtSignal(str)
# PySide6
from PySide6.QtCore import Signal, Slot
my_signal = Signal(str)
The bigger gotcha is strict enums. PyQt6 enforces them everywhere. PySide6 is more lenient — which means some PyQt6 code actually runs stricter than PySide6, so migrating in that direction has fewer surprises than going the other way.
Two projects, about two hours each. Migration done.
The Wayland Window Snap Problem
Once I was on PySide6, I ran into the window management issue I'd been avoiding: snap doesn't work properly on Wayland.
Chromium snaps fine. My Qt app doesn't. Here's why that's not a fair comparison — Chromium uses its own window management layer entirely. Qt apps on Wayland communicate through the compositor protocol, and there are three separate issues hiding inside "snap is broken":
Problem 1: setMaximumWidth on the sidebar kills snap layouts
When the window is snapped to half-screen (~700px), the sidebar demands 350px and leaves the canvas only 330px. The fix is giving it a smaller ceiling:
# Before
self.scroll_area.setMinimumWidth(350)
self.scroll_area.setMaximumWidth(450) # too rigid
# After
self.scroll_area.setMinimumWidth(260)
self.scroll_area.setMaximumWidth(400)
Problem 2: Canvas resize fires hundreds of times during snap animation
ImageCanvas.resizeEvent calls display_image() on every frame of the snap animation, causing visible flicker. The fix is a debounce timer:
# In __init__
self._resize_timer = QTimer()
self._resize_timer.setSingleShot(True)
self._resize_timer.timeout.connect(self._do_canvas_resize)
def on_canvas_resize(self):
self._resize_timer.start(60) # 60ms debounce
def _do_canvas_resize(self):
if self.processed_image is not None:
self.display_image(self.processed_image)
elif self.original_image is not None:
self.display_image(self.original_image)
else:
self.canvas.update()
Problem 3: restore_window_state on Wayland
Window geometry on Wayland isn't available until after the window is actually shown. Calling resize() and move() in __init__ does nothing reliable. The fix is to delay restoration:
def showEvent(self, event):
super().showEvent(event)
if not hasattr(self, '_state_restored'):
self._state_restored = True
self.restore_window_state()
self.restore_user_settings()
Also save and restore the splitter state across sessions:
# on close
self.db_manager.save_setting(
'splitter_state',
self.splitter.saveState().toHex().data().decode(),
'window'
)
# on restore
splitter_bytes = self.db_manager.get_setting('splitter_state')
if splitter_bytes:
from PySide6.QtCore import QByteArray
self.splitter.restoreState(QByteArray.fromHex(splitter_bytes.encode()))
Note: The KDE/GNOME snap grid that appears when hovering the maximize button is a compositor feature, not something Qt apps can opt into.
Meta + arrow keyis your friend here.
Replacing the Native File Dialog
The file picker looked like this:
GTK. White. On a dark cyberpunk app. No.
The typical fix is QFileDialog.Option.DontUseNativeDialog — but that gives you the Qt default dialog, which also ignores your stylesheet. I wanted something that actually fits.
So I built one from scratch.
The design
┌─ 📷 Select Image ─────────────── [🔍 Filter] [✕] ─┐
├───────────────────────────────────────────────────────┤
│ QUICK ACCESS │ Home › Pictures │ Preview │
│ │ ┌────┐ ┌────┐ ┌────┐ │ [image] │
│ 🏠 Home │ │img │ │img │ │img │ │ │
│ 🖼️ Pictures │ └────┘ └────┘ └────┘ │ file.jpg │
│ ⬇️ Downloads │ ┌────┐ ┌────┐ │ 1920×1080│
│ 🖥️ Desktop │ │📁 │ │img │ │ 2.3 MB │
│ │ └────┘ └────┘ │ │
├───────────────────────────────────────────────────────┤
│ /home/kareem/Pictures [Cancel] [ Open ] │
└───────────────────────────────────────────────────────┘
Three animations:
- Dialog opens with a fade-in
- File tiles appear with a staggered cascade (each 18ms apart)
- Preview panel slides in from the right when you select an image
The Wayland painter trap
My first implementation used QGraphicsOpacityEffect for the tile animations. On Linux, the terminal filled up with this:
QPainter::begin: A paint device can only be painted by one painter at a time.
QWidgetEffectSourcePrivate::pixmap: Painter not active
QPainter::save: Painter not active
...
Hundreds of lines. The dialog still opened, but something was clearly wrong.
Here's what happens: when you put a QGraphicsOpacityEffect on a widget that has a custom paintEvent, Qt's effect pipeline tries to grab the widget's pixels using an internal painter — while your paintEvent is already holding a painter on the same device. On Wayland, this conflict is loud.
The fix: don't use QGraphicsOpacityEffect on widgets with custom paint. Instead, expose opacity as a Property and apply it directly inside the painter:
class FileTile(QWidget):
# ── opacity as animatable property ─────────────────────────
def _get_tile_opacity(self): return self._tile_opacity
def _set_tile_opacity(self, v):
self._tile_opacity = max(0.0, min(1.0, v))
self.update()
tile_opacity = Property(float, _get_tile_opacity, _set_tile_opacity)
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
# Single painter, opacity baked in — no effect pipeline
p.setOpacity(self._tile_opacity)
# ... rest of paint
And in the grid loader, staggered reveal without effects:
for i, tile in enumerate(self._tiles):
tile._tile_opacity = 0.0
def _fade(t=tile):
a = QPropertyAnimation(t, b'tile_opacity', t)
a.setDuration(200)
a.setStartValue(0.0)
a.setEndValue(1.0)
a.setEasingCurve(QEasingCurve.Type.OutCubic)
a.start()
t._reveal_anim = a # keep ref — GC would kill it otherwise
QTimer.singleShot(min(i * 18, 300), _fade)
The t._reveal_anim = a line is important. If you don't keep a reference to the QPropertyAnimation, Python's garbage collector kills it mid-animation and the tile stays frozen at whatever opacity it reached.
The dialog open animation on Wayland
QGraphicsOpacityEffect on the container had the same painter conflict. The solution is setWindowOpacity() — compositor-level, no painter involved:
# But first, check if the platform supports it
import os
_is_wayland = (
os.environ.get('WAYLAND_DISPLAY') is not None or
QApplication.platformName() == 'wayland'
)
if not _is_wayland:
self.setWindowOpacity(0.0)
anim = QPropertyAnimation(self, b'windowOpacity', self)
anim.setDuration(220)
anim.setStartValue(0.0)
anim.setEndValue(1.0)
anim.setEasingCurve(QEasingCurve.Type.OutCubic)
anim.start()
Wayland compositors spam This plugin does not support setting window opacity to the terminal otherwise. The dialog appears instantly on Wayland — acceptable behavior.
The QScrollArea double-delete crash
First run:
RuntimeError: libshiboken: Internal C++ object (QWidget) already deleted.
In _load_grid() I was doing:
old = self.grid_widget
self.grid_widget = QWidget()
self.scroll.setWidget(self.grid_widget)
old.deleteLater() # 💥 Qt already deleted it
From Qt docs: "The widget becomes a child of the scroll area, and will be **destroyed* when a new widget is set."*
setWidget() already deletes the old widget. Calling deleteLater() on an already-deleted C++ object is a crash. The fix: remove those two lines entirely.
Cross-platform bookmarks
The original sidebar was hardcoded for Linux:
BOOKMARKS = [
('🏠', 'Home', str(Path.home())),
('🖼️', 'Pictures', str(Path.home() / 'Pictures')),
('⬇️', 'Downloads', str(Path.home() / 'Downloads')),
('🖥️', 'Desktop', str(Path.home() / 'Desktop')),
('📂', 'Root', '/'), # doesn't exist on Windows
]
On a French Linux system, ~/Desktop might be ~/Bureau. On Windows, C:\Users\kareem\Pictures might be somewhere else entirely. The fix uses XDG on Linux and platform-specific approaches elsewhere:
def _read_xdg_user_dirs() -> dict:
"""Parse ~/.config/user-dirs.dirs — handles localized folder names."""
result = {}
config = Path.home() / '.config' / 'user-dirs.dirs'
if not config.exists():
return result
try:
with open(config) as f:
for line in f:
line = line.strip()
if line.startswith('#') or '=' not in line:
continue
key, _, val = line.partition('=')
key = key.replace('XDG_', '').replace('_DIR', '')
val = val.strip().strip('"').replace('$HOME', str(Path.home()))
if val:
result[key] = val
except Exception:
pass
return result
def get_bookmarks() -> list[tuple[str, str, str]]:
home = Path.home()
marks = [('🏠', 'Home', str(home))]
if sys.platform == 'win32':
# Windows drives + user folders
import string
candidates = [
('🖼️', 'Pictures', os.environ.get('USERPROFILE', str(home)) + '\\Pictures'),
('⬇️', 'Downloads', str(home / 'Downloads')),
('🖥️', 'Desktop', str(home / 'Desktop')),
('📄', 'Documents', os.environ.get('DOCUMENTS', str(home / 'Documents'))),
]
for icon, label, path in candidates:
if os.path.exists(path):
marks.append((icon, label, path))
for letter in string.ascii_uppercase:
drive = f'{letter}:\\'
if os.path.exists(drive):
marks.append(('💾', f'{letter}:', drive))
elif sys.platform == 'darwin':
candidates = [
('🖼️', 'Pictures', home / 'Pictures'),
('⬇️', 'Downloads', home / 'Downloads'),
('🖥️', 'Desktop', home / 'Desktop'),
('📂', 'Volumes', Path('/Volumes')),
]
for icon, label, path in candidates:
if Path(path).exists():
marks.append((icon, label, str(path)))
else:
# Linux — XDG first, fallback to English names
xdg = _read_xdg_user_dirs()
candidates = [
('🖼️', 'Pictures', xdg.get('PICTURES', str(home / 'Pictures'))),
('⬇️', 'Downloads', xdg.get('DOWNLOAD', str(home / 'Downloads'))),
('🖥️', 'Desktop', xdg.get('DESKTOP', str(home / 'Desktop'))),
('📄', 'Documents', xdg.get('DOCUMENTS', str(home / 'Documents'))),
('🎵', 'Music', xdg.get('MUSIC', str(home / 'Music'))),
('📂', 'Root', '/'),
]
for icon, label, path in candidates:
if os.path.exists(path):
marks.append((icon, label, path))
return marks
Pyrefly IDE users: The
win32anddarwinbranches will appear dimmed/dead on Linux because Pyrefly does platform narrowing — static analysis treats unreachable-on-current-platform code as dead. The code is correct. Add_PLATFORM = sys.platformand check against that variable to suppress it, or setplatform = "all"inpyrefly.toml.
Thumbnail loading
Thumbnails load in a QThreadPool so scrolling stays smooth:
class ThumbLoader(QRunnable):
def run(self):
try:
from PIL import Image
img = Image.open(self.path)
img.thumbnail((140, 140), Image.LANCZOS)
img = img.convert('RGBA')
data = img.tobytes('raw', 'RGBA')
qimg = QImage(data, img.width, img.height,
QImage.Format.Format_RGBA8888)
qimg._pil_data = data # keep alive until QImage is done
self.signals.done.emit(self.path, QPixmap.fromImage(qimg))
except Exception:
pass
Using Pillow instead of QImage directly avoids qt.gui.imageio.jpeg: Corrupt JPEG data warnings for slightly malformed JPEGs — Pillow is more lenient and the visual difference is zero.
Multithreading in Qt: Non-Blocking Batch Processing
Adding a batch processing feature poses a major UI challenge. If you try to process 100 images sequentially on the main GUI thread, the Qt event loop locks up. The application window freezes, visual elements stop repainting, and the operating system eventually prompts the user to force-close the "Not Responding" application.
To solve this, the processing must happen asynchronously in the background. But there is a catch: Qt widgets are not thread-safe. You cannot modify GUI elements (like updating a progress bar or changing a label's text) directly from a background worker thread without triggering random segfaults.
Thread-Safe Communication via Custom Signals
The standard way to bridge this gap in PySide6 is using custom Signals. Since PySide6 signals are inherently thread-safe when using a queued connection, emitting a signal from a worker thread safely marshals the event back to the main thread's event loop.
First, we define a small QObject to hold our signals:
class BatchSignals(QObject):
"""Signals for thread-safe UI updates from the processing worker thread"""
progress = Signal(int, int, dict)
error = Signal(str, str)
complete = Signal()
Inside our dialog initialization, we connect these signals to the GUI update slots:
self.signals = BatchSignals()
self.signals.progress.connect(self.on_progress)
self.signals.error.connect(self.on_error)
self.signals.complete.connect(self.on_complete)
The Worker Thread Lifecycle
When the user clicks "Start", we launch a standard Python threading.Thread as a daemon thread. We pass nested callbacks that do nothing but emit our thread-safe signals:
def start_processing(self):
# ... read UI values and settings ...
self.processing = True
self.update_ui_state(processing=True)
def process_worker():
def progress_cb(current, total, result):
self.signals.progress.emit(current, total, result)
def error_cb(file_path, error_msg):
self.signals.error.emit(file_path, error_msg)
self.batch_processor.process_batch(
valid_files,
output_dir,
settings,
progress_callback=progress_cb,
error_callback=error_cb,
is_cancelled=lambda: not self.processing
)
self.signals.complete.emit()
thread = threading.Thread(target=process_worker, daemon=True)
thread.start()
Graceful Cancellation and License Gating
The is_cancelled parameter passes a lambda lambda: not self.processing down to the batch loop. Inside the processing loop, BatchProcessor checks this condition on every iteration:
for i, input_path in enumerate(input_paths):
if is_cancelled and is_cancelled():
break # Exit early if the user clicked "Stop"
# ... process image ...
Additionally, because batch processing is a resource-intensive "PRO" feature, we gate the GUI. If the license manager detects that the Max tier is not activated, the window restricts input selection:
FREE_FILE_LIMIT = 3
def _is_max(self):
return self.license_manager.is_max_activated
# When adding files:
if not self._is_max() and len(self.selected_files) > self.FREE_FILE_LIMIT:
QMessageBox.warning(self, "Limit Reached", "Free tier is limited to 3 files.")
# ... slice list to limit ...
This keeps the GUI perfectly responsive, gives the user real-time feedback with zero crashes, and ties in the licensing system we built in Part 2.
What's Next: Designing AI Evasion (Adversarial Perturbation)
DotScramble can now blur, pixelate, or black out sensitive regions like faces. While that visual degradation is enough to prevent a human from recognizing the subject, it turns out it is not enough for modern AI-driven facial recognition systems.
The Threat Model: AI Face Recognition
Deep learning models extract abstract feature vectors from faces. Even if a face is heavily blurred or partially pixelated, the neural network can still reconstruct or match the underlying features with surprisingly high confidence. In my tests, a blurred face run through an open-source face recognition model still yielded a cosine similarity match of over 0.7 against the original unblurred image—well above typical detection thresholds.
To achieve true privacy in the age of scraping pipelines and surveillance, DotScramble needs a second line of defense: adversarial perturbations.
Fooling the Neural Network
Adversarial machine learning involves adding a mathematically optimized, imperceptible noise pattern to an image. To a human, the image looks virtually unchanged, but to a neural network, the features are completely scrambled, leading to an incorrect classification or a failed match:
The Black-Box Challenge (SPSA)
Most standard adversarial attack methods (like FGSM) are "white-box" attacks—they require full access to the target model's architecture, weights, and gradients so you can perform backpropagation.
For DotScramble, we don't know what model the end-user (or scraper) will use. We need a black-box attack. To solve this, we can design an estimation engine using Simultaneous Perturbation Stochastic Approximation (SPSA). SPSA approximates gradients by evaluating the loss function at just two perturbed points per iteration, regardless of parameter count:
This allows us to optimize adversarial perturbations on the fly using a local proxy model, ensuring the face remains unrecognizable to AI systems.
Bugs I Hit and What They Actually Were
| Error | Looked Like | Actually Was |
|---|---|---|
RuntimeError: Internal C++ object already deleted |
Memory bug |
deleteLater() on widget Qt already deleted via setWidget()
|
QPainter: A paint device can only be painted by one painter at a time |
Concurrent threads |
QGraphicsOpacityEffect + custom paintEvent on same widget |
This plugin does not support setting window opacity |
Qt bug |
setWindowOpacity() not supported on Wayland compositor |
Corrupt JPEG data: 1 extraneous bytes before marker 0xd9 |
Broken images | Cameras that write slightly nonstandard JPEG — images load fine |
| Sidebar items dimmed in IDE | Code wrong | Pyrefly platform-narrowing dead code detection |
Key Takeaways
On Wayland: Don't use QGraphicsOpacityEffect on widgets with custom paintEvent. The effect pipeline creates a painter conflict. Use p.setOpacity() inside your custom paint instead.
On Qt object lifetime: QScrollArea::setWidget() destroys the previous widget. Don't call deleteLater() on it afterward. Same pattern appears in QStackedWidget, QDockWidget, and others.
On file dialogs: The native file dialog on Linux is GTK and ignores your stylesheet. Building a custom one is maybe 400 lines and gives you full control over layout, animations, and behavior — worth it for a premium feel.
On Qt multithreading: Never update GUI elements directly from a background thread; it leads to memory corruption and instant crashes. Use QObject with Signals to safely bridge communication from the worker thread to the main GUI thread.
On adversarial perturbations: Blurring or pixelating a face might fool a human, but modern neural networks can still recognize it. Adversarial noise slightly alters pixels to break the model's feature extraction while keeping the image recognizable to humans.
On black-box optimization: When you don't have access to model weights or gradients, black-box optimization algorithms like SPSA (Simultaneous Perturbation Stochastic Approximation) let you estimate gradients efficiently with just a few forward passes.
DotScramble is part of DotSuite — a collection of developer privacy tools. Part 4 covers the AI Evasion system — why blurring alone isn't enough against modern face recognition, and how SPSA black-box adversarial perturbations add a second layer of protection that works without access to any model weights.


Top comments (0)