Working Python version for Commodore.
This commit is contained in:
commit
2a48f52979
51 changed files with 3095 additions and 0 deletions
490
c64view/gui.py
Normal file
490
c64view/gui.py
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
"""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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue