frame/dither_test/preview.py
2026-03-30 08:09:47 +01:00

332 lines
12 KiB
Python
Executable file

#!/usr/bin/env python3
"""
Interactive Dithering Preview Tool
Opens a window to preview different dithering algorithms in real-time.
Use keyboard shortcuts to cycle through algorithms.
Requirements:
pip install pillow
Usage:
python preview.py image.jpg
"""
import argparse
import sys
import os
from pathlib import Path
try:
import tkinter as tk
from tkinter import ttk, filedialog
from PIL import Image, ImageTk
except ImportError as e:
print(f"Error: Missing dependency - {e}")
print("Install with: pip install pillow")
sys.exit(1)
from dither_algorithms import (
DITHER_ALGORITHMS,
apply_dithering,
get_algorithm_names,
PALETTE_RGB,
PALETTE_NAMES,
)
DISPLAY_WIDTH = 800
DISPLAY_HEIGHT = 480
class DitherPreview:
def __init__(self, image_path: str = None):
self.root = tk.Tk()
self.root.title("Dithering Preview - 6-Color E-Ink")
self.root.configure(bg='#1a1a2e')
self.algorithms = get_algorithm_names()
self.current_algo_idx = 0
self.source_image = None
self.prepared_image = None
self.orientation = 0
self.setup_ui()
self.bind_keys()
if image_path and os.path.exists(image_path):
self.load_image(image_path)
def setup_ui(self):
# Main container
main_frame = ttk.Frame(self.root)
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# Style
style = ttk.Style()
style.configure('TFrame', background='#1a1a2e')
style.configure('TLabel', background='#1a1a2e', foreground='#eee')
style.configure('Title.TLabel', font=('Helvetica', 14, 'bold'), foreground='#00d9ff')
style.configure('Info.TLabel', font=('Helvetica', 10))
# Top bar with controls
top_frame = ttk.Frame(main_frame)
top_frame.pack(fill=tk.X, pady=(0, 10))
# Algorithm selector
ttk.Label(top_frame, text="Algorithm:", style='TLabel').pack(side=tk.LEFT)
self.algo_var = tk.StringVar(value=self.algorithms[0])
self.algo_combo = ttk.Combobox(
top_frame,
textvariable=self.algo_var,
values=self.algorithms,
state='readonly',
width=25
)
self.algo_combo.pack(side=tk.LEFT, padx=(5, 20))
self.algo_combo.bind('<<ComboboxSelected>>', self.on_algo_change)
# Rotation
ttk.Label(top_frame, text="Rotation:", style='TLabel').pack(side=tk.LEFT)
self.rotation_var = tk.StringVar(value='0')
rotation_combo = ttk.Combobox(
top_frame,
textvariable=self.rotation_var,
values=['0', '90', '180', '270'],
state='readonly',
width=5
)
rotation_combo.pack(side=tk.LEFT, padx=(5, 20))
rotation_combo.bind('<<ComboboxSelected>>', self.on_rotation_change)
# Load button
load_btn = ttk.Button(top_frame, text="Load Image", command=self.open_file_dialog)
load_btn.pack(side=tk.LEFT, padx=5)
# Save button
save_btn = ttk.Button(top_frame, text="Save Result", command=self.save_result)
save_btn.pack(side=tk.LEFT, padx=5)
# Image display area
display_frame = ttk.Frame(main_frame)
display_frame.pack(fill=tk.BOTH, expand=True)
# Source image
source_frame = ttk.LabelFrame(display_frame, text="Source (Prepared)")
source_frame.pack(side=tk.LEFT, padx=(0, 5))
self.source_label = ttk.Label(source_frame)
self.source_label.pack(padx=5, pady=5)
# Result image
result_frame = ttk.LabelFrame(display_frame, text="Dithered Result")
result_frame.pack(side=tk.LEFT, padx=(5, 0))
self.result_label = ttk.Label(result_frame)
self.result_label.pack(padx=5, pady=5)
# Info panel
info_frame = ttk.Frame(main_frame)
info_frame.pack(fill=tk.X, pady=(10, 0))
self.algo_title = ttk.Label(info_frame, text="", style='Title.TLabel')
self.algo_title.pack(anchor=tk.W)
self.algo_desc = ttk.Label(info_frame, text="", style='Info.TLabel', wraplength=800)
self.algo_desc.pack(anchor=tk.W)
self.time_label = ttk.Label(info_frame, text="", style='Info.TLabel')
self.time_label.pack(anchor=tk.W)
# Palette display
palette_frame = ttk.Frame(main_frame)
palette_frame.pack(fill=tk.X, pady=(10, 0))
ttk.Label(palette_frame, text="Palette:", style='TLabel').pack(side=tk.LEFT)
for color, name in zip(PALETTE_RGB, PALETTE_NAMES):
r, g, b = color
hex_color = f'#{r:02x}{g:02x}{b:02x}'
swatch = tk.Canvas(palette_frame, width=30, height=20, bg=hex_color,
highlightthickness=1, highlightbackground='#333')
swatch.pack(side=tk.LEFT, padx=2)
# Keyboard shortcuts info
shortcuts_frame = ttk.Frame(main_frame)
shortcuts_frame.pack(fill=tk.X, pady=(10, 0))
ttk.Label(
shortcuts_frame,
text="Shortcuts: ← → or A/D = cycle algorithms | R = rotate | S = save | O = open | Q = quit",
style='Info.TLabel'
).pack()
# Set placeholder
self.show_placeholder()
def bind_keys(self):
self.root.bind('<Left>', lambda e: self.prev_algo())
self.root.bind('<Right>', lambda e: self.next_algo())
self.root.bind('a', lambda e: self.prev_algo())
self.root.bind('d', lambda e: self.next_algo())
self.root.bind('r', lambda e: self.rotate())
self.root.bind('s', lambda e: self.save_result())
self.root.bind('o', lambda e: self.open_file_dialog())
self.root.bind('q', lambda e: self.root.quit())
self.root.bind('<Escape>', lambda e: self.root.quit())
def show_placeholder(self):
# Create placeholder images
placeholder = Image.new('RGB', (400, 240), (40, 40, 60))
try:
from PIL import ImageDraw
draw = ImageDraw.Draw(placeholder)
draw.text((150, 110), "Load an image", fill=(100, 100, 120))
except:
pass
self.source_photo = ImageTk.PhotoImage(placeholder)
self.result_photo = ImageTk.PhotoImage(placeholder)
self.source_label.configure(image=self.source_photo)
self.result_label.configure(image=self.result_photo)
self.algo_title.configure(text="No image loaded")
self.algo_desc.configure(text="Press 'O' or click 'Load Image' to open an image file")
self.time_label.configure(text="")
def prepare_image(self, img: Image.Image) -> Image.Image:
"""Prepare image for display dimensions."""
# Apply rotation
if self.orientation == 90:
img = img.transpose(Image.Transpose.ROTATE_270)
elif self.orientation == 180:
img = img.transpose(Image.Transpose.ROTATE_180)
elif self.orientation == 270:
img = img.transpose(Image.Transpose.ROTATE_90)
# Scale to fit
scale = min(DISPLAY_WIDTH / img.width, DISPLAY_HEIGHT / img.height)
new_w = int(img.width * scale)
new_h = int(img.height * scale)
img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
# Center on canvas
canvas = Image.new('RGB', (DISPLAY_WIDTH, DISPLAY_HEIGHT), (255, 255, 255))
x = (DISPLAY_WIDTH - new_w) // 2
y = (DISPLAY_HEIGHT - new_h) // 2
canvas.paste(img, (x, y))
return canvas
def load_image(self, path: str):
try:
self.source_image = Image.open(path).convert('RGB')
self.image_path = path
self.root.title(f"Dithering Preview - {Path(path).name}")
self.update_display()
except Exception as e:
print(f"Error loading image: {e}")
def update_display(self):
if self.source_image is None:
return
import time
# Prepare source image
self.prepared_image = self.prepare_image(self.source_image)
# Create display-size version for preview (half size to fit window)
preview_size = (DISPLAY_WIDTH // 2, DISPLAY_HEIGHT // 2)
source_preview = self.prepared_image.resize(preview_size, Image.Resampling.LANCZOS)
# Apply dithering
algo_name = self.algo_var.get()
algo_info = DITHER_ALGORITHMS[algo_name]
start_time = time.time()
dithered = apply_dithering(self.prepared_image, algo_name)
duration = time.time() - start_time
self.dithered_result = dithered
result_preview = dithered.resize(preview_size, Image.Resampling.LANCZOS)
# Update display
self.source_photo = ImageTk.PhotoImage(source_preview)
self.result_photo = ImageTk.PhotoImage(result_preview)
self.source_label.configure(image=self.source_photo)
self.result_label.configure(image=self.result_photo)
self.algo_title.configure(text=algo_info['name'])
self.algo_desc.configure(text=algo_info['description'])
self.time_label.configure(text=f"Processing time: {duration:.2f}s")
def on_algo_change(self, event=None):
self.current_algo_idx = self.algorithms.index(self.algo_var.get())
self.update_display()
def on_rotation_change(self, event=None):
self.orientation = int(self.rotation_var.get())
self.update_display()
def next_algo(self):
self.current_algo_idx = (self.current_algo_idx + 1) % len(self.algorithms)
self.algo_var.set(self.algorithms[self.current_algo_idx])
self.update_display()
def prev_algo(self):
self.current_algo_idx = (self.current_algo_idx - 1) % len(self.algorithms)
self.algo_var.set(self.algorithms[self.current_algo_idx])
self.update_display()
def rotate(self):
rotations = ['0', '90', '180', '270']
current_idx = rotations.index(self.rotation_var.get())
next_idx = (current_idx + 1) % 4
self.rotation_var.set(rotations[next_idx])
self.orientation = int(rotations[next_idx])
self.update_display()
def open_file_dialog(self):
filetypes = [
('Image files', '*.jpg *.jpeg *.png *.bmp *.gif *.tiff'),
('All files', '*.*')
]
path = filedialog.askopenfilename(filetypes=filetypes)
if path:
self.load_image(path)
def save_result(self):
if not hasattr(self, 'dithered_result') or self.dithered_result is None:
return
algo_name = self.algo_var.get()
base_name = Path(self.image_path).stem if hasattr(self, 'image_path') else 'output'
default_name = f"{base_name}_{algo_name}.png"
path = filedialog.asksaveasfilename(
defaultextension='.png',
initialfile=default_name,
filetypes=[('PNG', '*.png'), ('BMP', '*.bmp'), ('JPEG', '*.jpg')]
)
if path:
self.dithered_result.save(path)
print(f"Saved: {path}")
def run(self):
self.root.mainloop()
def main():
parser = argparse.ArgumentParser(description='Interactive dithering preview')
parser.add_argument('image', nargs='?', help='Input image file')
args = parser.parse_args()
app = DitherPreview(args.image)
app.run()
if __name__ == '__main__':
main()