"""PyQt5 GUI: open an image, tune the conversion with a live C64 preview, export a disk image.""" from __future__ import annotations import os import sys import traceback import numpy as np from PyQt5 import QtCore, QtGui, QtWidgets from . import gallery, imageprep from .convert import MODES, convert_image, render_preview from .diskimage import FORMATS, have_c1541, have_vice, launch_in_vice from .exporter import export_disk from .palette import COLOR_NAMES from .viewer.assemble import have_xa MODE_CHOICES = ["auto", *MODES] DITHER_CHOICES = ["bayer", "floyd", "atkinson", "stucki", "jarvis", "none"] PALETTE_CHOICES = ["colodore", "pepto"] ASPECT_CHOICES = ["fit", "fill", "stretch"] BASE_CHOICES = ["grayscale", *COLOR_NAMES] # for mono mode def numpy_to_pixmap(rgb: np.ndarray) -> QtGui.QPixmap: rgb = np.ascontiguousarray(rgb) h, w, _ = rgb.shape img = QtGui.QImage(rgb.data, w, h, 3 * w, QtGui.QImage.Format_RGB888) return QtGui.QPixmap.fromImage(img.copy()) class ConvertWorker(QtCore.QThread): """Runs one conversion off the UI thread.""" done = QtCore.pyqtSignal(object) # Conversion failed = QtCore.pyqtSignal(str) def __init__(self, path, params): super().__init__() self.path = path self.params = params def run(self): try: p = self.params prep = imageprep.PrepOptions( aspect=p["aspect"], brightness=p["brightness"], contrast=p["contrast"], saturation=p["saturation"], gamma=p["gamma"], ) conv = convert_image( self.path, mode=p["mode"], palette_name=p["palette"], dither_mode=p["dither"], intensive=p["intensive"], prep_opt=prep, base_color=p["base_color"], ) self.done.emit(conv) except Exception: self.failed.emit(traceback.format_exc()) class GalleryWorker(QtCore.QThread): """Renders every Mode x Palette x Dither variation across CPU cores.""" result = QtCore.pyqtSignal(int, str, str, str, float, object) progress = QtCore.pyqtSignal(int, int) finished_all = QtCore.pyqtSignal() def __init__(self, path, prep_kwargs): super().__init__() self.path = path self.prep_kwargs = prep_kwargs self._cancel = False def cancel(self): self._cancel = True def run(self): from concurrent.futures import ProcessPoolExecutor, as_completed combos = gallery.COMBOS args = [(self.path, m, p, d, self.prep_kwargs) for (m, p, d) in combos] total = len(combos) done = 0 try: with ProcessPoolExecutor() as ex: futs = {ex.submit(gallery.render_variation, a): i for i, a in enumerate(args)} for fut in as_completed(futs): if self._cancel: ex.shutdown(wait=False, cancel_futures=True) break i = futs[fut] try: m, p, d, err, rgb = fut.result() except Exception: continue done += 1 self.result.emit(i, m, p, d, err, rgb) self.progress.emit(done, total) finally: self.finished_all.emit() class VariationsDialog(QtWidgets.QDialog): """Contact sheet of every Mode x Palette x Dither combination to pick from.""" def __init__(self, path, prep_kwargs, parent=None, cached=None): super().__init__(parent) self.setWindowTitle("Explore variations -- pick the best looking one") self.resize(940, 680) self.choice = None self.cells = {} self.best = (float("inf"), None) self.collected = {} # index -> (m, p, d, err, rgb) self.worker = None outer = QtWidgets.QVBoxLayout(self) self.info = QtWidgets.QLabel("Rendering variations... click any thumbnail to choose it") outer.addWidget(self.info) scroll = QtWidgets.QScrollArea() scroll.setWidgetResizable(True) inner = QtWidgets.QWidget() self.grid = QtWidgets.QGridLayout(inner) scroll.setWidget(inner) outer.addWidget(scroll, 1) cols = 4 for i, (m, p, d) in enumerate(gallery.COMBOS): btn = QtWidgets.QToolButton() btn.setToolButtonStyle(QtCore.Qt.ToolButtonTextUnderIcon) btn.setIconSize(QtCore.QSize(200, 125)) btn.setText(f"{m} - {p} - {d}\n(rendering...)") btn.setEnabled(False) btn.clicked.connect(lambda _checked, idx=i: self._choose(idx)) self.cells[i] = btn self.grid.addWidget(btn, i // cols, i % cols) btns = QtWidgets.QHBoxLayout() btns.addStretch(1) cancel = QtWidgets.QPushButton("Cancel") cancel.clicked.connect(self.reject) btns.addWidget(cancel) outer.addLayout(btns) if cached and len(cached) == len(gallery.COMBOS): for i, vals in cached.items(): self._populate(i, *vals) self._on_done() # show "best" marker + final message else: self.worker = GalleryWorker(path, prep_kwargs) self.worker.result.connect(self._on_result) self.worker.progress.connect(self._on_progress) self.worker.finished_all.connect(self._on_done) self.worker.start() def _populate(self, i, m, p, d, err, rgb): btn = self.cells[i] pm = numpy_to_pixmap(rgb).scaled(200, 125, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation) btn.setIcon(QtGui.QIcon(pm)) btn.setText(f"{m} - {p} - {d}\ndE {err:.1f}") btn.setEnabled(True) btn.setProperty("combo", (m, p, d)) self.collected[i] = (m, p, d, err, rgb) if err < self.best[0]: self.best = (err, i) def _on_result(self, i, m, p, d, err, rgb): self._populate(i, m, p, d, err, rgb) def _on_progress(self, done, total): self.info.setText(f"Rendering variations... {done}/{total} " f"-- click any thumbnail to choose it") def _on_done(self): msg = "Click the variation you like best." if self.best[1] is not None: m, p, d = self.cells[self.best[1]].property("combo") self.cells[self.best[1]].setText( self.cells[self.best[1]].text() + " *best*") msg += f" (lowest error: {m} / {p} / {d})" self.info.setText(msg) def _choose(self, i): self.choice = self.cells[i].property("combo") self.accept() def reject(self): if self.worker: self.worker.cancel() self.worker.wait(2000) super().reject() class MainWindow(QtWidgets.QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("c64view -- image to Commodore 64 disk") self.resize(980, 620) self.source_path = None self.last_conv = None self.worker = None self.pending = False self._gallery_key = None # cache key for Explore variations self._gallery_results = None # cached {index: (m, p, d, err, rgb)} self._build_ui() self._check_tools() # ---- UI construction ---- def _build_ui(self): central = QtWidgets.QWidget() self.setCentralWidget(central) root = QtWidgets.QHBoxLayout(central) # previews previews = QtWidgets.QVBoxLayout() self.src_label = self._make_preview("Original") self.c64_label = self._make_preview("C64 preview") previews.addWidget(self.src_label["box"]) previews.addWidget(self.c64_label["box"]) root.addLayout(previews, 3) # controls panel = QtWidgets.QVBoxLayout() root.addLayout(panel, 1) open_btn = QtWidgets.QPushButton("Open image...") open_btn.clicked.connect(self.open_image) panel.addWidget(open_btn) self.explore_btn = QtWidgets.QPushButton("Explore variations...") self.explore_btn.setToolTip( "Render every Mode x Palette x Dither combination, pick the best,\n" "then fine-tune brightness/contrast/etc.") self.explore_btn.clicked.connect(self.explore_variations) self.explore_btn.setEnabled(False) panel.addWidget(self.explore_btn) form = QtWidgets.QFormLayout() self.mode_cb = self._combo(MODE_CHOICES, "multicolor") self.format_cb = self._combo(list(FORMATS.keys()), "d64") self.palette_cb = self._combo(PALETTE_CHOICES, "colodore") self.dither_cb = self._combo(DITHER_CHOICES, "bayer") self.base_cb = self._combo(BASE_CHOICES, "grayscale") self.video_cb = self._combo(["pal", "ntsc"], "pal") self.aspect_cb = self._combo(ASPECT_CHOICES, "fit") form.addRow("Mode", self.mode_cb) form.addRow("Disk format", self.format_cb) form.addRow("Palette", self.palette_cb) form.addRow("Dither", self.dither_cb) form.addRow("Mono base", self.base_cb) form.addRow("Video", self.video_cb) form.addRow("Aspect", self.aspect_cb) panel.addLayout(form) self.intensive_cb = QtWidgets.QCheckBox("Intensive analysis (slower, best quality)") self.intensive_cb.stateChanged.connect(self.schedule_convert) panel.addWidget(self.intensive_cb) self.sliders = {} for key, lo, hi in [("brightness", 50, 200), ("contrast", 50, 200), ("saturation", 0, 200), ("gamma", 50, 200)]: panel.addLayout(self._slider(key, lo, hi)) for cb in (self.mode_cb, self.format_cb, self.palette_cb, self.dither_cb, self.base_cb, self.aspect_cb): cb.currentIndexChanged.connect(self.schedule_convert) panel.addStretch(1) self.vice_btn = QtWidgets.QPushButton("Run in VICE") self.vice_btn.setToolTip( "Build a temporary disk, open it in VICE, list the directory,\n" 'then LOAD"*",8,1 and RUN the viewer.') self.vice_btn.clicked.connect(self.run_in_vice) self.vice_btn.setEnabled(False) panel.addWidget(self.vice_btn) self.export_btn = QtWidgets.QPushButton("Export disk image...") self.export_btn.clicked.connect(self.export) self.export_btn.setEnabled(False) panel.addWidget(self.export_btn) self.status = self.statusBar() self._temp_disks = [] def _make_preview(self, title): box = QtWidgets.QGroupBox(title) lay = QtWidgets.QVBoxLayout(box) label = QtWidgets.QLabel("(no image)") label.setAlignment(QtCore.Qt.AlignCenter) label.setMinimumSize(320, 200) label.setStyleSheet("background:#202020;color:#888;") lay.addWidget(label) return {"box": box, "label": label} def _combo(self, items, default): cb = QtWidgets.QComboBox() cb.addItems(items) if default in items: cb.setCurrentText(default) return cb def _slider(self, key, lo, hi): row = QtWidgets.QHBoxLayout() row.addWidget(QtWidgets.QLabel(key[:4])) s = QtWidgets.QSlider(QtCore.Qt.Horizontal) s.setRange(lo, hi) s.setValue(100) s.sliderReleased.connect(self.schedule_convert) self.sliders[key] = s row.addWidget(s) return row def _check_tools(self): missing = [] if not have_xa(): missing.append("xa (xa65 assembler)") if not have_c1541(): missing.append("c1541 (VICE)") if missing: QtWidgets.QMessageBox.warning( self, "Missing tools", "Export needs these tools on PATH:\n " + "\n ".join(missing) + "\n\nInstall: sudo apt install xa65 vice") # ---- params ---- def params(self): base = self.base_cb.currentText() return { "mode": self.mode_cb.currentText(), "palette": self.palette_cb.currentText(), "dither": self.dither_cb.currentText(), "base_color": None if base == "grayscale" else COLOR_NAMES.index(base), "aspect": self.aspect_cb.currentText(), "intensive": self.intensive_cb.isChecked(), "brightness": self.sliders["brightness"].value() / 100.0, "contrast": self.sliders["contrast"].value() / 100.0, "saturation": self.sliders["saturation"].value() / 100.0, "gamma": self.sliders["gamma"].value() / 100.0, } # ---- actions ---- def open_image(self): path, _ = QtWidgets.QFileDialog.getOpenFileName( self, "Open image", "", "Images (*.png *.jpg *.jpeg *.gif *.bmp *.webp);;All files (*)") if not path: return self.source_path = path self.explore_btn.setEnabled(True) pm = QtGui.QPixmap(path) self.src_label["label"].setPixmap( pm.scaled(self.src_label["label"].size(), QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)) self.schedule_convert() def explore_variations(self): if not self.source_path: return p = self.params() prep_kwargs = dict(aspect=p["aspect"], brightness=p["brightness"], contrast=p["contrast"], saturation=p["saturation"], gamma=p["gamma"]) # Reuse cached results when the source and prep adjustments are unchanged. key = (self.source_path, tuple(sorted(prep_kwargs.items()))) cached = self._gallery_results if self._gallery_key == key else None dlg = VariationsDialog(self.source_path, prep_kwargs, self, cached=cached) result = dlg.exec_() if len(dlg.collected) == len(gallery.COMBOS): self._gallery_key = key self._gallery_results = dict(dlg.collected) if result == QtWidgets.QDialog.Accepted and dlg.choice: mode, palette, dither = dlg.choice # apply choice, then let the focused live preview + tuning take over for cb in (self.mode_cb, self.palette_cb, self.dither_cb): cb.blockSignals(True) self.mode_cb.setCurrentText(mode) self.palette_cb.setCurrentText(palette) self.dither_cb.setCurrentText(dither) for cb in (self.mode_cb, self.palette_cb, self.dither_cb): cb.blockSignals(False) self.status.showMessage( f"Chosen: {mode} / {palette} / {dither}. " f"Now fine-tune brightness/contrast/saturation/gamma.") self.schedule_convert() def schedule_convert(self): if not self.source_path: return if self.worker and self.worker.isRunning(): self.pending = True # coalesce: re-run when current finishes return self.status.showMessage("Converting...") self.worker = ConvertWorker(self.source_path, self.params()) self.worker.done.connect(self.on_converted) self.worker.failed.connect(self.on_failed) self.worker.finished.connect(self._worker_finished) self.worker.start() def _worker_finished(self): if self.pending: self.pending = False self.schedule_convert() def on_converted(self, conv): self.last_conv = conv rgb = render_preview(conv, conv.meta.get("palette", "colodore"), scale=2) pm = numpy_to_pixmap(rgb) self.c64_label["label"].setPixmap( pm.scaled(self.c64_label["label"].size(), QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)) self.export_btn.setEnabled(True) self.vice_btn.setEnabled(have_vice()) self.status.showMessage( f"{conv.mode}: mean dE {conv.error:.1f} " f"(palette {conv.meta.get('palette')}, dither {conv.meta.get('dither')})") def on_failed(self, msg): self.status.showMessage("Conversion failed") QtWidgets.QMessageBox.critical(self, "Conversion failed", msg) def export(self): if not self.last_conv: return fmt = self.format_cb.currentText() suggested = os.path.splitext(os.path.basename(self.source_path or "picture"))[0] path, _ = QtWidgets.QFileDialog.getSaveFileName( self, "Export disk image", f"{suggested}.{fmt}", f"Disk image (*.{fmt})") if not path: return try: out = export_disk(self.last_conv, path, disk_format=fmt, source_path=self.source_path, video=self.video_cb.currentText()) self.status.showMessage(f"Wrote {out}") QtWidgets.QMessageBox.information( self, "Exported", f"Wrote {out}\n\nIn an emulator or on a C64:\n" f' LOAD"*",8,1 then RUN') except Exception: QtWidgets.QMessageBox.critical(self, "Export failed", traceback.format_exc()) def run_in_vice(self): if not self.last_conv: return if not have_vice(): QtWidgets.QMessageBox.warning( self, "VICE not found", "The VICE emulator (x64sc) was not found on PATH.\n" "Install it with: sudo apt install vice") return try: import tempfile fmt = self.format_cb.currentText() stem = os.path.splitext(os.path.basename(self.source_path or "picture"))[0] fd, path = tempfile.mkstemp(suffix=f".{fmt}", prefix=f"{stem}_") os.close(fd) standard = self.video_cb.currentText() export_disk(self.last_conv, path, disk_format=fmt, disk_name=stem, source_path=self.source_path, video=standard) self._temp_disks.append(path) # interlace flickers at the field rate; warp would make it too fast. warp = self.last_conv.mode != "interlace" launch_in_vice(path, warp=warp, standard=standard) self.status.showMessage( f"Launched VICE ({standard}): directory, then LOAD\"*\",8,1 + RUN." + ("" if warp else " (no warp for interlace)")) except Exception: QtWidgets.QMessageBox.critical(self, "Run in VICE failed", traceback.format_exc()) def closeEvent(self, event): for path in self._temp_disks: try: os.remove(path) except OSError: pass super().closeEvent(event) def main(argv=None): app = QtWidgets.QApplication(argv if argv is not None else sys.argv) win = MainWindow() win.show() return app.exec_() if __name__ == "__main__": raise SystemExit(main())