#!/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())