Pixdither High Quality May 2026

#!/usr/bin/env python3 """ pixdither - Image dithering tool with Floyd-Steinberg algorithm Converts images to reduced color palettes using error diffusion """

import argparse import sys from PIL import Image import numpy as np from pathlib import Path pixdither

class PixDither: def __init__(self, image_path, output_path=None, bits_per_channel=1, palette_type="monochrome", dither_algorithm="floyd-steinberg"): """ Initialize dithering processor Args: image_path: Path to input image output_path: Path for output image (optional) bits_per_channel: Color depth (1-8 bits per channel) palette_type: "monochrome", "grayscale", "rgb", or custom dither_algorithm: "floyd-steinberg", "atkinson", or "none" """ self.image_path = Path(image_path) self.output_path = output_path self.bits = bits_per_channel self.palette_type = palette_type self.algorithm = dither_algorithm # Load image self.img = Image.open(image_path).convert('RGB') self.pixels = np.array(self.img, dtype=np.float32) self.height, self.width = self.pixels.shape[:2] def quantize_color(self, color): """Quantize a single RGB color based on bits per channel""" if self.palette_type == "monochrome": # Simple black/white based on luminance luminance = 0.299 * color[0] + 0.587 * color[1] + 0.114 * color[2] return np.array([255, 255, 255]) if luminance > 127 else np.array([0, 0, 0]) elif self.palette_type == "grayscale": # Grayscale with 2^bits levels levels = 2 ** self.bits step = 256 / levels luminance = 0.299 * color[0] + 0.587 * color[1] + 0.114 * color[2] gray_level = round(luminance / step) * step return np.array([gray_level, gray_level, gray_level]) else: # RGB quantization levels = 2 ** self.bits step = 256 / levels quantized = np.round(color / step) * step return np.clip(quantized, 0, 255) def floyd_steinberg(self): """Apply Floyd-Steinberg dithering""" result = self.pixels.copy() for y in range(self.height): for x in range(self.width): old_pixel = result[y, x].copy() new_pixel = self.quantize_color(old_pixel) result[y, x] = new_pixel error = old_pixel - new_pixel # Distribute error to neighboring pixels if x + 1 < self.width: result[y, x + 1] += error * 7/16 if y + 1 < self.height: if x > 0: result[y + 1, x - 1] += error * 3/16 result[y + 1, x] += error * 5/16 if x + 1 < self.width: result[y + 1, x + 1] += error * 1/16 return np.clip(result, 0, 255).astype(np.uint8) def atkinson(self): """Apply Atkinson dithering""" result = self.pixels.copy() for y in range(self.height): for x in range(self.width): old_pixel = result[y, x].copy() new_pixel = self.quantize_color(old_pixel) result[y, x] = new_pixel error = old_pixel - new_pixel # Distribute error (all divided by 8) if x + 1 < self.width: result[y, x + 1] += error * 1/8 if x + 2 < self.width: result[y, x + 2] += error * 1/8 if y + 1 < self.height: if x > 0: result[y + 1, x - 1] += error * 1/8 result[y + 1, x] += error * 1/8 if x + 1 < self.width: result[y + 1, x + 1] += error * 1/8 if y + 2 < self.height: result[y + 2, x] += error * 1/8 return np.clip(result, 0, 255).astype(np.uint8) def simple_quantize(self): """Simple quantization without dithering""" result = self.pixels.copy() for y in range(self.height): for x in range(self.width): result[y, x] = self.quantize_color(result[y, x]) return result.astype(np.uint8) def process(self): """Process the image with selected algorithm""" print(f"Processing: {self.image_path.name}") print(f" Size: {self.width}x{self.height}") print(f" Palette: {self.palette_type}") print(f" Bits per channel: {self.bits}") print(f" Algorithm: {self.algorithm}") if self.algorithm == "floyd-steinberg": output_pixels = self.floyd_steinberg() elif self.algorithm == "atkinson": output_pixels = self.atkinson() elif self.algorithm == "none": output_pixels = self.simple_quantize() else: raise ValueError(f"Unknown algorithm: {self.algorithm}") # Create output image output_img = Image.fromarray(output_pixels, 'RGB') # Save or return if self.output_path: output_img.save(self.output_path) print(f" Saved to: {self.output_path}") else: # Auto-generate output filename output_path = self.image_path.stem + f"_dithered{self.image_path.suffix}" output_img.save(output_path) print(f" Saved to: {output_path}") return output_img or custom dither_algorithm: "floyd-steinberg"

def create_gif(input_path, output_path, frames=10, duration=0.1): """Create animated dithering GIF showing progression""" from PIL import ImageDraw, ImageFont images = [] base_img = Image.open(input_path).convert('RGB') for i in range(1, frames + 1): bits = max(1, int(8 * i / frames)) dithered = PixDither(input_path, bits_per_channel=bits, palette_type="rgb", dither_algorithm="floyd-steinberg") img = dithered.process() # Add text overlay draw = ImageDraw.Draw(img) try: font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20) except: font = ImageFont.load_default() draw.text((10, 10), f"{bits} bits/channel", fill=(255, 255, 255), font=font) images.append(img) images[0].save(output_path, save_all=True, append_images=images[1:], duration=duration*1000, loop=0) print(f"GIF saved to: {output_path}") self.width = self.pixels.shape[:2] def quantize_color(self

def main(): parser = argparse.ArgumentParser( description='pixdither - Apply dithering to images', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: pixdither input.jpg -o output.png # Default (black/white Floyd-Steinberg) pixdither input.jpg -b 3 -p grayscale # 3-bit grayscale pixdither input.jpg -b 2 -p rgb -a atkinson # 2-bit per channel RGB with Atkinson pixdither input.jpg --no-dither # Simple quantization without dithering pixdither input.jpg --gif output.gif # Create animated dithering GIF """ ) parser.add_argument('input', help='Input image path') parser.add_argument('-o', '--output', help='Output image path') parser.add_argument('-b', '--bits', type=int, default=1, help='Bits per channel (1-8, default: 1)') parser.add_argument('-p', '--palette', choices=['monochrome', 'grayscale', 'rgb'], default='monochrome', help='Color palette type (default: monochrome)') parser.add_argument('-a', '--algorithm', choices=['floyd-steinberg', 'atkinson', 'none'], default='floyd-steinberg', help='Dithering algorithm (default: floyd-steinberg)') parser.add_argument('--gif', help='Create animated dithering GIF (provide output path)') args = parser.parse_args() # Validate bits if args.bits < 1 or args.bits > 8: print("Error: bits must be between 1 and 8") sys.exit(1) # Check if creating GIF if args.gif: create_gif(args.input, args.gif) return # Process single image try: dithered = PixDither( args.input, args.output, bits_per_channel=args.bits, palette_type=args.palette, dither_algorithm=args.algorithm ) dithered.process() except FileNotFoundError: print(f"Error: File '{args.input}' not found") sys.exit(1) except Exception as e: print(f"Error: {e}") sys.exit(1)