summaryrefslogtreecommitdiff
path: root/pixelart-qt.py
diff options
context:
space:
mode:
authorheqnx <root@heqnx.com>2025-06-29 13:13:52 +0300
committerheqnx <root@heqnx.com>2025-06-29 13:13:52 +0300
commit0eafd57ed1c150437d90a85f5cda74c170926631 (patch)
tree78c263bc2b1ff9326f37036af1e5e50f2a8ef065 /pixelart-qt.py
parenta7ff86ee9bfce088a6d232fea38874ffd4346bbc (diff)
downloadpixelart-main.tar.gz
pixelart-main.zip
added qt guiHEADmain
Diffstat (limited to 'pixelart-qt.py')
-rwxr-xr-xpixelart-qt.py283
1 files changed, 283 insertions, 0 deletions
diff --git a/pixelart-qt.py b/pixelart-qt.py
new file mode 100755
index 0000000..791e7c5
--- /dev/null
+++ b/pixelart-qt.py
@@ -0,0 +1,283 @@
+#!/usr/bin/env python3
+
+import sys
+import os
+from PIL import Image, ImageQt
+from PyQt6.QtWidgets import (
+ QApplication, QWidget, QLabel, QPushButton,
+ QSpinBox, QSlider, QComboBox, QFileDialog,
+ QMessageBox, QGridLayout, QHBoxLayout, QVBoxLayout,
+)
+from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QPixmap, QFontDatabase
+
+def load_external_font():
+ base_dir = os.path.dirname(os.path.abspath(__file__))
+ font_path = os.path.join(base_dir, "fonts", "MorePerfectDOSVGA.ttf")
+ if not os.path.isfile(font_path):
+ print(f"[warn] Font file not found: {font_path}")
+ return None
+ font_id = QFontDatabase.addApplicationFont(font_path)
+ if font_id == -1:
+ print(f"[warn] Failed to load font from {font_path}")
+ return None
+ family = QFontDatabase.applicationFontFamilies(font_id)[0]
+ print(f"Loaded font family from file: {family}")
+ return family
+
+DARK_STYLE = """
+QWidget {
+ background-color: #121212;
+ color: #e0e0e0;
+ font-size: 13px;
+}
+QSpinBox, QSlider, QComboBox {
+ background-color: #1e1e1e;
+ border: 1px solid #333;
+ padding: 4px;
+ min-height: 26px;
+ max-width: 140px;
+ selection-background-color: #444;
+ selection-color: #eee;
+}
+QPushButton {
+ background-color: #2e2e2e;
+ border: 1px solid #555;
+ padding: 6px 12px;
+ min-height: 26px;
+ min-width: 100px;
+}
+QPushButton:hover {
+ background-color: #444;
+}
+QLabel {
+ padding-right: 6px;
+}
+QSlider::groove:horizontal {
+ height: 6px;
+ background: #333;
+ border-radius: 0px;
+}
+QSlider::handle:horizontal {
+ background: #555;
+ border: 1px solid #666;
+ width: 14px;
+ margin: -5px 0;
+ border-radius: 0px;
+}
+QComboBox, QSpinBox {
+ border-radius: 0px;
+}
+"""
+
+def generate_ansi_256():
+ palette = []
+ palette.extend([
+ (0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0),
+ (0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192),
+ (128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0),
+ (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255),
+ ])
+ levels = [0, 95, 135, 175, 215, 255]
+ for r in levels:
+ for g in levels:
+ for b in levels:
+ palette.append((r, g, b))
+ for i in range(24):
+ gray = 8 + i * 10
+ palette.append((gray, gray, gray))
+ return palette[:256]
+
+def create_palette(colors):
+ palette = []
+ for r, g, b in colors:
+ palette.extend([r, g, b])
+ while len(palette) < 768:
+ palette.extend([0, 0, 0])
+ return palette
+
+def pixelate_and_reduce_colors(image, width, block_scale, dither, palette_type):
+ aspect = image.size[1] / image.size[0]
+ height = int(width * aspect)
+
+ block_size = max(1, int(width * (block_scale / 100.0)))
+ block_width = max(1, width // block_size)
+ block_height = max(1, height // block_size)
+
+ image = image.resize((block_width, block_height), Image.NEAREST).convert('RGB')
+
+ if palette_type == 'full':
+ return image.resize((block_width * block_size, block_height * block_size), Image.NEAREST).convert('RGB')
+
+ if palette_type == 'ansi256':
+ colors = generate_ansi_256()
+ elif palette_type == 'ansi16':
+ colors = generate_ansi_256()[:16]
+ else:
+ raise ValueError(f'[err] unknown palette type: {palette_type}')
+
+ pal_image = Image.new('P', (1, 1))
+ pal_image.putpalette(create_palette(colors))
+ dither_mode = {'floyd': Image.FLOYDSTEINBERG, 'ordered': Image.NONE}[dither]
+ image = image.quantize(colors=len(colors), palette=pal_image, method=Image.LIBIMAGEQUANT, dither=dither_mode)
+
+ return image.resize((block_width * block_size, block_height * block_size), Image.NEAREST).convert('RGB')
+
+class PixelateGUI(QWidget):
+ PREVIEW_WIDTH = 500
+ PREVIEW_HEIGHT = 400
+
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("pixelart")
+ self.setFixedSize(850, 420)
+
+ font_family = load_external_font()
+ if not font_family:
+ if sys.platform.startswith('win'):
+ font_family = "Segoe UI"
+ elif sys.platform.startswith('darwin'):
+ font_family = "Helvetica"
+ else:
+ font_family = "Sans Serif"
+ print(f"Using fallback font: {font_family}")
+
+ self.setStyleSheet(f"font-family: '{font_family}';" + DARK_STYLE)
+
+ # Controls
+ self.width_spin = QSpinBox()
+ self.width_spin.setRange(1, 2000)
+ self.width_spin.setValue(800)
+ self.width_spin.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons)
+ self.width_spin.setFixedWidth(120)
+
+ self.block_slider = QSlider(Qt.Orientation.Horizontal)
+ self.block_slider.setRange(10, 1000)
+ self.block_slider.setValue(100)
+ self.block_slider.setFixedWidth(120)
+
+ self.block_label = QLabel("block scale: 1.00%")
+ self.block_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
+
+ self.dither_combo = QComboBox()
+ self.dither_combo.addItems(['floyd', 'ordered'])
+ self.dither_combo.setFixedWidth(120)
+
+ self.palette_combo = QComboBox()
+ self.palette_combo.addItems(['ansi16', 'ansi256', 'full'])
+ self.palette_combo.setFixedWidth(120)
+
+ self.btn_browse = QPushButton("browse image")
+ self.btn_save = QPushButton("save image")
+ self.btn_browse.setMinimumSize(100, 26)
+ self.btn_save.setMinimumSize(100, 26)
+
+ # Preview
+ self.preview_label = QLabel()
+ self.preview_label.setFixedSize(self.PREVIEW_WIDTH, self.PREVIEW_HEIGHT)
+ self.preview_label.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter)
+ self.preview_label.setText("load an image")
+ preview_margin = 10
+ self.preview_label.setContentsMargins(preview_margin, 0, preview_margin, preview_margin)
+ self.preview_label.setStyleSheet(f"""
+ border: 1px solid #333;
+ background: #1e1e1e;
+ margin-left: {preview_margin}px;
+ margin-right: {preview_margin}px;
+ margin-bottom: {preview_margin}px;
+ """)
+
+ self.pixelated_image = None
+ self.original_image = None
+
+ # Layout
+ controls_grid = QGridLayout()
+ controls_grid.setHorizontalSpacing(15)
+ controls_grid.setVerticalSpacing(10)
+
+ controls_grid.addWidget(QLabel("width:"), 0, 0, alignment=Qt.AlignmentFlag.AlignLeft)
+ controls_grid.addWidget(self.width_spin, 0, 1, alignment=Qt.AlignmentFlag.AlignRight)
+
+ controls_grid.addWidget(self.block_label, 1, 0, alignment=Qt.AlignmentFlag.AlignLeft)
+ controls_grid.addWidget(self.block_slider, 1, 1, alignment=Qt.AlignmentFlag.AlignRight)
+
+ controls_grid.addWidget(QLabel("dither:"), 2, 0, alignment=Qt.AlignmentFlag.AlignLeft)
+ controls_grid.addWidget(self.dither_combo, 2, 1, alignment=Qt.AlignmentFlag.AlignRight)
+
+ controls_grid.addWidget(QLabel("palette:"), 3, 0, alignment=Qt.AlignmentFlag.AlignLeft)
+ controls_grid.addWidget(self.palette_combo, 3, 1, alignment=Qt.AlignmentFlag.AlignRight)
+
+ controls_grid.addWidget(self.btn_browse, 4, 1, alignment=Qt.AlignmentFlag.AlignRight)
+ controls_grid.addWidget(self.btn_save, 5, 1, alignment=Qt.AlignmentFlag.AlignRight)
+
+ controls_grid.setRowStretch(6, 1)
+
+ main_layout = QHBoxLayout(self)
+ main_layout.addLayout(controls_grid)
+ main_layout.addWidget(self.preview_label)
+
+ # Connect signals
+ self.btn_browse.clicked.connect(self.browse_file)
+ self.btn_save.clicked.connect(self.save_image)
+ self.width_spin.valueChanged.connect(self.update_preview)
+ self.block_slider.valueChanged.connect(self.on_block_slider_change)
+ self.dither_combo.currentIndexChanged.connect(self.update_preview)
+ self.palette_combo.currentIndexChanged.connect(self.update_preview)
+
+ def on_block_slider_change(self, value):
+ percent = value / 100.0
+ self.block_label.setText(f"Block Scale: {percent:.2f}%")
+ self.update_preview()
+
+ def browse_file(self):
+ path, _ = QFileDialog.getOpenFileName(self, "Select Image", "", "Images (*.png *.jpg *.bmp *.gif)")
+ if path:
+ self.original_image = Image.open(path).convert('RGBA')
+ self.update_preview()
+
+ def save_image(self):
+ if self.pixelated_image is None:
+ QMessageBox.warning(self, "Warning", "No pixelated image to save!")
+ return
+ path, _ = QFileDialog.getSaveFileName(self, "Save Image As", "output.png", "PNG Files (*.png)")
+ if path:
+ try:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ self.pixelated_image.save(path)
+ QMessageBox.information(self, "Success", f"Image saved to:\n{path}")
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Failed to save image:\n{e}")
+
+ def update_preview(self):
+ if self.original_image is None:
+ self.preview_label.setText("load an image first")
+ self.pixelated_image = None
+ return
+ width = self.width_spin.value()
+ block_scale = self.block_slider.value() / 100.0
+ dither = self.dither_combo.currentText()
+ palette_type = self.palette_combo.currentText()
+
+ try:
+ pixelated = pixelate_and_reduce_colors(
+ self.original_image, width, block_scale, dither, palette_type
+ )
+ self.pixelated_image = pixelated
+
+ qimg = ImageQt.ImageQt(pixelated)
+ pixmap = QPixmap.fromImage(qimg)
+ self.preview_label.setPixmap(pixmap.scaled(
+ self.preview_label.size(),
+ Qt.AspectRatioMode.KeepAspectRatio,
+ Qt.TransformationMode.SmoothTransformation,
+ ))
+ except Exception as e:
+ self.preview_label.setText(f"Error processing image:\n{e}")
+ self.pixelated_image = None
+
+if __name__ == "__main__":
+ app = QApplication(sys.argv)
+ window = PixelateGUI()
+ window.show()
+ sys.exit(app.exec())
+