diff options
-rw-r--r-- | fonts/MorePerfectDOSVGA.ttf | bin | 0 -> 78252 bytes | |||
-rwxr-xr-x | pixelart-qt.py | 283 | ||||
-rw-r--r-- | requirements.txt | 3 |
3 files changed, 286 insertions, 0 deletions
diff --git a/fonts/MorePerfectDOSVGA.ttf b/fonts/MorePerfectDOSVGA.ttf Binary files differnew file mode 100644 index 0000000..bf70112 --- /dev/null +++ b/fonts/MorePerfectDOSVGA.ttf 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()) + diff --git a/requirements.txt b/requirements.txt index 73259b7..72268ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,4 @@ pillow==11.2.1 +PyQt6==6.9.1 +PyQt6-Qt6==6.9.1 +PyQt6_sip==13.10.2 |