490 lines
19 KiB
Python
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())
|