332 lines
12 KiB
Python
Executable file
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()
|