This commit is contained in:
Andras Schmelczer 2026-03-30 08:09:47 +01:00
commit 36d975545b
38 changed files with 2837 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
*.pyc

6
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,6 @@
{
"files.exclude": {
"__pycache__": true,
"**/__pycache__": true
}
}

58
CLAUDE.md Normal file
View file

@ -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 midnight7am)
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

20
README.md Normal file
View file

@ -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
```

164
dither_test/README.md Normal file
View file

@ -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) |

16
dither_test/__init__.py Normal file
View file

@ -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',
]

512
dither_test/compare.py Executable file
View file

@ -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"""<!DOCTYPE html>
<html>
<head>
<title>Dithering Comparison: {base_name}</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
margin: 0;
padding: 20px;
}}
h1 {{ color: #00d9ff; margin-bottom: 10px; }}
.subtitle {{ color: #888; margin-bottom: 30px; }}
.grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
gap: 20px;
}}
.card {{
background: #16213e;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}}
.card img {{
width: 100%;
height: auto;
display: block;
cursor: pointer;
transition: transform 0.2s;
}}
.card img:hover {{ transform: scale(1.02); }}
.card-info {{
padding: 15px;
}}
.card-title {{
font-size: 18px;
font-weight: 600;
color: #00d9ff;
margin-bottom: 5px;
}}
.card-desc {{
font-size: 13px;
color: #888;
margin-bottom: 8px;
}}
.card-time {{
font-size: 12px;
color: #666;
}}
.source-card {{
grid-column: 1 / -1;
max-width: 850px;
}}
.source-card img {{ max-width: 800px; }}
.palette {{
display: flex;
gap: 10px;
margin: 20px 0;
padding: 15px;
background: #16213e;
border-radius: 8px;
width: fit-content;
}}
.color-swatch {{
width: 40px;
height: 40px;
border-radius: 6px;
border: 2px solid #333;
}}
.fullscreen {{
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.95);
z-index: 1000;
justify-content: center;
align-items: center;
}}
.fullscreen img {{
max-width: 95%;
max-height: 95%;
}}
.fullscreen.active {{ display: flex; }}
</style>
</head>
<body>
<h1>Dithering Algorithm Comparison</h1>
<p class="subtitle">Source: {image_path}</p>
<h3>6-Color Palette</h3>
<div class="palette">
"""
for i, (color, name) in enumerate(zip(PALETTE_RGB, ['Black', 'White', 'Yellow', 'Red', 'Blue', 'Green'])):
r, g, b = color
html += f' <div class="color-swatch" style="background: rgb({r},{g},{b});" title="{name}"></div>\n'
html += """ </div>
<h3>Results</h3>
<div class="grid">
<div class="card source-card">
<img src="{base_name}_source.png" alt="Prepared Source" onclick="showFullscreen(this)">
<div class="card-info">
<div class="card-title">Original (Prepared)</div>
<div class="card-desc">Source image resized to 800x480 with LANCZOS resampling</div>
</div>
</div>
"""
for algo_name, (out_path, duration) in results.items():
algo_info = DITHER_ALGORITHMS[algo_name]
filename = Path(out_path).name
html += f"""
<div class="card">
<img src="{filename}" alt="{algo_info['name']}" onclick="showFullscreen(this)">
<div class="card-info">
<div class="card-title">{algo_info['name']}</div>
<div class="card-desc">{algo_info['description']}</div>
<div class="card-time">Processing time: {duration:.2f}s</div>
</div>
</div>
"""
html += """
</div>
<div class="fullscreen" onclick="hideFullscreen()">
<img src="" id="fullscreen-img">
</div>
<script>
function showFullscreen(img) {
document.getElementById('fullscreen-img').src = img.src;
document.querySelector('.fullscreen').classList.add('active');
}
function hideFullscreen() {
document.querySelector('.fullscreen').classList.remove('active');
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') hideFullscreen();
});
</script>
</body>
</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()

View file

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View file

@ -0,0 +1,269 @@
<!DOCTYPE html>
<html>
<head>
<title>Dithering Comparison: _DSC2637-sterling</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
margin: 0;
padding: 20px;
}
h1 { color: #00d9ff; margin-bottom: 10px; }
.subtitle { color: #888; margin-bottom: 30px; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
gap: 20px;
}
.card {
background: #16213e;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.card img {
width: 100%;
height: auto;
display: block;
cursor: pointer;
transition: transform 0.2s;
}
.card img:hover { transform: scale(1.02); }
.card-info {
padding: 15px;
}
.card-title {
font-size: 18px;
font-weight: 600;
color: #00d9ff;
margin-bottom: 5px;
}
.card-desc {
font-size: 13px;
color: #888;
margin-bottom: 8px;
}
.card-time {
font-size: 12px;
color: #666;
}
.source-card {
grid-column: 1 / -1;
max-width: 850px;
}
.source-card img { max-width: 800px; }
.palette {
display: flex;
gap: 10px;
margin: 20px 0;
padding: 15px;
background: #16213e;
border-radius: 8px;
width: fit-content;
}
.color-swatch {
width: 40px;
height: 40px;
border-radius: 6px;
border: 2px solid #333;
}
.fullscreen {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.95);
z-index: 1000;
justify-content: center;
align-items: center;
}
.fullscreen img {
max-width: 95%;
max-height: 95%;
}
.fullscreen.active { display: flex; }
</style>
</head>
<body>
<h1>Dithering Algorithm Comparison</h1>
<p class="subtitle">Source: /volumes/syncthing/Projects/frame/src/_DSC2637-sterling.jpg</p>
<h3>6-Color Palette</h3>
<div class="palette">
<div class="color-swatch" style="background: rgb(0,0,0);" title="Black"></div>
<div class="color-swatch" style="background: rgb(255,255,255);" title="White"></div>
<div class="color-swatch" style="background: rgb(255,255,0);" title="Yellow"></div>
<div class="color-swatch" style="background: rgb(255,0,0);" title="Red"></div>
<div class="color-swatch" style="background: rgb(0,0,255);" title="Blue"></div>
<div class="color-swatch" style="background: rgb(0,255,0);" title="Green"></div>
</div>
<h3>Results</h3>
<div class="grid">
<div class="card source-card">
<img src="_DSC2637-sterling_source.png" alt="Prepared Source" onclick="showFullscreen(this)">
<div class="card-info">
<div class="card-title">Original (Prepared)</div>
<div class="card-desc">Source image resized to 800x480 with LANCZOS resampling</div>
</div>
</div>
<div class="card">
<img src="_DSC2637-sterling_none.png" alt="No Dithering (PIL)" onclick="showFullscreen(this)">
<div class="card-info">
<div class="card-title">No Dithering (PIL)</div>
<div class="card-desc">Simple nearest-color quantization without error diffusion</div>
<div class="card-time">Processing time: 0.00s</div>
</div>
</div>
<div class="card">
<img src="_DSC2637-sterling_pil_fs.png" alt="Floyd-Steinberg (PIL)" onclick="showFullscreen(this)">
<div class="card-info">
<div class="card-title">Floyd-Steinberg (PIL)</div>
<div class="card-desc">PIL built-in Floyd-Steinberg implementation</div>
<div class="card-time">Processing time: 0.01s</div>
</div>
</div>
<div class="card">
<img src="_DSC2637-sterling_floyd_steinberg.png" alt="Floyd-Steinberg" onclick="showFullscreen(this)">
<div class="card-info">
<div class="card-title">Floyd-Steinberg</div>
<div class="card-desc">Classic error diffusion (1976), good balance of speed and quality</div>
<div class="card-time">Processing time: 3.58s</div>
</div>
</div>
<div class="card">
<img src="_DSC2637-sterling_floyd_steinberg_weighted.png" alt="Floyd-Steinberg (Weighted)" onclick="showFullscreen(this)">
<div class="card-info">
<div class="card-title">Floyd-Steinberg (Weighted)</div>
<div class="card-desc">Floyd-Steinberg with perceptual color weighting</div>
<div class="card-time">Processing time: 3.93s</div>
</div>
</div>
<div class="card">
<img src="_DSC2637-sterling_atkinson.png" alt="Atkinson" onclick="showFullscreen(this)">
<div class="card-info">
<div class="card-title">Atkinson</div>
<div class="card-desc">Bill Atkinson (Apple), diffuses only 75% of error for cleaner results</div>
<div class="card-time">Processing time: 3.57s</div>
</div>
</div>
<div class="card">
<img src="_DSC2637-sterling_atkinson_weighted.png" alt="Atkinson (Weighted)" onclick="showFullscreen(this)">
<div class="card-info">
<div class="card-title">Atkinson (Weighted)</div>
<div class="card-desc">Atkinson with perceptual color weighting</div>
<div class="card-time">Processing time: 3.89s</div>
</div>
</div>
<div class="card">
<img src="_DSC2637-sterling_jarvis.png" alt="Jarvis-Judice-Ninke" onclick="showFullscreen(this)">
<div class="card-info">
<div class="card-title">Jarvis-Judice-Ninke</div>
<div class="card-desc">Larger diffusion kernel (1976), smoother gradients but slower</div>
<div class="card-time">Processing time: 7.85s</div>
</div>
</div>
<div class="card">
<img src="_DSC2637-sterling_stucki.png" alt="Stucki" onclick="showFullscreen(this)">
<div class="card-info">
<div class="card-title">Stucki</div>
<div class="card-desc">Similar to JJN with modified weights (1981)</div>
<div class="card-time">Processing time: 7.83s</div>
</div>
</div>
<div class="card">
<img src="_DSC2637-sterling_sierra.png" alt="Sierra" onclick="showFullscreen(this)">
<div class="card-info">
<div class="card-title">Sierra</div>
<div class="card-desc">Full Sierra dithering, balanced results</div>
<div class="card-time">Processing time: 6.75s</div>
</div>
</div>
<div class="card">
<img src="_DSC2637-sterling_sierra_lite.png" alt="Sierra Lite" onclick="showFullscreen(this)">
<div class="card-info">
<div class="card-title">Sierra Lite</div>
<div class="card-desc">Faster Sierra variant with smaller kernel</div>
<div class="card-time">Processing time: 3.03s</div>
</div>
</div>
<div class="card">
<img src="_DSC2637-sterling_burkes.png" alt="Burkes" onclick="showFullscreen(this)">
<div class="card-info">
<div class="card-title">Burkes</div>
<div class="card-desc">Simplified two-row error diffusion</div>
<div class="card-time">Processing time: 5.21s</div>
</div>
</div>
<div class="card">
<img src="_DSC2637-sterling_bayer2.png" alt="Ordered (Bayer 2x2)" onclick="showFullscreen(this)">
<div class="card-info">
<div class="card-title">Ordered (Bayer 2x2)</div>
<div class="card-desc">Ordered dithering with 2x2 Bayer matrix</div>
<div class="card-time">Processing time: 2.05s</div>
</div>
</div>
<div class="card">
<img src="_DSC2637-sterling_bayer4.png" alt="Ordered (Bayer 4x4)" onclick="showFullscreen(this)">
<div class="card-info">
<div class="card-title">Ordered (Bayer 4x4)</div>
<div class="card-desc">Ordered dithering with 4x4 Bayer matrix</div>
<div class="card-time">Processing time: 2.05s</div>
</div>
</div>
<div class="card">
<img src="_DSC2637-sterling_bayer8.png" alt="Ordered (Bayer 8x8)" onclick="showFullscreen(this)">
<div class="card-info">
<div class="card-title">Ordered (Bayer 8x8)</div>
<div class="card-desc">Ordered dithering with 8x8 Bayer matrix</div>
<div class="card-time">Processing time: 2.04s</div>
</div>
</div>
<div class="card">
<img src="_DSC2637-sterling_bayer4_strong.png" alt="Ordered (Bayer 4x4 Strong)" onclick="showFullscreen(this)">
<div class="card-info">
<div class="card-title">Ordered (Bayer 4x4 Strong)</div>
<div class="card-desc">Bayer 4x4 with increased dithering strength</div>
<div class="card-time">Processing time: 2.03s</div>
</div>
</div>
</div>
<div class="fullscreen" onclick="hideFullscreen()">
<img src="" id="fullscreen-img">
</div>
<script>
function showFullscreen(img) {
document.getElementById('fullscreen-img').src = img.src;
document.querySelector('.fullscreen').classList.add('active');
}
function hideFullscreen() {
document.querySelector('.fullscreen').classList.remove('active');
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') hideFullscreen();
});
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

332
dither_test/preview.py Executable file
View file

@ -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('<<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()

85
src/display.py Normal file
View file

@ -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.example.com")
IMMICH_API_KEY = os.environ.get("IMMICH_API_KEY", "REDACTED_IMMICH_API_KEY")
HA_URL = os.environ.get("HA_URL", "https://homeassistant.example.com")
HA_TOKEN = os.environ.get("HA_TOKEN", "REDACTED_HA_TOKEN")
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()

25
src/lib/homeassistant.py Normal file
View file

@ -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

260
src/lib/immich.py Normal file
View file

@ -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)

54
src/lib/progress.py Normal file
View file

@ -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}")

Binary file not shown.

Binary file not shown.

View file

View file

@ -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()

View file

@ -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))

Binary file not shown.

Binary file not shown.

2
sync.sh Executable file
View file

@ -0,0 +1,2 @@
#!/bin/bash
rsync -avz --progress src/ andras@192.168.0.81:~/frame/