aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-30 19:03:12 +0100
committerDanilo M. <danix@danix.xyz>2026-10-30 19:03:12 +0100
commitfd5eb858f89c7a39506abb2aeea8756fd1228763 (patch)
tree7cef158fb54ded3183ba3eab775f43c8286bc104
parent71e6979efcb6ac9593a4618d964504a7f44d490f (diff)
downloadupscale-qt-fd5eb858f89c7a39506abb2aeea8756fd1228763.tar.gz
upscale-qt-fd5eb858f89c7a39506abb2aeea8756fd1228763.zip
Add slider-wipe compare view
Before and after share one transform; before is drawn into after's rectangle so it is never copied at 4x size. Drag the divider to wipe, drag elsewhere to pan, wheel zooms around the cursor, double-click fits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--docs/superpowers/plans/2026-10-30-upscale-qt.md6
-rw-r--r--test_upscale_qt.py19
-rwxr-xr-xupscale-qt99
3 files changed, 120 insertions, 4 deletions
diff --git a/docs/superpowers/plans/2026-10-30-upscale-qt.md b/docs/superpowers/plans/2026-10-30-upscale-qt.md
index 9af7600..8254308 100644
--- a/docs/superpowers/plans/2026-10-30-upscale-qt.md
+++ b/docs/superpowers/plans/2026-10-30-upscale-qt.md
@@ -241,12 +241,12 @@ def test_compare_view():
after = QImage(40, 40, QImage.Format.Format_RGB32)
after.fill(QColor('blue'))
v = uq.CompareView()
- v.resize(200, 200)
+ v.resize(400, 400) # at or above the minimum size
v.set_images(before, after)
shot = v.grab().toImage()
# left of the divider shows before, right shows after
- assert shot.pixelColor(50, 100) == QColor('red')
- assert shot.pixelColor(150, 100) == QColor('blue')
+ assert shot.pixelColor(100, 200) == QColor('red')
+ assert shot.pixelColor(300, 200) == QColor('blue')
```
- [ ] **Step 2: Run, expect failure**
diff --git a/test_upscale_qt.py b/test_upscale_qt.py
index 526dc37..439807c 100644
--- a/test_upscale_qt.py
+++ b/test_upscale_qt.py
@@ -77,6 +77,25 @@ def test_save_over():
assert not os.path.exists(path + '.part')
+from PyQt6.QtWidgets import QApplication # noqa: E402
+
+APP = QApplication.instance() or QApplication([])
+
+
+def test_compare_view():
+ before = QImage(10, 10, QImage.Format.Format_RGB32)
+ before.fill(QColor('red'))
+ after = QImage(40, 40, QImage.Format.Format_RGB32)
+ after.fill(QColor('blue'))
+ v = uq.CompareView()
+ v.resize(400, 400) # at or above the minimum size
+ v.set_images(before, after)
+ shot = v.grab().toImage()
+ # left of the divider shows before, right shows after
+ assert shot.pixelColor(100, 200) == QColor('red')
+ assert shot.pixelColor(300, 200) == QColor('blue')
+
+
if __name__ == '__main__':
for name, fn in list(globals().items()):
if name.startswith('test_'):
diff --git a/upscale-qt b/upscale-qt
index 5baa355..41e3f5a 100755
--- a/upscale-qt
+++ b/upscale-qt
@@ -19,7 +19,9 @@ import os
import sys
from pathlib import Path
-from PyQt6.QtGui import QImage, QImageReader
+from PyQt6.QtCore import QPointF, QRectF, Qt
+from PyQt6.QtGui import QColor, QImage, QImageReader, QPainter, QPen
+from PyQt6.QtWidgets import QWidget
MODEL_EXTS = ('.pth', '.safetensors', '.gguf')
JPEG_QUALITY = 95
@@ -70,3 +72,98 @@ def save_over(img, path):
return False
os.replace(part, path)
return True
+
+
+class CompareView(QWidget):
+ """Before left of a draggable divider, after right, one shared transform.
+
+ Before is drawn into after's rectangle, so it is scaled on paint, never
+ copied at 4x size."""
+
+ GRAB = 8 # px around the divider that start a divider drag
+
+ def __init__(self):
+ super().__init__()
+ self.before = self.after = None
+ self.split = 0.5
+ self.scale = 1.0
+ self.offset = QPointF()
+ self.drag = None
+ self.setMouseTracking(True)
+ self.setMinimumSize(400, 300)
+
+ def set_images(self, before, after=None):
+ self.before, self.after = before, after
+ self.fit()
+
+ def ref(self):
+ return self.after if self.after is not None else self.before
+
+ def fit(self):
+ img = self.ref()
+ if img is not None:
+ self.scale = min(self.width() / img.width(),
+ self.height() / img.height())
+ self.offset = QPointF((self.width() - img.width() * self.scale) / 2,
+ (self.height() - img.height() * self.scale) / 2)
+ self.update()
+
+ def divider_x(self):
+ return self.split * self.width()
+
+ def paintEvent(self, e):
+ p = QPainter(self)
+ p.fillRect(self.rect(), QColor(40, 40, 40))
+ img = self.ref()
+ if img is None:
+ return
+ if self.scale >= 0.5:
+ # ponytail: smooth only near 1:1, smoothing a 160 MP image
+ # scaled down to screen size stalls the paint
+ p.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
+ target = QRectF(self.offset.x(), self.offset.y(),
+ img.width() * self.scale, img.height() * self.scale)
+ if self.after is None:
+ p.drawImage(target, self.before)
+ return
+ x = self.divider_x()
+ p.setClipRect(QRectF(0, 0, x, self.height()))
+ p.drawImage(target, self.before)
+ p.setClipRect(QRectF(x, 0, self.width() - x, self.height()))
+ p.drawImage(target, self.after)
+ p.setClipping(False)
+ p.setPen(QPen(QColor('white'), 2))
+ p.drawLine(QPointF(x, 0), QPointF(x, self.height()))
+
+ def near_divider(self, pos):
+ return self.after is not None and abs(pos.x() - self.divider_x()) < self.GRAB
+
+ def mousePressEvent(self, e):
+ self.drag = 'split' if self.near_divider(e.position()) else e.position()
+
+ def mouseMoveEvent(self, e):
+ pos = e.position()
+ if self.drag == 'split':
+ self.split = min(max(pos.x() / self.width(), 0.0), 1.0)
+ elif self.drag is not None:
+ self.offset += pos - self.drag
+ self.drag = pos
+ self.setCursor(Qt.CursorShape.SplitHCursor if self.drag == 'split'
+ or self.near_divider(pos) else Qt.CursorShape.ArrowCursor)
+ self.update()
+
+ def mouseReleaseEvent(self, e):
+ self.drag = None
+
+ def mouseDoubleClickEvent(self, e):
+ self.fit()
+
+ def wheelEvent(self, e):
+ f = 1.25 if e.angleDelta().y() > 0 else 0.8
+ pos = e.position()
+ self.offset = pos - (pos - self.offset) * f # zoom around the cursor
+ self.scale *= f
+ self.update()
+
+ def resizeEvent(self, e):
+ self.fit() # ponytail: resize resets zoom, keep the view center if it annoys