Pixdither <UHD>
#!/usr/bin/env python3 """ pixdither - Image dithering tool with Floyd-Steinberg algorithm Converts images to reduced color palettes using error diffusion """
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}") pixdither
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) frames + 1): bits = max(1
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 20) except: font = ImageFont.load_default() draw.text((10
import argparse import sys from PIL import Image import numpy as np from pathlib import Path