commit 4a1fc13cf5bba7af24e67470ef1104a04f531b39 Author: Andras Schmelczer Date: Mon Mar 30 08:09:47 2026 +0100 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d20b64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.pyc diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..da9662d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "files.exclude": { + "__pycache__": true, + "**/__pycache__": true + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8674010 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,58 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Is + +An e-ink photo frame that runs on a Raspberry Pi Zero 2W. It fetches photos from an Immich server, checks Home Assistant for presence (only displays when someone is home), and renders them on a Waveshare 7.3" 6-color e-Paper display (800x480, ACeP technology with Black/White/Yellow/Red/Blue/Green). + +## Deployment + +```bash +./sync.sh # rsync src/ to andras@192.168.0.81:~/frame/ +``` + +On the Pi: +```bash +cd ~/frame +python3 display.py # default: photos of Me,Ruby +python3 display.py --album "Album Name" # from specific album +python3 display.py -o 90 # portrait mode (90° or 270°) +python3 display.py --saturation 1.5 --contrast 1.1 --gamma 0.85 +``` + +## Architecture + +**`src/display.py`** — Entry point. Orchestrates the pipeline: +1. Checks time (skips between midnight–7am) +2. Checks Home Assistant presence (skips if nobody home) +3. Fetches a random photo from Immich (by people or album) +4. Sends to e-ink display driver + +**`src/lib/immich.py`** — Immich API client. Key behaviors: +- `PhotoHistory` tracks displayed photos in `photo_history.json` to avoid repeats (resets after 7 days) +- `_pick_weighted_random()` biases selection: 50% chance favorites, 50% chance recent (last 7 days), otherwise random +- Filters photos by orientation (portrait/landscape) based on EXIF data including rotation tags +- Downloads preview-size thumbnails, not originals + +**`src/lib/homeassistant.py`** — Simple Home Assistant REST client for presence detection. + +**`src/lib/waveshare_epd/epd7in3e.py`** — Modified Waveshare driver. The `getbuffer()` method handles the full image pipeline: +- Center-crops to 800x480 (or 480x800) +- Enhances saturation/contrast/gamma for e-ink (defaults: saturation=1.4, contrast=1.2, gamma=0.9) +- Atkinson dithering to 6-color palette using numba JIT +- Packs into 4-bit-per-pixel buffer (two pixels per byte) + +**`src/lib/waveshare_epd/epdconfig.py`** — GPIO/SPI hardware config. **Critical: PWR pin is BCM 27** (not default 18). + +**`src/lib/progress.py`** — Simple terminal progress bar. + +## Key Constraints + +- **Always call `epd.sleep()` after display** — the driver uses a try/finally pattern for this +- **Display refresh takes 12-15 seconds** — the BUSY pin polling handles this +- **No test suite** — this is a hardware project; test by deploying to the Pi +- **Dependencies on Pi**: `python3-pil python3-opencv python3-numba python3-smbus spidev gpiozero` +- **Config via environment variables**: `IMMICH_URL`, `IMMICH_API_KEY`, `HA_URL`, `HA_TOKEN` (with hardcoded defaults in display.py) +- **Uses only stdlib `urllib`** — no requests library; the Immich client uses `urllib.request` directly +- `sys.path.append` is used to add `lib/` to the path from display.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..664a7bd --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +## Installation + +sudo raspi-confi +- Enable SPI + +```sh +sudo apt update +sudo apt upgrade +sudo apt install -y python3-pip python3-pil python3-opencv python3-smbus python3-numba +sudo swapoff -a +sudo systemctl mask swap.target +sudo systemctl disable --now bluetooth +``` + +Reduce journald writes +Edit /etc/systemd/journald.conf: + +``` +Storage=volatile +``` \ No newline at end of file diff --git a/dither_test/README.md b/dither_test/README.md new file mode 100644 index 0000000..26f93ae --- /dev/null +++ b/dither_test/README.md @@ -0,0 +1,164 @@ +# Dithering Test Suite + +Local testing suite for comparing dithering algorithms on the 6-color e-ink display. + +## Setup + +```bash +cd dither_test +pip install pillow numpy +``` + +For the interactive preview (optional): +```bash +# Tkinter is usually included with Python +# On Debian/Ubuntu if missing: +sudo apt-get install python3-tk +``` + +## Quick Start + +### Compare all algorithms +```bash +python compare.py photo.jpg --html +# Open dither_output/photo_report.html in browser +``` + +### Interactive preview +```bash +python preview.py photo.jpg +# Use arrow keys to cycle through algorithms +``` + +## Tools + +### `compare.py` - Batch Comparison Tool + +Generate comparison outputs for multiple dithering algorithms. + +```bash +# Compare all algorithms, save individual images +python compare.py image.jpg + +# Compare specific algorithms only +python compare.py image.jpg -a floyd_steinberg atkinson jarvis + +# Generate visual grid comparison +python compare.py image.jpg --grid + +# Generate HTML report (recommended) +python compare.py image.jpg --html + +# Side-by-side comparison of two algorithms +python compare.py image.jpg --side-by-side atkinson pil_fs + +# With rotation +python compare.py image.jpg --html -r 90 + +# List available algorithms +python compare.py --list +``` + +### `preview.py` - Interactive Preview + +Real-time preview with keyboard navigation. + +```bash +python preview.py image.jpg +``` + +**Keyboard shortcuts:** +- `←` / `→` or `A` / `D` - Cycle through algorithms +- `R` - Rotate image (0° → 90° → 180° → 270°) +- `S` - Save current result +- `O` - Open new image +- `Q` or `Esc` - Quit + +### `dither_algorithms.py` - Algorithm Library + +Use in your own scripts: + +```python +from dither_algorithms import apply_dithering, get_algorithm_names +from PIL import Image + +img = Image.open('photo.jpg') +dithered = apply_dithering(img, 'atkinson') +dithered.save('output.png') + +# List all algorithms +print(get_algorithm_names()) +``` + +## Available Algorithms + +### Error Diffusion (spread quantization error to neighbors) + +| Algorithm | Description | +|-----------|-------------| +| `floyd_steinberg` | Classic (1976), good balance of speed and quality | +| `floyd_steinberg_weighted` | With perceptual color weighting | +| `atkinson` | Bill Atkinson (Apple), cleaner with 75% error diffusion | +| `atkinson_weighted` | Atkinson with perceptual weighting | +| `jarvis` | Jarvis-Judice-Ninke, smoother gradients, slower | +| `stucki` | Similar to JJN with modified weights | +| `sierra` | Full Sierra, balanced results | +| `sierra_lite` | Faster Sierra variant | +| `burkes` | Simplified two-row diffusion | + +### Ordered Dithering (threshold matrix pattern) + +| Algorithm | Description | +|-----------|-------------| +| `bayer2` | 2×2 Bayer matrix, visible pattern | +| `bayer4` | 4×4 Bayer matrix, common choice | +| `bayer8` | 8×8 Bayer matrix, finer pattern | +| `bayer4_strong` | 4×4 with increased strength | + +### PIL Built-in (for reference) + +| Algorithm | Description | +|-----------|-------------| +| `none` | No dithering, nearest color only | +| `pil_fs` | PIL's Floyd-Steinberg implementation | + +## Recommendations + +For **photographic images**: `atkinson` or `floyd_steinberg_weighted` +- Better color accuracy, smoother gradients + +For **graphics/illustrations**: `bayer4` or `bayer8` +- Consistent patterns, no "wormy" artifacts + +For **high contrast images**: `atkinson` +- Cleaner edges, less noise in solid areas + +For **fastest processing**: `sierra_lite` or `pil_fs` +- Good quality with faster execution + +## Output + +Results are saved to `dither_output/` by default: + +``` +dither_output/ +├── photo_source.png # Prepared source (800×480) +├── photo_atkinson.png # Each algorithm result +├── photo_floyd_steinberg.png +├── ... +├── photo_grid.png # Grid comparison (--grid) +└── photo_report.html # HTML report (--html) +``` + +## 6-Color Palette + +The e-ink display uses these colors: + +| Color | RGB | +|--------|-----| +| Black | (0, 0, 0) | +| White | (255, 255, 255) | +| Yellow | (255, 255, 0) | +| Red | (255, 0, 0) | +| Blue | (0, 0, 255) | +| Green | (0, 255, 0) | diff --git a/dither_test/__init__.py b/dither_test/__init__.py new file mode 100644 index 0000000..f30795d --- /dev/null +++ b/dither_test/__init__.py @@ -0,0 +1,16 @@ +# Dithering Test Suite for 6-Color E-Ink Display +from .dither_algorithms import ( + apply_dithering, + get_algorithm_names, + DITHER_ALGORITHMS, + PALETTE_RGB, + PALETTE_NAMES, +) + +__all__ = [ + 'apply_dithering', + 'get_algorithm_names', + 'DITHER_ALGORITHMS', + 'PALETTE_RGB', + 'PALETTE_NAMES', +] diff --git a/dither_test/compare.py b/dither_test/compare.py new file mode 100755 index 0000000..f8ad693 --- /dev/null +++ b/dither_test/compare.py @@ -0,0 +1,512 @@ +#!/usr/bin/env python3 +""" +Dithering Comparison Tool for 6-Color E-Ink Display + +Generates side-by-side comparisons of different dithering algorithms +to help select the best option for your images. + +Usage: + python compare.py image.jpg # Compare all algorithms + python compare.py image.jpg -a floyd_steinberg atkinson # Compare specific + python compare.py image.jpg --grid # Generate grid comparison + python compare.py image.jpg --html # Generate HTML report + python compare.py --list # List available algorithms +""" + +import argparse +import os +import sys +import time +from pathlib import Path +from typing import List, Optional + +from PIL import Image + +from dither_algorithms import ( + DITHER_ALGORITHMS, + apply_dithering, + get_algorithm_names, + PALETTE_RGB, +) + +# Display dimensions +DISPLAY_WIDTH = 800 +DISPLAY_HEIGHT = 480 + + +def prepare_image(image_path: str, orientation: int = 0) -> Image.Image: + """Load and prepare image for the display dimensions.""" + img = Image.open(image_path).convert('RGB') + + # Apply rotation + if orientation == 90: + img = img.transpose(Image.Transpose.ROTATE_270) + elif orientation == 180: + img = img.transpose(Image.Transpose.ROTATE_180) + elif orientation == 270: + img = img.transpose(Image.Transpose.ROTATE_90) + + # Calculate scaling to fit display + target_w, target_h = DISPLAY_WIDTH, DISPLAY_HEIGHT + scale = min(target_w / img.width, target_h / img.height) + new_w = int(img.width * scale) + new_h = int(img.height * scale) + + # Resize with high-quality resampling + img = img.resize((new_w, new_h), Image.Resampling.LANCZOS) + + # Center on white canvas + canvas = Image.new('RGB', (target_w, target_h), (255, 255, 255)) + x = (target_w - new_w) // 2 + y = (target_h - new_h) // 2 + canvas.paste(img, (x, y)) + + return canvas + + +def run_comparison( + image_path: str, + algorithms: Optional[List[str]] = None, + output_dir: str = 'dither_output', + orientation: int = 0, +) -> dict: + """ + Run dithering comparison and save results. + + Returns dict with algorithm names as keys and tuples of (output_path, duration) as values. + """ + if algorithms is None: + algorithms = get_algorithm_names() + + # Prepare output directory + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + # Get base name for outputs + base_name = Path(image_path).stem + + # Load and prepare source image + print(f"Loading and preparing: {image_path}") + source = prepare_image(image_path, orientation) + + # Save prepared source for reference + source_out = output_path / f"{base_name}_source.png" + source.save(source_out) + print(f" Saved prepared source: {source_out}") + + results = {} + + for algo_name in algorithms: + if algo_name not in DITHER_ALGORITHMS: + print(f" Warning: Unknown algorithm '{algo_name}', skipping") + continue + + algo_info = DITHER_ALGORITHMS[algo_name] + print(f" Processing: {algo_info['name']}...", end=' ', flush=True) + + start_time = time.time() + dithered = apply_dithering(source, algo_name) + duration = time.time() - start_time + + out_file = output_path / f"{base_name}_{algo_name}.png" + dithered.save(out_file) + + results[algo_name] = (str(out_file), duration) + print(f"{duration:.2f}s -> {out_file}") + + return results + + +def create_comparison_grid( + image_path: str, + algorithms: Optional[List[str]] = None, + output_dir: str = 'dither_output', + orientation: int = 0, + cols: int = 3, +) -> str: + """Create a single image with all algorithms in a grid layout.""" + if algorithms is None: + algorithms = get_algorithm_names() + + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + base_name = Path(image_path).stem + source = prepare_image(image_path, orientation) + + # Calculate grid dimensions + n_images = len(algorithms) + 1 # +1 for source + rows = (n_images + cols - 1) // cols + + # Thumbnail size (scaled down for grid) + thumb_w = DISPLAY_WIDTH // 2 + thumb_h = DISPLAY_HEIGHT // 2 + padding = 10 + label_height = 30 + + # Create grid canvas + grid_w = cols * (thumb_w + padding) + padding + grid_h = rows * (thumb_h + label_height + padding) + padding + grid = Image.new('RGB', (grid_w, grid_h), (240, 240, 240)) + + # Import for text rendering + try: + from PIL import ImageDraw, ImageFont + draw = ImageDraw.Draw(grid) + try: + font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 16) + except: + font = ImageFont.load_default() + except ImportError: + draw = None + font = None + + def add_to_grid(img: Image.Image, label: str, idx: int): + row = idx // cols + col = idx % cols + x = padding + col * (thumb_w + padding) + y = padding + row * (thumb_h + label_height + padding) + + # Resize for thumbnail + thumb = img.resize((thumb_w, thumb_h), Image.Resampling.LANCZOS) + grid.paste(thumb, (x, y + label_height)) + + # Add label + if draw: + draw.text((x + 5, y + 5), label, fill=(0, 0, 0), font=font) + + # Add source image first + add_to_grid(source, "Original (Prepared)", 0) + + # Process and add each algorithm + for i, algo_name in enumerate(algorithms, 1): + if algo_name not in DITHER_ALGORITHMS: + continue + algo_info = DITHER_ALGORITHMS[algo_name] + print(f" Grid: {algo_info['name']}...") + dithered = apply_dithering(source, algo_name) + add_to_grid(dithered, algo_info['name'], i) + + # Save grid + grid_file = output_path / f"{base_name}_grid.png" + grid.save(grid_file) + print(f"Grid saved: {grid_file}") + + return str(grid_file) + + +def create_side_by_side( + image_path: str, + algo1: str, + algo2: str, + output_dir: str = 'dither_output', + orientation: int = 0, +) -> str: + """Create a side-by-side comparison of two algorithms.""" + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + base_name = Path(image_path).stem + source = prepare_image(image_path, orientation) + + info1 = DITHER_ALGORITHMS[algo1] + info2 = DITHER_ALGORITHMS[algo2] + + print(f" Processing {info1['name']}...") + img1 = apply_dithering(source, algo1) + + print(f" Processing {info2['name']}...") + img2 = apply_dithering(source, algo2) + + # Create side-by-side + padding = 20 + label_h = 40 + width = DISPLAY_WIDTH * 2 + padding * 3 + height = DISPLAY_HEIGHT + padding * 2 + label_h + + canvas = Image.new('RGB', (width, height), (240, 240, 240)) + canvas.paste(img1, (padding, padding + label_h)) + canvas.paste(img2, (DISPLAY_WIDTH + padding * 2, padding + label_h)) + + # Add labels + try: + from PIL import ImageDraw, ImageFont + draw = ImageDraw.Draw(canvas) + try: + font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20) + except: + font = ImageFont.load_default() + + draw.text((padding + 10, padding + 5), info1['name'], fill=(0, 0, 0), font=font) + draw.text((DISPLAY_WIDTH + padding * 2 + 10, padding + 5), info2['name'], fill=(0, 0, 0), font=font) + except ImportError: + pass + + out_file = output_path / f"{base_name}_{algo1}_vs_{algo2}.png" + canvas.save(out_file) + print(f"Side-by-side saved: {out_file}") + + return str(out_file) + + +def generate_html_report( + image_path: str, + results: dict, + output_dir: str = 'dither_output', +) -> str: + """Generate an HTML report for easy comparison.""" + output_path = Path(output_dir) + base_name = Path(image_path).stem + + html = f""" + + + Dithering Comparison: {base_name} + + + +

Dithering Algorithm Comparison

+

Source: {image_path}

+ +

6-Color Palette

+
+""" + + for i, (color, name) in enumerate(zip(PALETTE_RGB, ['Black', 'White', 'Yellow', 'Red', 'Blue', 'Green'])): + r, g, b = color + html += f'
\n' + + html += """
+ +

Results

+
+
+ Prepared Source +
+
Original (Prepared)
+
Source image resized to 800x480 with LANCZOS resampling
+
+
+""" + + for algo_name, (out_path, duration) in results.items(): + algo_info = DITHER_ALGORITHMS[algo_name] + filename = Path(out_path).name + html += f""" +
+ {algo_info['name']} +
+
{algo_info['name']}
+
{algo_info['description']}
+
Processing time: {duration:.2f}s
+
+
+""" + + html += """ +
+ +
+ +
+ + + + +""" + + html_file = output_path / f"{base_name}_report.html" + html_file.write_text(html.replace('{base_name}', base_name)) + print(f"HTML report saved: {html_file}") + + return str(html_file) + + +def list_algorithms(): + """Print available algorithms with descriptions.""" + print("\nAvailable Dithering Algorithms:") + print("=" * 70) + for name, info in DITHER_ALGORITHMS.items(): + print(f"\n {name}") + print(f" Name: {info['name']}") + print(f" {info['description']}") + print() + + +def main(): + parser = argparse.ArgumentParser( + description='Compare dithering algorithms for 6-color e-ink display', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python compare.py photo.jpg # Compare all algorithms + python compare.py photo.jpg -a atkinson pil_fs # Compare specific algorithms + python compare.py photo.jpg --grid # Generate grid comparison + python compare.py photo.jpg --html # Generate HTML report + python compare.py photo.jpg --side-by-side atkinson floyd_steinberg + python compare.py --list # List available algorithms + """ + ) + + parser.add_argument('image', nargs='?', help='Input image file') + parser.add_argument('-a', '--algorithms', nargs='+', + help='Specific algorithms to compare (default: all)') + parser.add_argument('-o', '--output', default='dither_output', + help='Output directory (default: dither_output)') + parser.add_argument('--orientation', '-r', type=int, default=0, + choices=[0, 90, 180, 270], + help='Rotate image (degrees)') + parser.add_argument('--grid', action='store_true', + help='Generate grid comparison image') + parser.add_argument('--html', action='store_true', + help='Generate HTML comparison report') + parser.add_argument('--side-by-side', nargs=2, metavar=('ALGO1', 'ALGO2'), + help='Create side-by-side comparison of two algorithms') + parser.add_argument('--list', action='store_true', + help='List available algorithms') + + args = parser.parse_args() + + if args.list: + list_algorithms() + return + + if not args.image: + parser.error("Image file is required (unless using --list)") + + if not os.path.exists(args.image): + print(f"Error: File not found: {args.image}") + sys.exit(1) + + print(f"\n{'='*60}") + print(f"Dithering Comparison Test Suite") + print(f"{'='*60}\n") + + if args.side_by_side: + create_side_by_side( + args.image, + args.side_by_side[0], + args.side_by_side[1], + args.output, + args.orientation + ) + elif args.grid: + create_comparison_grid( + args.image, + args.algorithms, + args.output, + args.orientation + ) + else: + results = run_comparison( + args.image, + args.algorithms, + args.output, + args.orientation + ) + + if args.html: + generate_html_report(args.image, results, args.output) + + print(f"\n{'='*60}") + print(f"Done! Output saved to: {args.output}/") + print(f"{'='*60}\n") + + +if __name__ == '__main__': + main() diff --git a/dither_test/dither_algorithms.py b/dither_test/dither_algorithms.py new file mode 100644 index 0000000..8424673 --- /dev/null +++ b/dither_test/dither_algorithms.py @@ -0,0 +1,563 @@ +""" +Dithering algorithms for 6-color e-ink display testing. + +Includes multiple error-diffusion and ordered dithering algorithms +for comparison testing. +""" + +import numpy as np +from PIL import Image +from typing import Tuple, List + +from numba import jit + +# 6-color ACeP palette (RGB format) +PALETTE_RGB = [ + (0, 0, 0), # BLACK + (255, 255, 255), # WHITE + (255, 255, 0), # YELLOW + (255, 0, 0), # RED + (0, 0, 255), # BLUE + (0, 255, 0), # GREEN +] + +PALETTE_NAMES = ['Black', 'White', 'Yellow', 'Red', 'Blue', 'Green'] + + +def create_pil_palette_image() -> Image.Image: + """Create a PIL palette image for quantization.""" + pal_image = Image.new("P", (1, 1)) + flat_palette = [] + for color in PALETTE_RGB: + flat_palette.extend(color) + # Pad to 256 colors (768 values) + flat_palette.extend([0] * (768 - len(flat_palette))) + pal_image.putpalette(flat_palette) + return pal_image + + +def find_nearest_color(pixel: np.ndarray, palette: np.ndarray) -> Tuple[int, np.ndarray]: + """Find the nearest palette color using Euclidean distance.""" + distances = np.sqrt(np.sum((palette - pixel) ** 2, axis=1)) + idx = np.argmin(distances) + return idx, palette[idx] + + +def find_nearest_color_weighted(pixel: np.ndarray, palette: np.ndarray) -> Tuple[int, np.ndarray]: + """Find nearest color using perceptually-weighted distance (human eye sensitivity).""" + # Weights based on human perception: Green > Red > Blue + weights = np.array([0.299, 0.587, 0.114]) + diff = palette - pixel + weighted_diff = diff * weights + distances = np.sqrt(np.sum(weighted_diff ** 2, axis=1)) + idx = np.argmin(distances) + return idx, palette[idx] + + +# ============================================================================= +# Error Diffusion Dithering Algorithms +# ============================================================================= + +def dither_floyd_steinberg(image: Image.Image, weighted: bool = False) -> Image.Image: + """ + Floyd-Steinberg dithering (1976). + + Classic error diffusion algorithm with distribution: + * 7/16 + 3/16 5/16 1/16 + """ + img = np.array(image.convert('RGB'), dtype=np.float64) + height, width = img.shape[:2] + palette = np.array(PALETTE_RGB, dtype=np.float64) + find_fn = find_nearest_color_weighted if weighted else find_nearest_color + + for y in range(height): + for x in range(width): + old_pixel = img[y, x].copy() + _, new_pixel = find_fn(old_pixel, palette) + img[y, x] = new_pixel + error = old_pixel - new_pixel + + if x + 1 < width: + img[y, x + 1] += error * 7 / 16 + if y + 1 < height: + if x > 0: + img[y + 1, x - 1] += error * 3 / 16 + img[y + 1, x] += error * 5 / 16 + if x + 1 < width: + img[y + 1, x + 1] += error * 1 / 16 + + img = np.clip(img, 0, 255).astype(np.uint8) + return Image.fromarray(img, 'RGB') + + +def dither_jarvis_judice_ninke(image: Image.Image, weighted: bool = False) -> Image.Image: + """ + Jarvis, Judice, and Ninke dithering (1976). + + Spreads error over a larger area (12 pixels): + * 7 5 + 3 5 7 5 3 + 1 3 5 3 1 + All divided by 48. + """ + img = np.array(image.convert('RGB'), dtype=np.float64) + height, width = img.shape[:2] + palette = np.array(PALETTE_RGB, dtype=np.float64) + find_fn = find_nearest_color_weighted if weighted else find_nearest_color + + for y in range(height): + for x in range(width): + old_pixel = img[y, x].copy() + _, new_pixel = find_fn(old_pixel, palette) + img[y, x] = new_pixel + error = old_pixel - new_pixel + + # Row 0 (current row) + if x + 1 < width: + img[y, x + 1] += error * 7 / 48 + if x + 2 < width: + img[y, x + 2] += error * 5 / 48 + + # Row 1 + if y + 1 < height: + for dx, w in [(-2, 3), (-1, 5), (0, 7), (1, 5), (2, 3)]: + if 0 <= x + dx < width: + img[y + 1, x + dx] += error * w / 48 + + # Row 2 + if y + 2 < height: + for dx, w in [(-2, 1), (-1, 3), (0, 5), (1, 3), (2, 1)]: + if 0 <= x + dx < width: + img[y + 2, x + dx] += error * w / 48 + + img = np.clip(img, 0, 255).astype(np.uint8) + return Image.fromarray(img, 'RGB') + + +def dither_stucki(image: Image.Image, weighted: bool = False) -> Image.Image: + """ + Stucki dithering (1981). + + Similar to JJN but with different weights: + * 8 4 + 2 4 8 4 2 + 1 2 4 2 1 + All divided by 42. + """ + img = np.array(image.convert('RGB'), dtype=np.float64) + height, width = img.shape[:2] + palette = np.array(PALETTE_RGB, dtype=np.float64) + find_fn = find_nearest_color_weighted if weighted else find_nearest_color + + for y in range(height): + for x in range(width): + old_pixel = img[y, x].copy() + _, new_pixel = find_fn(old_pixel, palette) + img[y, x] = new_pixel + error = old_pixel - new_pixel + + if x + 1 < width: + img[y, x + 1] += error * 8 / 42 + if x + 2 < width: + img[y, x + 2] += error * 4 / 42 + + if y + 1 < height: + for dx, w in [(-2, 2), (-1, 4), (0, 8), (1, 4), (2, 2)]: + if 0 <= x + dx < width: + img[y + 1, x + dx] += error * w / 42 + + if y + 2 < height: + for dx, w in [(-2, 1), (-1, 2), (0, 4), (1, 2), (2, 1)]: + if 0 <= x + dx < width: + img[y + 2, x + dx] += error * w / 42 + + img = np.clip(img, 0, 255).astype(np.uint8) + return Image.fromarray(img, 'RGB') + + +def dither_atkinson(image: Image.Image, weighted: bool = False) -> Image.Image: + """ + Atkinson dithering (Bill Atkinson, Apple). + + Only diffuses 6/8 of the error (loses some detail but reduces noise): + * 1 1 + 1 1 1 + 1 + All divided by 8 (but only 6/8 total error diffused). + """ + img = np.array(image.convert('RGB'), dtype=np.float64) + height, width = img.shape[:2] + palette = np.array(PALETTE_RGB, dtype=np.float64) + find_fn = find_nearest_color_weighted if weighted else find_nearest_color + + for y in range(height): + for x in range(width): + old_pixel = img[y, x].copy() + _, new_pixel = find_fn(old_pixel, palette) + img[y, x] = new_pixel + error = old_pixel - new_pixel + + # Distribute 1/8 to each of 6 neighbors + if x + 1 < width: + img[y, x + 1] += error / 8 + if x + 2 < width: + img[y, x + 2] += error / 8 + + if y + 1 < height: + if x > 0: + img[y + 1, x - 1] += error / 8 + img[y + 1, x] += error / 8 + if x + 1 < width: + img[y + 1, x + 1] += error / 8 + + if y + 2 < height: + img[y + 2, x] += error / 8 + + img = np.clip(img, 0, 255).astype(np.uint8) + return Image.fromarray(img, 'RGB') + + +def dither_sierra(image: Image.Image, weighted: bool = False) -> Image.Image: + """ + Sierra dithering (Frankie Sierra). + + Full Sierra (Sierra-3): + * 5 3 + 2 4 5 4 2 + 2 3 2 + All divided by 32. + """ + img = np.array(image.convert('RGB'), dtype=np.float64) + height, width = img.shape[:2] + palette = np.array(PALETTE_RGB, dtype=np.float64) + find_fn = find_nearest_color_weighted if weighted else find_nearest_color + + for y in range(height): + for x in range(width): + old_pixel = img[y, x].copy() + _, new_pixel = find_fn(old_pixel, palette) + img[y, x] = new_pixel + error = old_pixel - new_pixel + + if x + 1 < width: + img[y, x + 1] += error * 5 / 32 + if x + 2 < width: + img[y, x + 2] += error * 3 / 32 + + if y + 1 < height: + for dx, w in [(-2, 2), (-1, 4), (0, 5), (1, 4), (2, 2)]: + if 0 <= x + dx < width: + img[y + 1, x + dx] += error * w / 32 + + if y + 2 < height: + for dx, w in [(-1, 2), (0, 3), (1, 2)]: + if 0 <= x + dx < width: + img[y + 2, x + dx] += error * w / 32 + + img = np.clip(img, 0, 255).astype(np.uint8) + return Image.fromarray(img, 'RGB') + + +def dither_sierra_lite(image: Image.Image, weighted: bool = False) -> Image.Image: + """ + Sierra Lite (Sierra-2-4A) - faster variant. + + * 2 + 1 1 + All divided by 4. + """ + img = np.array(image.convert('RGB'), dtype=np.float64) + height, width = img.shape[:2] + palette = np.array(PALETTE_RGB, dtype=np.float64) + find_fn = find_nearest_color_weighted if weighted else find_nearest_color + + for y in range(height): + for x in range(width): + old_pixel = img[y, x].copy() + _, new_pixel = find_fn(old_pixel, palette) + img[y, x] = new_pixel + error = old_pixel - new_pixel + + if x + 1 < width: + img[y, x + 1] += error * 2 / 4 + + if y + 1 < height: + if x > 0: + img[y + 1, x - 1] += error * 1 / 4 + img[y + 1, x] += error * 1 / 4 + + img = np.clip(img, 0, 255).astype(np.uint8) + return Image.fromarray(img, 'RGB') + + +def dither_burkes(image: Image.Image, weighted: bool = False) -> Image.Image: + """ + Burkes dithering. + + * 8 4 + 2 4 8 4 2 + All divided by 32. + """ + img = np.array(image.convert('RGB'), dtype=np.float64) + height, width = img.shape[:2] + palette = np.array(PALETTE_RGB, dtype=np.float64) + find_fn = find_nearest_color_weighted if weighted else find_nearest_color + + for y in range(height): + for x in range(width): + old_pixel = img[y, x].copy() + _, new_pixel = find_fn(old_pixel, palette) + img[y, x] = new_pixel + error = old_pixel - new_pixel + + if x + 1 < width: + img[y, x + 1] += error * 8 / 32 + if x + 2 < width: + img[y, x + 2] += error * 4 / 32 + + if y + 1 < height: + for dx, w in [(-2, 2), (-1, 4), (0, 8), (1, 4), (2, 2)]: + if 0 <= x + dx < width: + img[y + 1, x + dx] += error * w / 32 + + img = np.clip(img, 0, 255).astype(np.uint8) + return Image.fromarray(img, 'RGB') + + +# ============================================================================= +# Ordered Dithering Algorithms +# ============================================================================= + +def get_bayer_matrix(n: int) -> np.ndarray: + """Generate a Bayer matrix of size 2^n x 2^n.""" + if n == 0: + return np.array([[0]]) + smaller = get_bayer_matrix(n - 1) + size = 2 ** (n - 1) + result = np.zeros((2 ** n, 2 ** n)) + result[:size, :size] = 4 * smaller + result[:size, size:] = 4 * smaller + 2 + result[size:, :size] = 4 * smaller + 3 + result[size:, size:] = 4 * smaller + 1 + return result + + +def dither_ordered_bayer(image: Image.Image, matrix_size: int = 4, strength: float = 1.0) -> Image.Image: + """ + Ordered dithering using Bayer matrix. + + Args: + image: Input image + matrix_size: Size of Bayer matrix (2, 4, 8, or 16) + strength: Dithering strength multiplier (0.0-2.0) + """ + img = np.array(image.convert('RGB'), dtype=np.float64) + height, width = img.shape[:2] + palette = np.array(PALETTE_RGB, dtype=np.float64) + + # Get appropriate Bayer matrix + n = {2: 1, 4: 2, 8: 3, 16: 4}.get(matrix_size, 2) + bayer = get_bayer_matrix(n) + bayer_size = bayer.shape[0] + + # Normalize Bayer matrix to -0.5 to 0.5 range, then scale + bayer_normalized = (bayer / (bayer_size ** 2) - 0.5) * strength * 128 + + result = np.zeros_like(img) + + for y in range(height): + for x in range(width): + threshold = bayer_normalized[y % bayer_size, x % bayer_size] + adjusted_pixel = img[y, x] + threshold + adjusted_pixel = np.clip(adjusted_pixel, 0, 255) + _, new_pixel = find_nearest_color(adjusted_pixel, palette) + result[y, x] = new_pixel + + return Image.fromarray(result.astype(np.uint8), 'RGB') + + +_NUMBA_PALETTE = np.array([ + [0, 0, 0], [255, 255, 255], [255, 255, 0], + [255, 0, 0], [0, 0, 255], [0, 255, 0], +], dtype=np.float64) +_NUMBA_WEIGHTS = np.array([0.299, 0.587, 0.114], dtype=np.float64) + +@jit(nopython=True) +def _numba_find_nearest(r, g, b, palette, weights): + best_idx = 0 + best_dist = 1e10 + for i in range(palette.shape[0]): + dr = (palette[i, 0] - r) * weights[0] + dg = (palette[i, 1] - g) * weights[1] + db = (palette[i, 2] - b) * weights[2] + dist = dr * dr + dg * dg + db * db + if dist < best_dist: + best_dist = dist + best_idx = i + return best_idx + +@jit(nopython=True) +def _numba_atkinson(img, palette, weights): + height, width = img.shape[0], img.shape[1] + for y in range(height): + for x in range(width): + old_r, old_g, old_b = img[y, x, 0], img[y, x, 1], img[y, x, 2] + idx = _numba_find_nearest(old_r, old_g, old_b, palette, weights) + new_r, new_g, new_b = palette[idx, 0], palette[idx, 1], palette[idx, 2] + img[y, x, 0], img[y, x, 1], img[y, x, 2] = new_r, new_g, new_b + err_r = (old_r - new_r) / 8.0 + err_g = (old_g - new_g) / 8.0 + err_b = (old_b - new_b) / 8.0 + if x + 1 < width: + img[y, x + 1, 0] += err_r + img[y, x + 1, 1] += err_g + img[y, x + 1, 2] += err_b + if x + 2 < width: + img[y, x + 2, 0] += err_r + img[y, x + 2, 1] += err_g + img[y, x + 2, 2] += err_b + if y + 1 < height: + if x > 0: + img[y + 1, x - 1, 0] += err_r + img[y + 1, x - 1, 1] += err_g + img[y + 1, x - 1, 2] += err_b + img[y + 1, x, 0] += err_r + img[y + 1, x, 1] += err_g + img[y + 1, x, 2] += err_b + if x + 1 < width: + img[y + 1, x + 1, 0] += err_r + img[y + 1, x + 1, 1] += err_g + img[y + 1, x + 1, 2] += err_b + if y + 2 < height: + img[y + 2, x, 0] += err_r + img[y + 2, x, 1] += err_g + img[y + 2, x, 2] += err_b + return img + +def dither_atkinson_numba(image: Image.Image) -> Image.Image: + """Numba-accelerated Atkinson dithering with perceptual weighting (~150x faster).""" + img = np.array(image.convert('RGB'), dtype=np.float64) + img = _numba_atkinson(img, _NUMBA_PALETTE, _NUMBA_WEIGHTS) + img = np.clip(img, 0, 255).astype(np.uint8) + return Image.fromarray(img, 'RGB') + + +# ============================================================================= +# PIL Built-in (for comparison) +# ============================================================================= + +def dither_pil_floyd_steinberg(image: Image.Image) -> Image.Image: + """PIL's built-in Floyd-Steinberg dithering for comparison.""" + pal_image = create_pil_palette_image() + img = image.convert('RGB') + quantized = img.quantize(dither=Image.Dither.FLOYDSTEINBERG, palette=pal_image) + return quantized.convert('RGB') + + +def dither_pil_none(image: Image.Image) -> Image.Image: + """PIL quantization with no dithering (nearest color only).""" + pal_image = create_pil_palette_image() + img = image.convert('RGB') + quantized = img.quantize(dither=Image.Dither.NONE, palette=pal_image) + return quantized.convert('RGB') + + +# ============================================================================= +# Algorithm Registry +# ============================================================================= + +DITHER_ALGORITHMS = { + 'none': { + 'name': 'No Dithering (PIL)', + 'func': dither_pil_none, + 'description': 'Simple nearest-color quantization without error diffusion', + }, + 'pil_fs': { + 'name': 'Floyd-Steinberg (PIL)', + 'func': dither_pil_floyd_steinberg, + 'description': 'PIL built-in Floyd-Steinberg implementation', + }, + 'floyd_steinberg': { + 'name': 'Floyd-Steinberg', + 'func': dither_floyd_steinberg, + 'description': 'Classic error diffusion (1976), good balance of speed and quality', + }, + 'floyd_steinberg_weighted': { + 'name': 'Floyd-Steinberg (Weighted)', + 'func': lambda img: dither_floyd_steinberg(img, weighted=True), + 'description': 'Floyd-Steinberg with perceptual color weighting', + }, + 'atkinson': { + 'name': 'Atkinson', + 'func': dither_atkinson, + 'description': 'Bill Atkinson (Apple), diffuses only 75% of error for cleaner results', + }, + 'atkinson_weighted': { + 'name': 'Atkinson (Weighted)', + 'func': lambda img: dither_atkinson(img, weighted=True), + 'description': 'Atkinson with perceptual color weighting', + }, + 'atkinson_fast': { + 'name': 'Atkinson (Numba Fast)', + 'func': dither_atkinson_numba, + 'description': 'Numba-accelerated Atkinson (~150x faster, requires numba)', + }, + 'jarvis': { + 'name': 'Jarvis-Judice-Ninke', + 'func': dither_jarvis_judice_ninke, + 'description': 'Larger diffusion kernel (1976), smoother gradients but slower', + }, + 'stucki': { + 'name': 'Stucki', + 'func': dither_stucki, + 'description': 'Similar to JJN with modified weights (1981)', + }, + 'sierra': { + 'name': 'Sierra', + 'func': dither_sierra, + 'description': 'Full Sierra dithering, balanced results', + }, + 'sierra_lite': { + 'name': 'Sierra Lite', + 'func': dither_sierra_lite, + 'description': 'Faster Sierra variant with smaller kernel', + }, + 'burkes': { + 'name': 'Burkes', + 'func': dither_burkes, + 'description': 'Simplified two-row error diffusion', + }, + 'bayer2': { + 'name': 'Ordered (Bayer 2x2)', + 'func': lambda img: dither_ordered_bayer(img, matrix_size=2), + 'description': 'Ordered dithering with 2x2 Bayer matrix', + }, + 'bayer4': { + 'name': 'Ordered (Bayer 4x4)', + 'func': lambda img: dither_ordered_bayer(img, matrix_size=4), + 'description': 'Ordered dithering with 4x4 Bayer matrix', + }, + 'bayer8': { + 'name': 'Ordered (Bayer 8x8)', + 'func': lambda img: dither_ordered_bayer(img, matrix_size=8), + 'description': 'Ordered dithering with 8x8 Bayer matrix', + }, + 'bayer4_strong': { + 'name': 'Ordered (Bayer 4x4 Strong)', + 'func': lambda img: dither_ordered_bayer(img, matrix_size=4, strength=1.5), + 'description': 'Bayer 4x4 with increased dithering strength', + }, +} + + +def get_algorithm_names() -> List[str]: + """Return list of available algorithm names.""" + return list(DITHER_ALGORITHMS.keys()) + + +def apply_dithering(image: Image.Image, algorithm: str) -> Image.Image: + """Apply the specified dithering algorithm to an image.""" + if algorithm not in DITHER_ALGORITHMS: + raise ValueError(f"Unknown algorithm: {algorithm}. Available: {get_algorithm_names()}") + return DITHER_ALGORITHMS[algorithm]['func'](image) diff --git a/dither_test/dither_output/_DSC2637-sterling_atkinson.png b/dither_test/dither_output/_DSC2637-sterling_atkinson.png new file mode 100644 index 0000000..828ec3d Binary files /dev/null and b/dither_test/dither_output/_DSC2637-sterling_atkinson.png differ diff --git a/dither_test/dither_output/_DSC2637-sterling_atkinson_weighted.png b/dither_test/dither_output/_DSC2637-sterling_atkinson_weighted.png new file mode 100644 index 0000000..9672b67 Binary files /dev/null and b/dither_test/dither_output/_DSC2637-sterling_atkinson_weighted.png differ diff --git a/dither_test/dither_output/_DSC2637-sterling_bayer2.png b/dither_test/dither_output/_DSC2637-sterling_bayer2.png new file mode 100644 index 0000000..c513c13 Binary files /dev/null and b/dither_test/dither_output/_DSC2637-sterling_bayer2.png differ diff --git a/dither_test/dither_output/_DSC2637-sterling_bayer4.png b/dither_test/dither_output/_DSC2637-sterling_bayer4.png new file mode 100644 index 0000000..0217c6f Binary files /dev/null and b/dither_test/dither_output/_DSC2637-sterling_bayer4.png differ diff --git a/dither_test/dither_output/_DSC2637-sterling_bayer4_strong.png b/dither_test/dither_output/_DSC2637-sterling_bayer4_strong.png new file mode 100644 index 0000000..be9b8c7 Binary files /dev/null and b/dither_test/dither_output/_DSC2637-sterling_bayer4_strong.png differ diff --git a/dither_test/dither_output/_DSC2637-sterling_bayer8.png b/dither_test/dither_output/_DSC2637-sterling_bayer8.png new file mode 100644 index 0000000..5f534b4 Binary files /dev/null and b/dither_test/dither_output/_DSC2637-sterling_bayer8.png differ diff --git a/dither_test/dither_output/_DSC2637-sterling_burkes.png b/dither_test/dither_output/_DSC2637-sterling_burkes.png new file mode 100644 index 0000000..5423422 Binary files /dev/null and b/dither_test/dither_output/_DSC2637-sterling_burkes.png differ diff --git a/dither_test/dither_output/_DSC2637-sterling_floyd_steinberg.png b/dither_test/dither_output/_DSC2637-sterling_floyd_steinberg.png new file mode 100644 index 0000000..8e2d2f0 Binary files /dev/null and b/dither_test/dither_output/_DSC2637-sterling_floyd_steinberg.png differ diff --git a/dither_test/dither_output/_DSC2637-sterling_floyd_steinberg_weighted.png b/dither_test/dither_output/_DSC2637-sterling_floyd_steinberg_weighted.png new file mode 100644 index 0000000..727545e Binary files /dev/null and b/dither_test/dither_output/_DSC2637-sterling_floyd_steinberg_weighted.png differ diff --git a/dither_test/dither_output/_DSC2637-sterling_jarvis.png b/dither_test/dither_output/_DSC2637-sterling_jarvis.png new file mode 100644 index 0000000..7dcc5c5 Binary files /dev/null and b/dither_test/dither_output/_DSC2637-sterling_jarvis.png differ diff --git a/dither_test/dither_output/_DSC2637-sterling_none.png b/dither_test/dither_output/_DSC2637-sterling_none.png new file mode 100644 index 0000000..9c8c9f4 Binary files /dev/null and b/dither_test/dither_output/_DSC2637-sterling_none.png differ diff --git a/dither_test/dither_output/_DSC2637-sterling_pil_fs.png b/dither_test/dither_output/_DSC2637-sterling_pil_fs.png new file mode 100644 index 0000000..543da17 Binary files /dev/null and b/dither_test/dither_output/_DSC2637-sterling_pil_fs.png differ diff --git a/dither_test/dither_output/_DSC2637-sterling_report.html b/dither_test/dither_output/_DSC2637-sterling_report.html new file mode 100644 index 0000000..b02b194 --- /dev/null +++ b/dither_test/dither_output/_DSC2637-sterling_report.html @@ -0,0 +1,269 @@ + + + + Dithering Comparison: _DSC2637-sterling + + + +

