# 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. # # 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. # # 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(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') ``` - [ ] **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=/cfg QT_QPA_PLATFORM=offscreen python3 -I