diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-10-30-upscale-qt.md | 793 |
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 +``` |
