8bitlenser/c64view/gui.py
2026-06-14 17:43:12 -07:00

490 lines
19 KiB
Python

"""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())