Dithering Algorithm Comparison

+

Source: /volumes/syncthing/Projects/frame/src/_DSC2637-sterling.jpg

+ +

6-Color Palette

+
+
+
+
+
+
+
+
+ +

Results

+
+
+ Prepared Source +
+
Original (Prepared)
+
Source image resized to 800x480 with LANCZOS resampling
+
+
+ +
+ No Dithering (PIL) +
+
No Dithering (PIL)
+
Simple nearest-color quantization without error diffusion
+
Processing time: 0.00s
+
+
+ +
+ Floyd-Steinberg (PIL) +
+
Floyd-Steinberg (PIL)
+
PIL built-in Floyd-Steinberg implementation
+
Processing time: 0.01s
+
+
+ +
+ Floyd-Steinberg +
+
Floyd-Steinberg
+
Classic error diffusion (1976), good balance of speed and quality
+
Processing time: 3.58s
+
+
+ +
+ Floyd-Steinberg (Weighted) +
+
Floyd-Steinberg (Weighted)
+
Floyd-Steinberg with perceptual color weighting
+
Processing time: 3.93s
+
+
+ +
+ Atkinson +
+
Atkinson
+
Bill Atkinson (Apple), diffuses only 75% of error for cleaner results
+
Processing time: 3.57s
+
+
+ +
+ Atkinson (Weighted) +
+
Atkinson (Weighted)
+
Atkinson with perceptual color weighting
+
Processing time: 3.89s
+
+
+ +
+ Jarvis-Judice-Ninke +
+
Jarvis-Judice-Ninke
+
Larger diffusion kernel (1976), smoother gradients but slower
+
Processing time: 7.85s
+
+
+ +
+ Stucki +
+
Stucki
+
Similar to JJN with modified weights (1981)
+
Processing time: 7.83s
+
+
+ +
+ Sierra +
+
Sierra
+
Full Sierra dithering, balanced results
+
Processing time: 6.75s
+
+
+ +
+ Sierra Lite +
+
Sierra Lite
+
Faster Sierra variant with smaller kernel
+
Processing time: 3.03s
+
+
+ +
+ Burkes +
+
Burkes
+
Simplified two-row error diffusion
+
Processing time: 5.21s
+
+
+ +
+ Ordered (Bayer 2x2) +
+
Ordered (Bayer 2x2)
+
Ordered dithering with 2x2 Bayer matrix
+
Processing time: 2.05s
+
+
+ +
+ Ordered (Bayer 4x4) +
+
Ordered (Bayer 4x4)
+
Ordered dithering with 4x4 Bayer matrix
+
Processing time: 2.05s
+
+
+ +
+ Ordered (Bayer 8x8) +
+
Ordered (Bayer 8x8)
+
Ordered dithering with 8x8 Bayer matrix
+
Processing time: 2.04s
+
+
+ +
+ Ordered (Bayer 4x4 Strong) +
+
Ordered (Bayer 4x4 Strong)
+
Bayer 4x4 with increased dithering strength
+
Processing time: 2.03s
+
+
+ +
+ +
+ +
+ + + + diff --git a/dither_test/dither_output/_DSC2637-sterling_sierra.png b/dither_test/dither_output/_DSC2637-sterling_sierra.png new file mode 100644 index 0000000..7250997 Binary files /dev/null and b/dither_test/dither_output/_DSC2637-sterling_sierra.png differ diff --git a/dither_test/dither_output/_DSC2637-sterling_sierra_lite.png b/dither_test/dither_output/_DSC2637-sterling_sierra_lite.png new file mode 100644 index 0000000..c389688 Binary files /dev/null and b/dither_test/dither_output/_DSC2637-sterling_sierra_lite.png differ diff --git a/dither_test/dither_output/_DSC2637-sterling_source.png b/dither_test/dither_output/_DSC2637-sterling_source.png new file mode 100644 index 0000000..05f8a0c Binary files /dev/null and b/dither_test/dither_output/_DSC2637-sterling_source.png differ diff --git a/dither_test/dither_output/_DSC2637-sterling_stucki.png b/dither_test/dither_output/_DSC2637-sterling_stucki.png new file mode 100644 index 0000000..b59e980 Binary files /dev/null and b/dither_test/dither_output/_DSC2637-sterling_stucki.png differ diff --git a/dither_test/preview.py b/dither_test/preview.py new file mode 100755 index 0000000..49ae982 --- /dev/null +++ b/dither_test/preview.py @@ -0,0 +1,332 @@ +#!/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('<>', 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('<>', 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('', lambda e: self.prev_algo()) + self.root.bind('', 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('', 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() diff --git a/src/display.py b/src/display.py new file mode 100644 index 0000000..14216e4 --- /dev/null +++ b/src/display.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +import argparse +import os +import sys +from datetime import datetime +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +sys.path.append(str(Path(__file__).parent / "lib")) +from waveshare_epd import epd7in3e +from immich import ImmichClient, get_random_photo_of_people, get_random_photo_from_album +from homeassistant import HomeAssistantClient + +IMMICH_URL = os.environ.get("IMMICH_URL", "https://immich.schmelczer.dev") +IMMICH_API_KEY = os.environ.get("IMMICH_API_KEY", "6crxVS1JLTJxsfGlzVhN2kefdL4EP7HPkkoMk9L6ZOE") + +HA_URL = os.environ.get("HA_URL", "https://homeassistant.schmelczer.dev") +HA_TOKEN = os.environ.get("HA_TOKEN", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJmZjk3OTNmOWMzOWU0YjdmYmRjYTc5YmJkMTUyODcyNSIsImlhdCI6MTc2OTIwMjg1NCwiZXhwIjoyMDg0NTYyODU0fQ.IiL_1vTrGMlOoPMksN6lAopE0aInlY_wRnL4Jc-CeBs") +HA_PRESENCE_ENTITIES = ["person.andras", "person.ruby"] + +DEFAULT_SATURATION = 1.3 +DEFAULT_CONTRAST = 1.05 +DEFAULT_GAMMA = 0.90 + +def display_image(image_path: Path, orientation: int, saturation: float, + contrast: float, gamma: float, enhance: bool) -> None: + epd = epd7in3e.EPD() + try: + epd.init() + img = Image.open(image_path).convert("RGB") + if orientation: + img = img.rotate(orientation, expand=True) + buf = epd.getbuffer(img, saturation=saturation, contrast=contrast, + gamma=gamma, enhance=enhance) + epd.display(buf) + finally: + epd.sleep() + + +def main() -> None: + parser = argparse.ArgumentParser(description="Display image on e-ink frame") + parser.add_argument("--people", default="Me,Ruby", + help="Comma-separated names for Immich search") + parser.add_argument("--album", help="Fetch from album (overrides --people)") + parser.add_argument("-o", "--orientation", type=int, choices=[0, 90, 180, 270], + default=0, help="Rotation in degrees") + parser.add_argument("--saturation", type=float, default=DEFAULT_SATURATION) + parser.add_argument("--contrast", type=float, default=DEFAULT_CONTRAST) + parser.add_argument("--gamma", type=float, default=DEFAULT_GAMMA) + parser.add_argument("--no-enhance", action="store_true") + args = parser.parse_args() + + now = datetime.now() + print(f"Time: {now.strftime('%H:%M')}") + + if 0 <= now.hour < 7: + print("Night time, skipping") + sys.exit(0) + + ha = HomeAssistantClient(HA_URL, HA_TOKEN) + home = [e.split(".")[-1].title() for e in HA_PRESENCE_ENTITIES if ha.is_person_home(e)] + if not home: + print("No one home, skipping") + sys.exit(0) + print(f"Home: {', '.join(home)}") + + client = ImmichClient(IMMICH_URL, IMMICH_API_KEY) + if args.album: + image_path = get_random_photo_from_album(client, args.album, args.orientation) + print(f"Album: {args.album}") + else: + names = [n.strip() for n in args.people.split(",")] + image_path = get_random_photo_of_people(client, names, args.orientation) + print(f"People: {', '.join(names)}") + + try: + display_image(image_path, args.orientation, args.saturation, + args.contrast, args.gamma, not args.no_enhance) + finally: + image_path.unlink(missing_ok=True) + + +if __name__ == "__main__": + main() diff --git a/src/lib/homeassistant.py b/src/lib/homeassistant.py new file mode 100644 index 0000000..fe7458e --- /dev/null +++ b/src/lib/homeassistant.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +import json +from urllib.request import Request, urlopen + + +class HomeAssistantClient: + def __init__(self, base_url: str, token: str): + self.base_url = base_url.rstrip("/") + self.token = token + + def get_state(self, entity_id: str) -> dict: + url = f"{self.base_url}/api/states/{entity_id}" + req = Request(url, headers={ + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + }) + with urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode()) + + def is_person_home(self, entity_id: str) -> bool: + try: + return self.get_state(entity_id).get("state") == "home" + except Exception as e: + print(f"Failed to check {entity_id}: {e}") + return False diff --git a/src/lib/immich.py b/src/lib/immich.py new file mode 100644 index 0000000..3d1d216 --- /dev/null +++ b/src/lib/immich.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +import json +import random +import tempfile +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from urllib.request import Request, urlopen + +from progress import ProgressBar + +HISTORY_FILE = Path(__file__).parent.parent / "photo_history.json" +HISTORY_MAX_AGE_DAYS = 7 + + +class PhotoHistory: + """Track displayed photos to avoid repeats. Clears after 7 days.""" + + def __init__(self, path: Path = HISTORY_FILE): + self.path = path + self.displayed: set[str] = set() + self.created_at: datetime | None = None + self._load() + + def _load(self) -> None: + if not self.path.exists(): + self._reset() + return + try: + data = json.loads(self.path.read_text()) + self.created_at = datetime.fromisoformat(data.get("created_at", "")) + if self.created_at.tzinfo is None: + self.created_at = self.created_at.replace(tzinfo=timezone.utc) + if datetime.now(timezone.utc) - self.created_at > timedelta(days=HISTORY_MAX_AGE_DAYS): + print(f"Photo history expired (>{HISTORY_MAX_AGE_DAYS} days), clearing...") + self._reset() + else: + self.displayed = set(data.get("displayed", [])) + except (json.JSONDecodeError, ValueError, KeyError): + self._reset() + + def _reset(self) -> None: + self.displayed = set() + self.created_at = datetime.now(timezone.utc) + self._save() + + def _save(self) -> None: + self.path.write_text(json.dumps({ + "created_at": self.created_at.isoformat(), + "displayed": list(self.displayed), + }, indent=2)) + + def mark_displayed(self, asset_id: str) -> None: + self.displayed.add(asset_id) + self._save() + + def filter_new(self, assets: list[dict]) -> list[dict]: + return [a for a in assets if a.get("id") not in self.displayed] + + +_history: PhotoHistory | None = None +_people_cache: dict[str, str] = {} # name -> id cache + + +def get_history() -> PhotoHistory: + global _history + if _history is None: + _history = PhotoHistory() + return _history + + +@dataclass +class ImmichClient: + base_url: str + api_key: str + + def _request(self, method: str, endpoint: str, data: dict | None = None, + show_progress: bool = False, progress_desc: str = "Fetching") -> dict: + url = f"{self.base_url.rstrip('/')}/api/{endpoint.lstrip('/')}" + headers = {"x-api-key": self.api_key} + body = None + if data is not None: + headers["Content-Type"] = "application/json" + body = json.dumps(data).encode() + + req = Request(url, data=body, headers=headers, method=method) + with urlopen(req, timeout=30) as resp: + total_size = resp.headers.get('Content-Length') + if total_size and show_progress: + total_size = int(total_size) + progress = ProgressBar(total_size, desc=progress_desc) + chunks = bytearray() + while chunk := resp.read(8192): + chunks.extend(chunk) + progress.update(len(chunk)) + progress.finish() + return json.loads(chunks.decode()) + return json.loads(resp.read().decode()) + + def get_people(self) -> list[dict]: + return self._request("GET", "/people")["people"] + + def get_person_id(self, name: str) -> str | None: + for person in self.get_people(): + if person["name"].lower() == name.lower(): + return person["id"] + return None + + def search_assets_by_people(self, person_ids: list[str]) -> list[dict]: + items = [] + page = 1 + while True: + result = self._request("POST", "/search/metadata", { + "personIds": person_ids, + "size": 250, + "page": page, + "type": "IMAGE", + "withExif": True, + }) + batch = result.get("assets", {}).get("items", []) + items.extend(batch) + if not batch or not result.get("assets", {}).get("nextPage"): + break + page += 1 + return items + + def download_asset(self, asset_id: str, dest: Path, show_progress: bool = True) -> Path: + url = f"{self.base_url.rstrip('/')}/api/assets/{asset_id}/thumbnail?size=preview" + req = Request(url, headers={"x-api-key": self.api_key}) + with urlopen(req, timeout=30) as resp: + total_size = resp.headers.get('Content-Length') + if total_size and show_progress: + total_size = int(total_size) + progress = ProgressBar(total_size, desc="Downloading") + data = bytearray() + while chunk := resp.read(8192): + data.extend(chunk) + progress.update(len(chunk)) + progress.finish() + dest.write_bytes(bytes(data)) + else: + dest.write_bytes(resp.read()) + return dest + + def get_album_id(self, name: str) -> str | None: + for album in self._request("GET", "/albums"): + if album["albumName"].lower() == name.lower(): + return album["id"] + return None + + def get_album_assets(self, album_id: str, show_progress: bool = False) -> list[dict]: + album = self._request("GET", f"/albums/{album_id}", + show_progress=show_progress, progress_desc="Fetching album") + return album.get("assets", []) + + +def _is_portrait(asset: dict) -> bool | None: + """Check if asset displays as portrait, accounting for EXIF orientation.""" + exif = asset.get("exifInfo") or {} + width = exif.get("exifImageWidth") or 0 + height = exif.get("exifImageHeight") or 0 + if not (width and height): + return None + # EXIF orientation 6 and 8 mean 90° rotation (swap dimensions) + orientation = str(exif.get("orientation") or "1") + if orientation in ("6", "8"): + width, height = height, width + return height > width + + +def _filter_by_orientation(assets: list[dict], portrait: bool) -> list[dict]: + """Filter assets by orientation, accounting for EXIF rotation.""" + filtered = [] + no_dimensions = 0 + for asset in assets: + is_portrait = _is_portrait(asset) + if is_portrait is not None: + if is_portrait == portrait: + filtered.append(asset) + else: + no_dimensions += 1 + if no_dimensions: + print(f"Note: {no_dimensions}/{len(assets)} photos missing dimension data") + return filtered + + +def _pick_weighted_random(assets: list[dict]) -> dict: + """Pick random asset, slightly biased towards favorites (20%) and recent photos (20%).""" + if not assets: + raise ValueError("No assets to choose from") + + one_week_ago = datetime.now(timezone.utc) - timedelta(days=7) + favorites = [a for a in assets if a.get("isFavorite")] + recent = [] + for asset in assets: + date_str = asset.get("fileCreatedAt") or asset.get("createdAt", "") + try: + if datetime.fromisoformat(date_str.replace("Z", "+00:00")) >= one_week_ago: + recent.append(asset) + except (ValueError, AttributeError): + pass + + if favorites and random.random() < 0.2: + return random.choice(favorites) + if recent and random.random() < 0.25: + return random.choice(recent) + return random.choice(assets) + + +def _download_random_asset(client: ImmichClient, assets: list[dict]) -> Path: + history = get_history() + new_assets = history.filter_new(assets) + + if new_assets: + print(f"Photos: {len(new_assets)} new / {len(assets)} total") + asset = _pick_weighted_random(new_assets) + else: + print(f"All {len(assets)} photos shown, picking from full list") + asset = _pick_weighted_random(assets) + + history.mark_displayed(asset["id"]) + dest = Path(tempfile.gettempdir()) / "immich_photo.jpg" + return client.download_asset(asset["id"], dest) + + +def get_random_photo_of_people(client: ImmichClient, names: list[str], orientation: int = 0) -> Path: + person_ids = [pid for name in names if (pid := client.get_person_id(name))] + if not person_ids: + raise ValueError(f"No people found: {names}") + + assets = client.search_assets_by_people(person_ids) + + if not assets: + raise ValueError(f"No photos found for: {names}") + + portrait = orientation in (90, 270) + filtered = _filter_by_orientation(assets, portrait) + if filtered: + assets = filtered + else: + print(f"No {'portrait' if portrait else 'landscape'} photos, using any orientation") + return _download_random_asset(client, assets) + + +def get_random_photo_from_album(client: ImmichClient, album_name: str, orientation: int = 0) -> Path: + album_id = client.get_album_id(album_name) + if not album_id: + raise ValueError(f"Album not found: {album_name}") + + assets = [a for a in client.get_album_assets(album_id) if a.get("type") == "IMAGE"] + if not assets: + raise ValueError(f"No photos in album: {album_name}") + + portrait = orientation in (90, 270) + filtered = _filter_by_orientation(assets, portrait) + if filtered: + assets = filtered + else: + print(f"No {'portrait' if portrait else 'landscape'} photos, using any orientation") + return _download_random_asset(client, assets) diff --git a/src/lib/progress.py b/src/lib/progress.py new file mode 100644 index 0000000..335544e --- /dev/null +++ b/src/lib/progress.py @@ -0,0 +1,54 @@ +"""Simple terminal progress bar for e-ink frame.""" + +import sys + + +class ProgressBar: + """Simple text-based progress bar.""" + + def __init__(self, total: int, desc: str = "", width: int = 30): + self.total = total + self.current = 0 + self.desc = desc + self.width = width + self._last_percent = -1 + + def update(self, n: int = 1) -> None: + """Update progress by n steps.""" + self.current = min(self.current + n, self.total) + self._render() + + def set(self, value: int) -> None: + """Set progress to specific value.""" + self.current = min(value, self.total) + self._render() + + def _render(self) -> None: + if self.total == 0: + return + + percent = int(100 * self.current / self.total) + if percent == self._last_percent: + return + self._last_percent = percent + + filled = int(self.width * self.current / self.total) + bar = "█" * filled + "░" * (self.width - filled) + + desc = f"{self.desc}: " if self.desc else "" + sys.stdout.write(f"\r{desc}|{bar}| {percent:3d}%") + sys.stdout.flush() + + if self.current >= self.total: + sys.stdout.write("\n") + sys.stdout.flush() + + def finish(self) -> None: + """Complete the progress bar.""" + self.current = self.total + self._render() + + +def print_status(msg: str) -> None: + """Print a status message.""" + print(f" {msg}") diff --git a/src/lib/waveshare_epd/DEV_Config_32.so b/src/lib/waveshare_epd/DEV_Config_32.so new file mode 100644 index 0000000..76251ce Binary files /dev/null and b/src/lib/waveshare_epd/DEV_Config_32.so differ diff --git a/src/lib/waveshare_epd/DEV_Config_64.so b/src/lib/waveshare_epd/DEV_Config_64.so new file mode 100644 index 0000000..c3886c0 Binary files /dev/null and b/src/lib/waveshare_epd/DEV_Config_64.so differ diff --git a/src/lib/waveshare_epd/__init__.py b/src/lib/waveshare_epd/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/waveshare_epd/epd7in3e.py b/src/lib/waveshare_epd/epd7in3e.py new file mode 100644 index 0000000..7114130 --- /dev/null +++ b/src/lib/waveshare_epd/epd7in3e.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +# Waveshare 7.3" 6-color e-Paper driver (modified) +# Original: Waveshare team, 2022-10-20 + +import sys +import numpy as np +import cv2 +from PIL import Image, ImageEnhance +from numba import jit +from . import epdconfig + +EPD_WIDTH = 800 +EPD_HEIGHT = 480 + +DEFAULT_SATURATION = 1.4 +DEFAULT_CONTRAST = 1.2 +DEFAULT_GAMMA = 0.9 + +PALETTE_RGB = np.array([ + [0, 0, 0], # BLACK + [255, 255, 255], # WHITE + [255, 255, 0], # YELLOW + [255, 0, 0], # RED + [0, 0, 255], # BLUE + [0, 255, 0], # GREEN +], dtype=np.float64) + +PERCEPTUAL_WEIGHTS = np.array([0.299, 0.587, 0.114], dtype=np.float64) + + +def _enhance_for_eink(image: Image.Image, saturation: float = None, + contrast: float = None, gamma: float = None) -> Image.Image: + saturation = saturation or DEFAULT_SATURATION + contrast = contrast or DEFAULT_CONTRAST + gamma = gamma or DEFAULT_GAMMA + + img = image.convert('RGB') + if saturation != 1.0: + img = ImageEnhance.Color(img).enhance(saturation) + if contrast != 1.0: + img = ImageEnhance.Contrast(img).enhance(contrast) + if gamma != 1.0: + lut = [int((i / 255.0) ** (1.0 / gamma) * 255) for i in range(256)] * 3 + img = img.point(lut) + return img + + +def _crop_center(image: Image.Image, target_w: int, target_h: int, + show_progress: bool = True) -> Image.Image: + if show_progress: + print("Center cropping...") + + img_cv = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR) + img_h, img_w = img_cv.shape[:2] + img_aspect, target_aspect = img_w / img_h, target_w / target_h + + if img_aspect < target_aspect: + new_w, new_h = target_w, int(target_w / img_aspect) + else: + new_w, new_h = int(target_h * img_aspect), target_h + + img_cv = cv2.resize(img_cv, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4) + x_off = (new_w - target_w) // 2 + y_off = (new_h - target_h) // 2 + cropped = img_cv[y_off:y_off + target_h, x_off:x_off + target_w] + return Image.fromarray(cv2.cvtColor(cropped, cv2.COLOR_BGR2RGB)) + + +def _render_progress(desc: str, current: int, total: int, width: int = 30) -> None: + if total == 0: + return + percent = int(100 * current / total) + filled = int(width * current / total) + bar = "█" * filled + "░" * (width - filled) + sys.stdout.write(f"\r{desc}: |{bar}| {percent:3d}%") + sys.stdout.flush() + if current >= total: + print() + + +@jit(nopython=True, cache=True) +def _find_nearest_color(r, g, b, palette, weights): + best_idx, best_dist = 0, 1e10 + for i in range(palette.shape[0]): + dr = (palette[i, 0] - r) * weights[0] + dg = (palette[i, 1] - g) * weights[1] + db = (palette[i, 2] - b) * weights[2] + dist = dr * dr + dg * dg + db * db + if dist < best_dist: + best_dist, best_idx = dist, i + return best_idx + + +@jit(nopython=True, cache=True) +def _atkinson_dither_rows(img, palette, weights, start_row, end_row): + height, width = img.shape[:2] + for y in range(start_row, end_row): + for x in range(width): + old_r, old_g, old_b = img[y, x, 0], img[y, x, 1], img[y, x, 2] + idx = _find_nearest_color(old_r, old_g, old_b, palette, weights) + new_r, new_g, new_b = palette[idx, 0], palette[idx, 1], palette[idx, 2] + img[y, x, 0], img[y, x, 1], img[y, x, 2] = new_r, new_g, new_b + + err_r, err_g, err_b = (old_r - new_r) / 8.0, (old_g - new_g) / 8.0, (old_b - new_b) / 8.0 + + if x + 1 < width: + img[y, x + 1, 0] += err_r + img[y, x + 1, 1] += err_g + img[y, x + 1, 2] += err_b + if x + 2 < width: + img[y, x + 2, 0] += err_r + img[y, x + 2, 1] += err_g + img[y, x + 2, 2] += err_b + if y + 1 < height: + if x > 0: + img[y + 1, x - 1, 0] += err_r + img[y + 1, x - 1, 1] += err_g + img[y + 1, x - 1, 2] += err_b + img[y + 1, x, 0] += err_r + img[y + 1, x, 1] += err_g + img[y + 1, x, 2] += err_b + if x + 1 < width: + img[y + 1, x + 1, 0] += err_r + img[y + 1, x + 1, 1] += err_g + img[y + 1, x + 1, 2] += err_b + if y + 2 < height: + img[y + 2, x, 0] += err_r + img[y + 2, x, 1] += err_g + img[y + 2, x, 2] += err_b + return img + + +def _dither_atkinson(image: Image.Image, show_progress: bool = True) -> Image.Image: + img = np.array(image.convert('RGB'), dtype=np.float64) + height = img.shape[0] + if show_progress: + print("Dithering...") + + chunk_size = 48 + for i in range((height + chunk_size - 1) // chunk_size): + start, end = i * chunk_size, min((i + 1) * chunk_size, height) + img = _atkinson_dither_rows(img, PALETTE_RGB, PERCEPTUAL_WEIGHTS, start, end) + if show_progress: + _render_progress("Dithering", end, height) + + return Image.fromarray(np.clip(img, 0, 255).astype(np.uint8), 'RGB') + + +class EPD: + def __init__(self): + self.reset_pin = epdconfig.RST_PIN + self.dc_pin = epdconfig.DC_PIN + self.busy_pin = epdconfig.BUSY_PIN + self.cs_pin = epdconfig.CS_PIN + self.width = EPD_WIDTH + self.height = EPD_HEIGHT + + def reset(self): + epdconfig.digital_write(self.reset_pin, 1) + epdconfig.delay_ms(20) + epdconfig.digital_write(self.reset_pin, 0) + epdconfig.delay_ms(2) + epdconfig.digital_write(self.reset_pin, 1) + epdconfig.delay_ms(20) + + def send_command(self, command): + epdconfig.digital_write(self.dc_pin, 0) + epdconfig.digital_write(self.cs_pin, 0) + epdconfig.spi_writebyte([command]) + epdconfig.digital_write(self.cs_pin, 1) + + def send_data(self, data): + epdconfig.digital_write(self.dc_pin, 1) + epdconfig.digital_write(self.cs_pin, 0) + epdconfig.spi_writebyte([data]) + epdconfig.digital_write(self.cs_pin, 1) + + def send_data2(self, data): + epdconfig.digital_write(self.dc_pin, 1) + epdconfig.digital_write(self.cs_pin, 0) + epdconfig.spi_writebyte2(data) + epdconfig.digital_write(self.cs_pin, 1) + + def wait_busy(self): + while epdconfig.digital_read(self.busy_pin) == 0: + epdconfig.delay_ms(5) + + def turn_on_display(self): + self.send_command(0x04) # POWER_ON + self.wait_busy() + self.send_command(0x12) # DISPLAY_REFRESH + self.send_data(0x00) + self.wait_busy() + self.send_command(0x02) # POWER_OFF + self.send_data(0x00) + self.wait_busy() + + def init(self): + if epdconfig.module_init() != 0: + return -1 + self.reset() + self.wait_busy() + epdconfig.delay_ms(30) + + self.send_command(0xAA) + for v in [0x49, 0x55, 0x20, 0x08, 0x09, 0x18]: + self.send_data(v) + + self.send_command(0x01) + self.send_data(0x3F) + + self.send_command(0x00) + self.send_data(0x5F) + self.send_data(0x69) + + self.send_command(0x03) + for v in [0x00, 0x54, 0x00, 0x44]: + self.send_data(v) + + self.send_command(0x05) + for v in [0x40, 0x1F, 0x1F, 0x2C]: + self.send_data(v) + + self.send_command(0x06) + for v in [0x6F, 0x1F, 0x17, 0x49]: + self.send_data(v) + + self.send_command(0x08) + for v in [0x6F, 0x1F, 0x1F, 0x22]: + self.send_data(v) + + self.send_command(0x30) + self.send_data(0x03) + + self.send_command(0x50) + self.send_data(0x3F) + + self.send_command(0x60) + self.send_data(0x02) + self.send_data(0x00) + + self.send_command(0x61) + for v in [0x03, 0x20, 0x01, 0xE0]: + self.send_data(v) + + self.send_command(0x84) + self.send_data(0x01) + + self.send_command(0xE3) + self.send_data(0x2F) + + self.send_command(0x04) + self.wait_busy() + return 0 + + def getbuffer(self, image, saturation=None, contrast=None, gamma=None, + enhance=True, show_progress=True): + pal_image = Image.new("P", (1, 1)) + pal_image.putpalette((0,0,0, 255,255,255, 255,255,0, 255,0,0, 0,0,0, 0,0,255, 0,255,0) + (0,0,0)*249) + + image = image.convert('RGB') + imwidth, imheight = image.size + + if imwidth != self.width or imheight != self.height: + if show_progress: + print(f"Input: {imwidth}x{imheight} → {self.width}x{self.height}") + image = _crop_center(image, self.width, self.height, show_progress) + + if enhance: + if show_progress: + print("Enhancing...") + image = _enhance_for_eink(image, saturation, contrast, gamma) + + image = _dither_atkinson(image, show_progress) + + if show_progress: + print("Packing buffer...") + image_6color = image.quantize(palette=pal_image, dither=Image.Dither.NONE) + buf_6color = bytearray(image_6color.tobytes('raw')) + + buf = [0x00] * (self.width * self.height // 2) + for i in range(0, len(buf_6color), 2): + buf[i // 2] = (buf_6color[i] << 4) + buf_6color[i + 1] + + if show_progress: + print("Ready") + return buf + + def display(self, image): + self.send_command(0x10) + self.send_data2(image) + self.turn_on_display() + + def Clear(self, color=0x11): + self.send_command(0x10) + self.send_data2([color] * (self.height * self.width // 2)) + self.turn_on_display() + + def sleep(self): + self.send_command(0x07) # DEEP_SLEEP + self.send_data(0xA5) + epdconfig.delay_ms(2000) + epdconfig.module_exit() diff --git a/src/lib/waveshare_epd/epdconfig.py b/src/lib/waveshare_epd/epdconfig.py new file mode 100644 index 0000000..d3f5465 --- /dev/null +++ b/src/lib/waveshare_epd/epdconfig.py @@ -0,0 +1,167 @@ +# /***************************************************************************** +# * | File : epdconfig.py +# * | Author : Waveshare team +# * | Function : Hardware underlying interface +# * | Info : +# *---------------- +# * | This version: V1.2 +# * | Date : 2022-10-29 +# * | Info : +# ****************************************************************************** +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documnetation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS OR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# + +import os +import logging +import sys +import time +import subprocess + +from ctypes import * + +logger = logging.getLogger(__name__) + + +class RaspberryPi: + # Pin definition + RST_PIN = 17 + DC_PIN = 25 + CS_PIN = 8 + BUSY_PIN = 24 + PWR_PIN = 27 + MOSI_PIN = 10 + SCLK_PIN = 11 + + def __init__(self): + import spidev + import gpiozero + + self.SPI = spidev.SpiDev() + self.GPIO_RST_PIN = gpiozero.LED(self.RST_PIN) + self.GPIO_DC_PIN = gpiozero.LED(self.DC_PIN) + # self.GPIO_CS_PIN = gpiozero.LED(self.CS_PIN) + self.GPIO_PWR_PIN = gpiozero.LED(self.PWR_PIN) + self.GPIO_BUSY_PIN = gpiozero.Button(self.BUSY_PIN, pull_up = False) + + + + def digital_write(self, pin, value): + if pin == self.RST_PIN: + if value: + self.GPIO_RST_PIN.on() + else: + self.GPIO_RST_PIN.off() + elif pin == self.DC_PIN: + if value: + self.GPIO_DC_PIN.on() + else: + self.GPIO_DC_PIN.off() + # elif pin == self.CS_PIN: + # if value: + # self.GPIO_CS_PIN.on() + # else: + # self.GPIO_CS_PIN.off() + elif pin == self.PWR_PIN: + if value: + self.GPIO_PWR_PIN.on() + else: + self.GPIO_PWR_PIN.off() + + def digital_read(self, pin): + if pin == self.BUSY_PIN: + return self.GPIO_BUSY_PIN.value + elif pin == self.RST_PIN: + return self.RST_PIN.value + elif pin == self.DC_PIN: + return self.DC_PIN.value + # elif pin == self.CS_PIN: + # return self.CS_PIN.value + elif pin == self.PWR_PIN: + return self.PWR_PIN.value + + def delay_ms(self, delaytime): + time.sleep(delaytime / 1000.0) + + def spi_writebyte(self, data): + self.SPI.writebytes(data) + + def spi_writebyte2(self, data): + self.SPI.writebytes2(data) + + def DEV_SPI_write(self, data): + self.DEV_SPI.DEV_SPI_SendData(data) + + def DEV_SPI_nwrite(self, data): + self.DEV_SPI.DEV_SPI_SendnData(data) + + def DEV_SPI_read(self): + return self.DEV_SPI.DEV_SPI_ReadData() + + def module_init(self, cleanup=False): + self.GPIO_PWR_PIN.on() + + if cleanup: + find_dirs = [ + os.path.dirname(os.path.realpath(__file__)), + '/usr/local/lib', + '/usr/lib', + ] + self.DEV_SPI = None + for find_dir in find_dirs: + val = int(os.popen('getconf LONG_BIT').read()) + logging.debug("System is %d bit"%val) + if val == 64: + so_filename = os.path.join(find_dir, 'DEV_Config_64.so') + else: + so_filename = os.path.join(find_dir, 'DEV_Config_32.so') + if os.path.exists(so_filename): + self.DEV_SPI = CDLL(so_filename) + break + if self.DEV_SPI is None: + RuntimeError('Cannot find DEV_Config.so') + + self.DEV_SPI.DEV_Module_Init() + + else: + # SPI device, bus = 0, device = 0 + self.SPI.open(0, 0) + self.SPI.max_speed_hz = 4000000 + self.SPI.mode = 0b00 + return 0 + + def module_exit(self, cleanup=False): + logger.debug("spi end") + self.SPI.close() + + self.GPIO_RST_PIN.off() + self.GPIO_DC_PIN.off() + self.GPIO_PWR_PIN.off() + logger.debug("close 5V, Module enters 0 power consumption ...") + + if cleanup: + self.GPIO_RST_PIN.close() + self.GPIO_DC_PIN.close() + # self.GPIO_CS_PIN.close() + self.GPIO_PWR_PIN.close() + self.GPIO_BUSY_PIN.close() + +implementation = RaspberryPi() + +for func in [x for x in dir(implementation) if not x.startswith('_')]: + setattr(sys.modules[__name__], func, getattr(implementation, func)) diff --git a/src/lib/waveshare_epd/sysfs_gpio.so b/src/lib/waveshare_epd/sysfs_gpio.so new file mode 100644 index 0000000..b8d9cdd Binary files /dev/null and b/src/lib/waveshare_epd/sysfs_gpio.so differ diff --git a/src/lib/waveshare_epd/sysfs_software_spi.so b/src/lib/waveshare_epd/sysfs_software_spi.so new file mode 100644 index 0000000..f9ff3a6 Binary files /dev/null and b/src/lib/waveshare_epd/sysfs_software_spi.so differ diff --git a/sync.sh b/sync.sh new file mode 100755 index 0000000..e0f8eca --- /dev/null +++ b/sync.sh @@ -0,0 +1,2 @@ +#!/bin/bash +rsync -avz --progress src/ andras@192.168.0.81:~/frame/