aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-30 19:01:00 +0100
committerDanilo M. <danix@danix.xyz>2026-10-30 19:01:00 +0100
commited52692899e18379ba762be2203e5df53f153ac4 (patch)
tree170a282939fcbaddb31ba5282f24036dc7cfb9d6 /docs/superpowers/plans
parentb9b19f7ecb9b8549986e2f6bd1fcec3914b68882 (diff)
downloadupscale-qt-ed52692899e18379ba762be2203e5df53f153ac4.tar.gz
upscale-qt-ed52692899e18379ba762be2203e5df53f153ac4.zip
Add implementation plan
Four tasks: tested helpers, compare view, main window, end-to-end run and docs. Feeds sd-cli an EXIF-oriented PNG so rotated photos stay upright. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers/plans')
-rw-r--r--docs/superpowers/plans/2026-10-30-upscale-qt.md793
1 files changed, 793 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-30-upscale-qt.md b/docs/superpowers/plans/2026-10-30-upscale-qt.md
new file mode 100644
index 0000000..9af7600
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-30-upscale-qt.md
@@ -0,0 +1,793 @@
+# upscale-qt Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Single-file PyQt6 app that upscales one image with `sd-cli`, lets imajev suggest the model, compares before/after with a slider wipe, and saves as new file or overwrites the original.
+
+**Architecture:** One executable script `upscale-qt` (no `.py`). Module-level pure helpers (model listing, answer matching, argument building, atomic overwrite) are unit tested; `CompareView` paints both images into one transform with a clipped divider; `Main` holds a list of `Job`s (one for now), drives a single `QProcess` for both external tools, and enables actions from state.
+
+**Tech Stack:** Python 3, PyQt6 (QtCore, QtGui, QtWidgets), external `sd-cli` and optional `llama-mtmd-cli`.
+
+**Spec:** `docs/superpowers/specs/2026-10-30-upscale-qt-design.md`
+
+**Deviation from spec, deliberate:** jobs carry no explicit state enum. "Result present" means done, and a running process disables Open/Upscale, so no other state is observable yet. Add the enum with batch mode.
+
+**EXIF orientation:** stb_image (used by sd-cli) and plain `QImage(path)` ignore EXIF orientation. `load()` reads with `QImageReader.setAutoTransform(True)` and writes an oriented `input.png` into the temp dir; that PNG feeds both sd-cli and imajev, so before, after and the overwritten file all share the upright orientation. Overwrite drops EXIF metadata (Qt does not copy it).
+
+---
+
+## File structure
+
+- Create `upscale-qt`: the whole app, executable, GPLv2-only header.
+- Create `test_upscale_qt.py`: plain-assert tests, loads `upscale-qt` with `SourceFileLoader`, runs offscreen with a throwaway config dir.
+- Modify `AGENTS.md` (Testing section), `README.md` (Usage).
+
+---
+
+### Task 1: Pure helpers
+
+**Files:**
+- Create: `upscale-qt`
+- Create: `test_upscale_qt.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+`test_upscale_qt.py`:
+
+```python
+# upscale-qt - tests for the non-GUI helpers and a GUI smoke test
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+import os
+import tempfile
+from importlib.machinery import SourceFileLoader
+from importlib.util import module_from_spec, spec_from_loader
+
+TMP = tempfile.mkdtemp(prefix='upscale-qt-test-')
+os.environ['QT_QPA_PLATFORM'] = 'offscreen'
+os.environ['XDG_CONFIG_HOME'] = os.path.join(TMP, 'config')
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+_loader = SourceFileLoader('upscale_qt', os.path.join(HERE, 'upscale-qt'))
+uq = module_from_spec(spec_from_loader('upscale_qt', _loader))
+_loader.exec_module(uq)
+
+from PyQt6.QtGui import QColor, QImage, QImageReader # noqa: E402
+
+NAMES = ['4x_detail.pth', 'RealESRGAN_x4plus.pth',
+ 'RealESRGAN_x4plus_anime_6B.pth']
+
+
+def test_match_model():
+ m = uq.match_model
+ # the longer stem must win over its own prefix
+ assert m('RealESRGAN_x4plus_anime_6B.pth', NAMES) == 'RealESRGAN_x4plus_anime_6B.pth'
+ assert m(' realesrgan_x4plus\n', NAMES) == 'RealESRGAN_x4plus.pth'
+ assert m('I suggest 4x_detail.', NAMES) == '4x_detail.pth'
+ assert m('no idea', NAMES) is None
+ assert m('', []) is None
+
+
+def test_list_models():
+ d = os.path.join(TMP, 'models')
+ os.makedirs(d)
+ for f in ('b.safetensors', 'a.pth', 'notes.txt', 'c.GGUF'):
+ open(os.path.join(d, f), 'w').close()
+ assert uq.list_models(d) == ['a.pth', 'b.safetensors', 'c.GGUF']
+ assert uq.list_models(os.path.join(TMP, 'missing')) == []
+
+
+def test_args():
+ a = uq.sd_args('/m/x.pth', '/in.png', '/out.png')
+ assert a == ['-M', 'upscale', '--upscale-model', '/m/x.pth',
+ '-i', '/in.png', '-o', '/out.png']
+ a = uq.llama_args('/m.gguf', '/mm.gguf', '/in.png', NAMES)
+ assert a[a.index('--image') + 1] == '/in.png'
+ prompt = a[a.index('-p') + 1]
+ assert all(n in prompt for n in NAMES)
+ assert 'noise' in prompt
+
+
+def test_save_over():
+ path = os.path.join(TMP, 'photo.JPG') # uppercase suffix on purpose
+ small = QImage(4, 4, QImage.Format.Format_RGB32)
+ small.fill(QColor('red'))
+ assert small.save(path, 'jpeg')
+ big = QImage(16, 16, QImage.Format.Format_RGB32)
+ big.fill(QColor('blue'))
+ assert uq.save_over(big, path)
+ assert QImage(path).width() == 16
+ assert bytes(QImageReader.imageFormat(path)) == b'jpeg'
+ assert not os.path.exists(path + '.part')
+
+
+if __name__ == '__main__':
+ for name, fn in list(globals().items()):
+ if name.startswith('test_'):
+ fn()
+ print('ok', name)
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+Run: `python3 test_upscale_qt.py`
+Expected: `FileNotFoundError` for `upscale-qt`.
+
+- [ ] **Step 3: Write the helpers**
+
+`upscale-qt` (then `chmod +x upscale-qt`):
+
+```python
+#!/usr/bin/env python3
+# upscale-qt - upscale an image with sd-cli and compare before/after
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+import os
+import sys
+from pathlib import Path
+
+from PyQt6.QtGui import QImage, QImageReader
+
+MODEL_EXTS = ('.pth', '.safetensors', '.gguf')
+JPEG_QUALITY = 95
+PROMPT = (
+ 'You pick an image upscaler model. Judge the subject (photo of people, '
+ 'landscape, anime, illustration) and also the source quality: noise, '
+ 'JPEG compression, blur. Noisy or compressed sources need a denoising '
+ 'model, clean sources a detail-preserving one. Answer with exactly one '
+ 'filename from this list and nothing else: {names}')
+
+
+def list_models(folder):
+ try:
+ return sorted(f for f in os.listdir(folder)
+ if f.lower().endswith(MODEL_EXTS))
+ except OSError:
+ return []
+
+
+def match_model(answer, names):
+ """Map imajev's free-text answer to one of names, or None.
+
+ Longest stem first, so 'x4plus_anime_6B' is not taken for 'x4plus'."""
+ answer = answer.lower()
+ for name in sorted(names, key=lambda n: len(Path(n).stem), reverse=True):
+ if Path(name).stem.lower() in answer:
+ return name
+ return None
+
+
+def sd_args(model, src, out):
+ return ['-M', 'upscale', '--upscale-model', model, '-i', src, '-o', out]
+
+
+def llama_args(model, mmproj, src, names):
+ return ['-m', model, '--mmproj', mmproj, '--image', src,
+ '--temp', '0', '-n', '64',
+ '-p', PROMPT.format(names=' '.join(names))]
+
+
+def save_over(img, path):
+ """Replace path with img in the original's format, never truncating it."""
+ fmt = bytes(QImageReader.imageFormat(path)).decode() or None
+ part = path + '.part'
+ if not img.save(part, fmt, JPEG_QUALITY if fmt == 'jpeg' else -1):
+ if os.path.exists(part):
+ os.remove(part)
+ return False
+ os.replace(part, path)
+ return True
+```
+
+- [ ] **Step 4: Run, expect pass**
+
+Run: `python3 test_upscale_qt.py`
+Expected: four `ok test_...` lines.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add upscale-qt test_upscale_qt.py
+git commit # "Add model matching, argument and overwrite helpers"
+```
+
+---
+
+### Task 2: CompareView
+
+**Files:**
+- Modify: `upscale-qt` (imports, new class after helpers)
+- Modify: `test_upscale_qt.py` (append test)
+
+- [ ] **Step 1: Write the failing test**
+
+Append before the `if __name__` block:
+
+```python
+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(200, 200)
+ 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')
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+Run: `python3 test_upscale_qt.py`
+Expected: `AttributeError: module 'upscale_qt' has no attribute 'CompareView'`.
+
+- [ ] **Step 3: Implement**
+
+Replace the Qt import lines at the top of `upscale-qt` with:
+
+```python
+from PyQt6.QtCore import QPointF, QRectF, Qt
+from PyQt6.QtGui import QColor, QImage, QImageReader, QPainter, QPen
+from PyQt6.QtWidgets import QWidget
+```
+
+Add after `save_over`:
+
+```python
+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
+```
+
+- [ ] **Step 4: Run, expect pass**
+
+Run: `python3 test_upscale_qt.py`
+Expected: five `ok` lines.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add upscale-qt test_upscale_qt.py
+git commit # "Add slider-wipe compare view"
+```
+
+---
+
+### Task 3: Main window
+
+**Files:**
+- Modify: `upscale-qt` (imports, `DEFAULTS`, `Job`, `SettingsDialog`, `Main`, `main()`)
+- Modify: `test_upscale_qt.py` (append smoke test)
+
+- [ ] **Step 1: Write the failing test**
+
+Append before the `if __name__` block:
+
+```python
+def test_main_window():
+ w = uq.Main()
+ w.cfg.setValue('imajev_model', '') # never run the real VLM in tests
+ w.cfg.setValue('upscalers_dir', os.path.join(TMP, 'models'))
+ w.reload_models()
+ assert not w.a_up.isEnabled() # no image yet
+ path = os.path.join(TMP, 'in.png')
+ img = QImage(8, 6, QImage.Format.Format_RGB32)
+ img.fill(QColor('green'))
+ img.save(path)
+ w.load(path)
+ assert len(w.jobs) == 1 and w.jobs[0].image.size() == img.size()
+ assert os.path.exists(w.jobs[0].input)
+ assert w.a_up.isEnabled() and not w.a_save.isEnabled()
+```
+
+`test_list_models` must run first: it creates the `models` folder this test points at (the runner keeps definition order).
+
+- [ ] **Step 2: Run, expect failure**
+
+Run: `python3 test_upscale_qt.py`
+Expected: `AttributeError: module 'upscale_qt' has no attribute 'Main'`.
+
+- [ ] **Step 3: Implement**
+
+Replace the Qt imports with:
+
+```python
+import shutil
+from dataclasses import dataclass
+
+from PyQt6.QtCore import QPointF, QProcess, QRectF, QSettings, QTemporaryDir, Qt
+from PyQt6.QtGui import (QAction, QColor, QImage, QImageReader, QKeySequence,
+ QPainter, QPen)
+from PyQt6.QtWidgets import (QApplication, QComboBox, QDialog,
+ QDialogButtonBox, QFileDialog, QFormLayout,
+ QLineEdit, QMainWindow, QMessageBox, QWidget)
+```
+
+(keep `import os`, `import sys`, `from pathlib import Path` above them).
+
+Add after `JPEG_QUALITY`:
+
+```python
+DEFAULTS = {
+ 'upscalers_dir': '/data/LLM-models/SD/upscalers',
+ 'imajev_model': '/data/LLM-models/SD/imajev/imajev-4b-Q4_K_M.gguf',
+ 'imajev_mmproj': '/data/LLM-models/SD/imajev/imajev-4b-mmproj-f16.gguf',
+}
+```
+
+Add after `save_over`, before `CompareView`:
+
+```python
+def tail(data, lines=15):
+ text = bytes(data).decode(errors='replace').replace('\r', '\n')
+ return '\n'.join([l for l in text.splitlines() if l.strip()][-lines:])
+
+
+@dataclass
+class Job:
+ path: str # file the user opened, overwrite target
+ image: QImage # original, EXIF orientation applied
+ input: str # oriented PNG fed to sd-cli and imajev
+ model: str = ''
+ out: str = ''
+ result: QImage | None = None
+```
+
+Add after `CompareView`:
+
+```python
+class SettingsDialog(QDialog):
+ def __init__(self, parent, cfg):
+ super().__init__(parent)
+ self.setWindowTitle('Settings')
+ self.cfg = cfg
+ self.edits = {k: QLineEdit(str(cfg.value(k, v))) for k, v in DEFAULTS.items()}
+ form = QFormLayout(self)
+ form.addRow('Upscalers folder', self.edits['upscalers_dir'])
+ form.addRow('imajev model (.gguf)', self.edits['imajev_model'])
+ form.addRow('imajev mmproj (.gguf)', self.edits['imajev_mmproj'])
+ bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok
+ | QDialogButtonBox.StandardButton.Cancel)
+ bb.accepted.connect(self.accept)
+ bb.rejected.connect(self.reject)
+ form.addRow(bb)
+
+ def accept(self):
+ for k, e in self.edits.items():
+ self.cfg.setValue(k, e.text().strip())
+ super().accept()
+
+
+class Main(QMainWindow):
+ def __init__(self, path=None):
+ super().__init__()
+ self.setWindowTitle('upscale-qt')
+ self.resize(1200, 800)
+ self.cfg = QSettings('upscale-qt', 'upscale-qt')
+ self.tmp = QTemporaryDir()
+ self.jobs = [] # one job for now, batch mode adds more
+ self.cur = None
+ self.runs = 0 # unique temp file names
+ self.what = None # 'suggest' or 'upscale' while a process runs
+ self.err = b''
+
+ self.view = CompareView()
+ self.setCentralWidget(self.view)
+ self.models = QComboBox()
+ self.models.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents)
+ self.models.setToolTip('Upscaler model')
+
+ tb = self.addToolBar('main')
+ tb.setMovable(False)
+
+ def act(text, slot, key=None):
+ a = QAction(text, self)
+ a.triggered.connect(slot)
+ if key is not None:
+ a.setShortcut(key)
+ tb.addAction(a)
+ return a
+
+ self.a_open = act('Open', self.open_dialog, QKeySequence.StandardKey.Open)
+ tb.addWidget(self.models)
+ self.a_up = act('Upscale', self.upscale, 'Ctrl+U')
+ self.a_save = act('Save As', self.save_as, QKeySequence.StandardKey.SaveAs)
+ self.a_over = act('Overwrite', self.overwrite)
+ act('Settings', self.settings)
+
+ self.proc = QProcess(self)
+ self.proc.finished.connect(self.proc_done)
+ self.proc.errorOccurred.connect(self.proc_error)
+ self.proc.readyReadStandardError.connect(self.proc_stderr)
+
+ self.reload_models()
+ if path:
+ self.load(path)
+
+ def get(self, key):
+ return str(self.cfg.value(key, DEFAULTS[key]))
+
+ def job(self):
+ return self.jobs[self.cur] if self.cur is not None else None
+
+ def names(self):
+ return [self.models.itemText(i) for i in range(self.models.count())]
+
+ def reload_models(self):
+ keep = self.models.currentText()
+ self.models.clear()
+ self.models.addItems(list_models(self.get('upscalers_dir')))
+ if keep:
+ self.models.setCurrentText(keep)
+ self.refresh()
+
+ def refresh(self):
+ busy = self.what is not None
+ job = self.job()
+ done = job is not None and job.result is not None
+ self.a_open.setEnabled(not busy)
+ self.models.setEnabled(not busy)
+ self.a_up.setEnabled(job is not None and not busy and self.models.count() > 0)
+ self.a_up.setToolTip('Upscale' if self.models.count() else
+ f'No models in {self.get("upscalers_dir")}, see Settings')
+ self.a_save.setEnabled(done and not busy)
+ self.a_over.setEnabled(done and not busy)
+
+ def open_dialog(self):
+ path, _ = QFileDialog.getOpenFileName(
+ self, 'Open image', str(self.cfg.value('last_dir', str(Path.home()))),
+ 'Images (*.png *.jpg *.jpeg *.webp *.bmp *.tif *.tiff);;All files (*)')
+ if path:
+ self.load(path)
+
+ def load(self, path):
+ r = QImageReader(path)
+ r.setAutoTransform(True)
+ img = r.read()
+ if img.isNull():
+ QMessageBox.warning(self, 'upscale-qt', f'Cannot read {path}:\n{r.errorString()}')
+ return
+ self.runs += 1
+ src = self.tmp.filePath(f'in{self.runs}.png')
+ if not img.save(src):
+ QMessageBox.warning(self, 'upscale-qt', f'Cannot write temp file {src}')
+ return
+ self.jobs = [Job(path, img, src)]
+ self.cur = 0
+ self.cfg.setValue('last_dir', str(Path(path).parent))
+ self.setWindowTitle(f'upscale-qt - {Path(path).name}')
+ self.view.set_images(img)
+ self.statusBar().showMessage(f'{img.width()}x{img.height()}')
+ self.refresh()
+ self.suggest()
+
+ def run(self, what, prog, args, msg):
+ self.what, self.err = what, b''
+ self.statusBar().showMessage(msg)
+ self.refresh()
+ self.proc.start(prog, args)
+
+ def suggest(self):
+ model, mmproj = self.get('imajev_model'), self.get('imajev_mmproj')
+ if not (self.names() and os.path.isfile(model) and os.path.isfile(mmproj)
+ and shutil.which('llama-mtmd-cli')):
+ return # suggestion is optional, skip quietly
+ self.run('suggest', 'llama-mtmd-cli',
+ llama_args(model, mmproj, self.job().input, self.names()),
+ 'imajev is looking at the image...')
+
+ def upscale(self):
+ job = self.job()
+ job.model = self.models.currentText()
+ self.runs += 1
+ job.out = self.tmp.filePath(f'out{self.runs}.png')
+ self.run('upscale', 'sd-cli',
+ sd_args(os.path.join(self.get('upscalers_dir'), job.model),
+ job.input, job.out),
+ f'Upscaling with {job.model}...')
+
+ def proc_stderr(self):
+ self.err = (self.err + bytes(self.proc.readAllStandardError()))[-8000:]
+ if self.what == 'upscale':
+ last = tail(self.err, 1)
+ if last:
+ self.statusBar().showMessage(last)
+
+ def proc_done(self, code, status):
+ what, self.what = self.what, None
+ ok = code == 0 and status == QProcess.ExitStatus.NormalExit
+ job = self.job()
+ if what == 'suggest':
+ answer = bytes(self.proc.readAllStandardOutput()).decode(errors='replace')
+ pick = match_model(answer, self.names()) if ok else None
+ if pick:
+ self.models.setCurrentText(pick)
+ self.statusBar().showMessage(f'imajev suggests {pick}')
+ else:
+ self.statusBar().showMessage('imajev gave no usable suggestion')
+ elif what == 'upscale':
+ img = QImage(job.out) if ok else QImage()
+ if img.isNull():
+ self.statusBar().showMessage('Upscale failed')
+ QMessageBox.warning(self, 'Upscale failed', tail(self.err) or f'exit code {code}')
+ else:
+ job.result = img
+ self.view.set_images(job.image, img)
+ self.statusBar().showMessage(
+ f'{job.image.width()}x{job.image.height()} -> '
+ f'{img.width()}x{img.height()} with {job.model}')
+ self.refresh()
+
+ def proc_error(self, err):
+ if err != QProcess.ProcessError.FailedToStart:
+ return # crashes also emit finished, handled there
+ what, self.what = self.what, None
+ prog = self.proc.program()
+ if what == 'upscale':
+ QMessageBox.warning(self, 'upscale-qt', f'Cannot start {prog}: is it installed?')
+ self.statusBar().showMessage(f'Cannot start {prog}')
+ self.refresh()
+
+ def save_as(self):
+ job = self.job()
+ src = Path(job.path)
+ path, _ = QFileDialog.getSaveFileName(
+ self, 'Save upscaled image', str(src.with_name(f'{src.stem}_x4{src.suffix}')),
+ 'Images (*.png *.jpg *.jpeg *.webp *.tif *.tiff)')
+ if not path:
+ return
+ if job.result.save(path, None, JPEG_QUALITY):
+ self.statusBar().showMessage(f'Saved {path}')
+ else:
+ QMessageBox.warning(self, 'upscale-qt', f'Cannot save {path}')
+
+ def overwrite(self):
+ job = self.job()
+ r = job.result
+ if QMessageBox.question(
+ self, 'Overwrite',
+ f'Replace {job.path}\nwith the {r.width()}x{r.height()} upscaled image?\n'
+ 'This cannot be undone.') != QMessageBox.StandardButton.Yes:
+ return
+ if save_over(r, job.path):
+ self.statusBar().showMessage(f'Overwrote {job.path}')
+ else:
+ QMessageBox.warning(self, 'upscale-qt', f'Cannot write {job.path}, original untouched')
+
+ def settings(self):
+ if SettingsDialog(self, self.cfg).exec():
+ self.reload_models()
+
+ def closeEvent(self, e):
+ if self.proc.state() != QProcess.ProcessState.NotRunning:
+ self.proc.kill()
+ self.proc.waitForFinished(2000)
+ super().closeEvent(e)
+
+
+def main():
+ app = QApplication(sys.argv)
+ w = Main(sys.argv[1] if len(sys.argv) > 1 else None)
+ w.show()
+ sys.exit(app.exec())
+
+
+if __name__ == '__main__':
+ main()
+```
+
+- [ ] **Step 4: Run, expect pass**
+
+Run: `python3 test_upscale_qt.py`
+Expected: six `ok` lines.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add upscale-qt test_upscale_qt.py
+git commit # "Add main window: open, suggest, upscale, save as, overwrite"
+```
+
+---
+
+### Task 4: Real run, docs
+
+**Files:**
+- Modify: `AGENTS.md` (Testing)
+- Modify: `README.md` (Usage)
+
+- [ ] **Step 1: End-to-end headless run with the real tools**
+
+Script in the scratchpad (not the repo), run with
+`XDG_CONFIG_HOME=<scratch>/cfg QT_QPA_PLATFORM=offscreen python3 -I <script>`:
+
+```python
+import os, sys
+from importlib.machinery import SourceFileLoader
+from importlib.util import module_from_spec, spec_from_loader
+from PyQt6.QtCore import QEventLoop
+from PyQt6.QtGui import QImage, QColor
+from PyQt6.QtWidgets import QApplication
+
+loader = SourceFileLoader('uq', os.path.expanduser('~/Programming/GIT/upscale-qt/upscale-qt'))
+uq = module_from_spec(spec_from_loader('uq', loader)); loader.exec_module(uq)
+app = QApplication([])
+w = uq.Main(); w.resize(1000, 700); w.show()
+
+def wait():
+ loop = QEventLoop(); w.proc.finished.connect(loop.quit); loop.exec()
+ w.proc.finished.disconnect(loop.quit)
+
+src = sys.argv[1] # a ~512 px test image
+w.load(src); wait() # imajev suggestion
+print('suggested:', w.models.currentText(), '|', w.statusBar().currentMessage())
+w.upscale(); wait()
+r = w.jobs[0].result
+print('result:', r.width(), r.height(), w.statusBar().currentMessage())
+w.grab().save(sys.argv[2])
+```
+
+Expected: a suggestion from the folder, result 4x the input size, screenshot shows the divider with before on the left. Check the screenshot by eye.
+
+- [ ] **Step 2: Manual check by the user**
+
+`./upscale-qt <photo>`: drag divider, wheel zoom, drag pan, double-click fit, Save As, Overwrite on a copy.
+
+- [ ] **Step 3: Docs**
+
+`AGENTS.md`, replace `No test suite yet.` with:
+
+```markdown
+ python3 test_upscale_qt.py
+
+Plain asserts for the helpers plus offscreen smoke tests of `CompareView`
+and `Main`. The test sets `XDG_CONFIG_HOME` to a temp dir and blanks the
+imajev path, so it never touches the real settings or runs the VLM. For an
+end-to-end check with the real tools, load the script with
+`SourceFileLoader` under `QT_QPA_PLATFORM=offscreen`, call `load()` and
+`upscale()`, wait on `proc.finished`, and `grab()` a screenshot. Use a
+small image: about 25 s per input megapixel.
+```
+
+`README.md`, replace the Usage section body with:
+
+```markdown
+ ./upscale-qt [image]
+
+- Open an image (Ctrl+O or as argument). If imajev is configured it looks at the image and preselects an upscaler, you can always pick another.
+- Upscale (Ctrl+U) runs `sd-cli`. Expect roughly 25 seconds per megapixel of input; the result is 4x in each direction.
+- The view shows before left of the divider and after right of it. Drag the divider to wipe, drag elsewhere to pan, mouse wheel to zoom, double-click to fit.
+- Save As (Ctrl+Shift+S) writes a new file; Overwrite replaces the original after a confirmation, in the same format. EXIF metadata is not kept.
+- Settings holds the upscalers folder and the imajev model and mmproj paths.
+```
+
+- [ ] **Step 4: Commit and push**
+
+```bash
+git add AGENTS.md README.md
+git commit # "Document usage and testing"
+git push
+```