diff options
| -rw-r--r-- | docs/superpowers/plans/2026-10-30-upscale-qt.md | 6 | ||||
| -rw-r--r-- | test_upscale_qt.py | 19 | ||||
| -rwxr-xr-x | upscale-qt | 99 |
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_'): @@ -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 |
