Improvements and notebooks

This commit is contained in:
Andras Schmelczer 2026-04-26 21:05:16 +01:00
parent 84f8456fff
commit f6b0ba5754
34 changed files with 2668 additions and 1373 deletions

View file

@ -30,7 +30,7 @@ python3 display.py --saturation 1.5 --contrast 1.1 --gamma 0.85
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). Asset is only marked displayed after a successful download.
- `_load_history()` / `_save_history()` track displayed photos in `photo_history.json` to avoid repeats (resets after 7 days). Asset is only marked displayed after a successful download.
- `_pick_weighted_random()` biases selection: 20% favorites, 50% recently-added (last 30 days, by Immich `createdAt`), otherwise uniform random
- Filters photos by orientation (portrait/landscape) based on EXIF data including rotation tags. Raises if nothing matches the requested orientation.
- Downloads preview-size thumbnails, not originals
@ -49,6 +49,13 @@ python3 display.py --saturation 1.5 --contrast 1.1 --gamma 0.85
**`src/lib/progress.py`** — Simple terminal progress bar.
**`notebooks/`** — Off-Pi observable comparisons covering pipeline stages. Run via the
uv-managed env (`uv run jupyter lab notebooks/...`). The notebooks share `_helpers.py`
(bootstrap, Immich client, pool fetch, image cache) and `_dither.py` (migrated from the
former `dither_test/`):
- `crop_compare.ipynb` — face-aware crop vs. centre crop on the most-divergent picks
- `dither_compare.ipynb` — error-diffusion + ordered dithering algorithms with timing
## Key Constraints
- **Always call `epd.sleep()` after display** — the driver uses a try/finally pattern for this

View file

@ -1,164 +0,0 @@
# 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) |

View file

@ -1,16 +0,0 @@
# 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',
]

View file

@ -1,512 +0,0 @@
#!/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()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

View file

@ -1,269 +0,0 @@
<!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.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

View file

@ -1,332 +0,0 @@
#!/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()

102
notebooks/_helpers.py Normal file
View file

@ -0,0 +1,102 @@
"""Shared helpers for the frame project notebooks.
Each notebook should call `bootstrap()` first it puts `src/lib/` on the import
path and stubs `waveshare_epd.epdconfig` so the production helpers can be
imported without trying to claim GPIO pins.
"""
from __future__ import annotations
import contextlib
import io
import os
import random
import sys
import tempfile
from pathlib import Path
from types import ModuleType
from typing import Callable, Iterable
REPO = Path(__file__).resolve().parent.parent
CACHE_DIR = Path(tempfile.gettempdir()) / "frame_notebook"
DEFAULT_PEOPLE = ("Me", "Ruby")
DEFAULT_IMMICH_URL = "https://immich.example.com"
DEFAULT_IMMICH_API_KEY = "REDACTED_IMMICH_API_KEY"
def bootstrap() -> None:
"""Make production lib + the migrated dither module importable, off-Pi safe."""
for p in (REPO / "src" / "lib", REPO / "notebooks"):
sp = str(p)
if sp not in sys.path:
sys.path.insert(0, sp)
sys.modules.setdefault("waveshare_epd.epdconfig", ModuleType("waveshare_epd.epdconfig"))
def immich_client():
from immich import ImmichClient
return ImmichClient(
os.environ.get("IMMICH_URL", DEFAULT_IMMICH_URL),
os.environ.get("IMMICH_API_KEY", DEFAULT_IMMICH_API_KEY),
)
def is_landscape(asset: dict) -> bool:
exif = asset.get("exifInfo") or {}
w, h = exif.get("exifImageWidth") or 0, exif.get("exifImageHeight") or 0
if exif.get("orientation") in (6, 8, "6", "8"):
w, h = h, w
return w > h > 0
def fetch_pool(client, names: Iterable[str] = DEFAULT_PEOPLE, pool_size: int = 500,
seed: int = 7, filter_fn: Callable[[dict], bool] = is_landscape) -> list[dict]:
person_ids = [pid for n in names if (pid := client.get_person_id(n))]
if not person_ids:
raise ValueError(f"no people found: {list(names)}")
assets = client.search_assets_by_people(person_ids)
filtered = [a for a in assets if filter_fn(a)]
rng = random.Random(seed)
return rng.sample(filtered, min(pool_size, len(filtered)))
def download_image(client, asset: dict):
"""Download (cached) and open as PIL RGB Image."""
from PIL import Image
CACHE_DIR.mkdir(exist_ok=True)
dest = CACHE_DIR / f"{asset['id']}.jpg"
if not dest.exists():
client.download_asset(asset["id"], dest)
return Image.open(dest).convert("RGB")
@contextlib.contextmanager
def silenced():
"""Suppress the production code's print() chatter during batch loops."""
with contextlib.redirect_stdout(io.StringIO()):
yield
def show_grid(rows: list[list], titles: list[list[str]], figsize_scale=(4.4, 3.0),
suptitle: str | None = None):
"""Render a 2-D image grid with matplotlib. `rows` is list-of-lists of PIL/np images."""
import matplotlib.pyplot as plt
n_rows, n_cols = len(rows), max(len(r) for r in rows)
fig, axes = plt.subplots(n_rows, n_cols,
figsize=(figsize_scale[0] * n_cols, figsize_scale[1] * n_rows))
if n_rows == 1:
axes = [axes] if n_cols == 1 else [list(axes)]
elif n_cols == 1:
axes = [[ax] for ax in axes]
for i, (row, row_titles) in enumerate(zip(rows, titles)):
for j in range(n_cols):
ax = axes[i][j]
if j < len(row) and row[j] is not None:
ax.imshow(row[j])
ax.set_title(row_titles[j], fontsize=10)
ax.axis("off")
if suptitle:
fig.suptitle(suptitle, fontsize=12)
plt.tight_layout()
return fig

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,182 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Dithering algorithm comparison\n",
"\n",
"Migrated from `dither_test/`. The 6-colour ACeP palette can only show Black, White, Yellow,\n",
"Red, Blue, Green — so the dithering algorithm choice has a big effect on perceived image\n",
"quality. This notebook applies a curated set of error-diffusion and ordered-dithering\n",
"algorithms to a few real photos from Immich and shows them side-by-side, with timing.\n",
"\n",
"Production uses Atkinson with perceptual weighting (`atkinson_weighted` is the closest\n",
"match — the actual production version is numba-JIT'd, equivalent to `atkinson_fast`). This\n",
"notebook is the place to evaluate alternatives if you want to switch.\n",
"\n",
"Algorithm taxonomy:\n",
"- **Error diffusion** (`floyd_steinberg`, `atkinson`, `jarvis`, `stucki`, `sierra`,\n",
" `sierra_lite`, `burkes`) — quantise pixels left-to-right, push the rounding error onto\n",
" unprocessed neighbours.\n",
"- **Ordered** (`bayer4`, `bayer8`, `bayer4_strong`) — add a deterministic threshold pattern\n",
" before quantising. No error spreading; pattern is independent of content.\n",
"- **PIL built-ins** (`pil_fs`, `pil_none`) — for reference."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import sys\n",
"sys.path.insert(0, '.')\n",
"from _helpers import bootstrap, immich_client, fetch_pool, download_image, silenced, show_grid\n",
"bootstrap()\n",
"\n",
"import time\n",
"import numpy as np\n",
"from PIL import Image\n",
"from waveshare_epd.epd7in3e import EPD_WIDTH, EPD_HEIGHT, _crop_center\n",
"from _dither import DITHER_ALGORITHMS, apply_dithering, PALETTE_RGB, PALETTE_NAMES\n",
"\n",
"# Pure-Python algorithms run at ~30s per 800x480 image; keep a curated subset by default.\n",
"# Toggle SHOW_ALL = True to run everything (will take several minutes).\n",
"DEFAULT_ALGOS = ['atkinson_fast', 'atkinson', 'atkinson_weighted',\n",
" 'floyd_steinberg', 'floyd_steinberg_weighted',\n",
" 'jarvis', 'sierra_lite', 'burkes',\n",
" 'bayer4', 'bayer8', 'pil_fs', 'none']\n",
"SHOW_ALL = False\n",
"ALGOS = list(DITHER_ALGORITHMS.keys()) if SHOW_ALL else DEFAULT_ALGOS\n",
"\n",
"N_PHOTOS = 2 # one image cycle per photo\n",
"SEED = 11"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"client = immich_client()\n",
"pool_assets = fetch_pool(client, pool_size=20, seed=SEED)\n",
"\n",
"with silenced():\n",
" sources = []\n",
" for asset in pool_assets[:N_PHOTOS]:\n",
" img = download_image(client, asset)\n",
" sources.append((asset, _crop_center(img, EPD_WIDTH, EPD_HEIGHT)))\n",
"\n",
"for asset, _ in sources:\n",
" print(asset.get('originalFileName') or asset['id'])\n",
"\n",
"# Render the 6-colour palette as a tiny banner so the colour budget is visible.\n",
"swatch_h = 60\n",
"swatch_w = 60\n",
"palette_strip = np.zeros((swatch_h, swatch_w * len(PALETTE_RGB), 3), dtype=np.uint8)\n",
"for i, rgb in enumerate(PALETTE_RGB):\n",
" palette_strip[:, i * swatch_w:(i + 1) * swatch_w] = rgb\n",
"Image.fromarray(palette_strip)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"results = [] # list of dict per (photo, algo)\n",
"for asset, source in sources:\n",
" name = asset.get('originalFileName') or asset['id']\n",
" print(f'[{name}]')\n",
" photo_results = []\n",
" for algo in ALGOS:\n",
" info = DITHER_ALGORITHMS[algo]\n",
" t0 = time.perf_counter()\n",
" out = apply_dithering(source, algo)\n",
" dt = time.perf_counter() - t0\n",
" photo_results.append({'algo': algo, 'name': info['name'], 'image': out, 'duration': dt})\n",
" print(f' {info[\"name\"]:32s} {dt:6.2f}s')\n",
" results.append({'asset': asset, 'source': source, 'algos': photo_results})"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# One grid per photo: original + every algorithm.\n",
"import matplotlib.pyplot as plt\n",
"for entry in results:\n",
" panels = [entry['source']] + [r['image'] for r in entry['algos']]\n",
" titles = ['original (cropped)'] + [f\"{r['name']}\\n{r['duration']:.2f}s\" for r in entry['algos']]\n",
" cols = 4\n",
" rows = (len(panels) + cols - 1) // cols\n",
" fig, axes = plt.subplots(rows, cols, figsize=(5.0 * cols, 3.2 * rows))\n",
" axes = np.atleast_2d(axes)\n",
" name = entry['asset'].get('originalFileName') or entry['asset']['id']\n",
" fig.suptitle(name, fontsize=12)\n",
" for k in range(rows * cols):\n",
" ax = axes[k // cols][k % cols]\n",
" if k < len(panels):\n",
" ax.imshow(panels[k])\n",
" ax.set_title(titles[k], fontsize=10)\n",
" ax.axis('off')\n",
" plt.tight_layout()\n",
" plt.show()"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Per-algorithm summary across both photos: mean runtime + a single representative panel.\n",
"from collections import defaultdict\n",
"import matplotlib.pyplot as plt\n",
"\n",
"agg = defaultdict(list)\n",
"for entry in results:\n",
" for r in entry['algos']:\n",
" agg[r['algo']].append(r['duration'])\n",
"\n",
"print(f\"{'algorithm':32s} {'avg time':>9s} description\")\n",
"for algo in ALGOS:\n",
" info = DITHER_ALGORITHMS[algo]\n",
" avg = np.mean(agg[algo])\n",
" print(f\"{info['name']:32s} {avg:>8.2f}s {info['description']}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Picking an algorithm\n",
"\n",
"- **Photographs** — `atkinson_fast` (production), `atkinson_weighted`, or\n",
" `floyd_steinberg_weighted`. Atkinson loses some detail (only diffuses 6/8 of the error)\n",
" but gives cleaner edges; FS preserves detail at the cost of more visible noise.\n",
"- **Graphics / illustrations / posters** — `bayer4` or `bayer8`. The pattern is regular\n",
" (no \"wormy\" artifacts) and well-suited to large flat regions.\n",
"- **Speed-critical paths** — `atkinson_fast` (numba-JIT). Pure-Python error-diffusion\n",
" algorithms are ~150× slower than the JIT'd version on this resolution.\n",
"\n",
"The `none` row (PIL nearest-colour) shows what happens with no dithering at all — useful as\n",
"a baseline to confirm the dithering is buying you something.\n",
"\n",
"**To compare more algorithms** set `SHOW_ALL = True` in the setup cell. Expect several\n",
"minutes of CPU time per photo for the slow pure-Python implementations."
]
}
],
"metadata": {
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
"language_info": {"name": "python"}
},
"nbformat": 4,
"nbformat_minor": 5
}

21
pyproject.toml Normal file
View file

@ -0,0 +1,21 @@
[project]
name = "frame"
version = "0.1.0"
description = "E-ink photo frame for Raspberry Pi Zero 2W"
requires-python = ">=3.11,<3.14"
dependencies = [
"numpy>=1.26",
"pillow>=10",
"opencv-python>=4.8",
"numba>=0.60",
]
[dependency-groups]
notebook = [
"matplotlib>=3.8",
"jupyterlab>=4.2",
"ipykernel>=6.29",
]
[tool.uv]
default-groups = ["notebook"]

View file

@ -12,6 +12,7 @@ sys.path.append(str(Path(__file__).parent / "lib"))
from immich import ImmichClient, get_random_photo_of_people, get_random_photo_from_album
from homeassistant import HomeAssistantClient
from overlay import format_age, format_location
from crop import face_aware_crop
# waveshare_epd is imported lazily after the lock — its epdconfig claims
# GPIO pins at import time, so two overlapping invocations would both crash
# on "GPIO busy" before reaching the flock below.
@ -21,7 +22,7 @@ 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"]
HA_PRESENCE = {"Andras": "person.andras", "Ruby": "person.ruby"}
def main() -> None:
@ -47,12 +48,12 @@ def main() -> None:
now = datetime.now()
print(f"Time: {now.strftime('%H:%M')}")
if 0 <= now.hour < 7:
if 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)]
home = [name for name, eid in HA_PRESENCE.items() if ha.is_person_home(eid)]
if not home:
print("No one home, skipping")
sys.exit(0)
@ -77,6 +78,10 @@ def main() -> None:
try:
epd.init()
img = Image.open(image_path).convert("RGB")
faces = client.get_asset_faces(asset["id"])
print(f"Faces: {len(faces)}")
target_w, target_h = (480, 800) if args.orientation in (90, 270) else (800, 480)
img = face_aware_crop(img, target_w, target_h, faces)
if args.orientation:
img = img.rotate(args.orientation, expand=True)
buf = epd.getbuffer(img, saturation=args.saturation, contrast=args.contrast,

69
src/lib/crop.py Normal file
View file

@ -0,0 +1,69 @@
"""Resize-to-cover then crop, biased toward Immich-detected face boxes."""
import math
from PIL import Image
# Face boxes end at the hairline; extend each box upward by this fraction of
# its own height so the fit-check considers the head, not just the face.
HEAD_EXTENSION = 0.4
def face_aware_crop(image: Image.Image, target_w: int, target_h: int,
faces: list[dict]) -> Image.Image:
"""Resize to cover (target_w, target_h), then crop to keep faces in frame.
Each face dict has imageWidth/imageHeight (the coord-space dims) and
boundingBoxX1/Y1/X2/Y2. Per axis: if every (head-extended) face fits in
the crop we centre on the joint span so all faces are included with hair
clearance on top. If the span doesn't fit, we fall back to the
area-weighted centroid of the unextended boxes that biases toward the
biggest, presumably foreground, face. Plain center crop when no faces.
"""
img_w, img_h = image.size
img_aspect = img_w / img_h
target_aspect = target_w / target_h
if img_aspect < target_aspect:
new_w = target_w
new_h = math.ceil(target_w / img_aspect)
else:
new_w = math.ceil(target_h * img_aspect)
new_h = target_h
resized = image.resize((new_w, new_h), Image.LANCZOS)
cx, cy = new_w / 2, new_h / 2
if faces:
boxes = []
for f in faces:
sx = new_w / (f.get("imageWidth") or img_w)
sy = new_h / (f.get("imageHeight") or img_h)
x1 = f["boundingBoxX1"] * sx
y1 = f["boundingBoxY1"] * sy
x2 = f["boundingBoxX2"] * sx
y2 = f["boundingBoxY2"] * sy
area = max(0.0, (x2 - x1) * (y2 - y1))
boxes.append((x1, y1, x2, y2, area))
x_lo = min(b[0] for b in boxes)
x_hi = max(b[2] for b in boxes)
if x_hi - x_lo <= target_w:
cx = (x_lo + x_hi) / 2
else:
cx = _weighted_center(boxes, 0, 2)
y_lo_ext = min(b[1] - (b[3] - b[1]) * HEAD_EXTENSION for b in boxes)
y_hi = max(b[3] for b in boxes)
if y_hi - y_lo_ext <= target_h:
cy = (y_lo_ext + y_hi) / 2
else:
cy = _weighted_center(boxes, 1, 3)
x_off = max(0, min(int(cx - target_w / 2), new_w - target_w))
y_off = max(0, min(int(cy - target_h / 2), new_h - target_h))
return resized.crop((x_off, y_off, x_off + target_w, y_off + target_h))
def _weighted_center(boxes: list[tuple], lo: int, hi: int) -> float:
total = sum(b[4] for b in boxes) or 1.0
return sum((b[lo] + b[hi]) / 2 * b[4] for b in boxes) / total

View file

@ -16,11 +16,11 @@ CACHE_DIR = Path(tempfile.gettempdir()) / "frame_cache"
def _cache_get(key: str) -> list[dict] | None:
path = CACHE_DIR / f"{key}.json"
if not path.exists() or time.time() - path.stat().st_mtime > 3600:
return None
try:
if time.time() - path.stat().st_mtime > 3600:
return None
return json.loads(path.read_text())
except (json.JSONDecodeError, OSError):
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
@ -29,45 +29,26 @@ def _cache_set(key: str, value: list[dict]) -> None:
(CACHE_DIR / f"{key}.json").write_text(json.dumps(value))
class PhotoHistory:
"""Track displayed photos to avoid repeats. Clears after 7 days."""
def _load_history() -> tuple[set[str], datetime]:
"""Load (displayed, created_at). Resets if missing/corrupt or older than 7 days."""
try:
data = json.loads(HISTORY_FILE.read_text())
created_at = datetime.fromisoformat(data["created_at"])
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) - created_at <= timedelta(days=7):
return set(data.get("displayed", [])), created_at
print("Photo history expired (>7 days), clearing...")
except (FileNotFoundError, json.JSONDecodeError, ValueError, KeyError):
pass
return set(), datetime.now(timezone.utc)
def __init__(self, path: Path = HISTORY_FILE):
self.path = path
self.displayed: set[str] = set()
self.created_at = datetime.now(timezone.utc)
self._load()
def _load(self) -> None:
if not self.path.exists():
self._save()
return
try:
data = json.loads(self.path.read_text())
self.created_at = datetime.fromisoformat(data["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=7):
print("Photo history expired (>7 days), clearing...")
self.created_at = datetime.now(timezone.utc)
self._save()
else:
self.displayed = set(data.get("displayed", []))
except (json.JSONDecodeError, ValueError, KeyError):
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]
def _save_history(displayed: set[str], created_at: datetime) -> None:
HISTORY_FILE.write_text(json.dumps({
"created_at": created_at.isoformat(),
"displayed": sorted(displayed),
}, indent=2))
@dataclass
@ -79,14 +60,13 @@ class ImmichClient:
self.base_url = self.base_url.rstrip("/")
def _request(self, method: str, endpoint: str, data: dict | None = None) -> dict:
url = f"{self.base_url}/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)
req = Request(f"{self.base_url}/api{endpoint}", data=body, headers=headers, method=method)
with urlopen_with_retry(req, timeout=30) as resp:
return json.loads(resp.read().decode())
@ -105,16 +85,15 @@ class ImmichClient:
items = []
page = 1
while True:
result = self._request("POST", "/search/metadata", {
assets = 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"):
}).get("assets", {})
items.extend(assets.get("items", []))
if not assets.get("nextPage"):
break
page += 1
_cache_set(key, items)
@ -127,6 +106,19 @@ class ImmichClient:
dest.write_bytes(resp.read())
return dest
def get_asset_faces(self, asset_id: str) -> list[dict]:
"""Face boxes for people assigned on this asset.
Each face has imageWidth, imageHeight, boundingBoxX1/Y1/X2/Y2.
Unassigned faces are skipped they're often false positives (posters,
reflections) and shouldn't drag the crop off the real subjects.
"""
asset = self._request("GET", f"/assets/{asset_id}")
faces = []
for person in asset.get("people") or []:
faces.extend(person.get("faces") or [])
return faces
def get_album_id(self, name: str) -> str | None:
for album in self._request("GET", "/albums"):
if album["albumName"].lower() == name.lower():
@ -159,8 +151,35 @@ def _filter_by_orientation(assets: list[dict], portrait: bool) -> list[dict]:
return out
def _on_this_day_candidates(assets: list[dict]) -> list[dict]:
"""Photos taken on today's month-day in past years, with a ±3-day fallback."""
today = datetime.now(timezone.utc).date()
dated = []
for a in assets:
exif = a.get("exifInfo") or {}
date_str = exif.get("dateTimeOriginal") or a.get("fileCreatedAt")
if not date_str:
continue
try:
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")).date()
except (ValueError, AttributeError):
continue
if dt.year < today.year:
dated.append((a, dt))
exact = [a for a, dt in dated if (dt.month, dt.day) == (today.month, today.day)]
if exact:
return exact
nearby_md = set()
for offset in range(-3, 4):
d = today + timedelta(days=offset)
nearby_md.add((d.month, d.day))
return [a for a, dt in dated if (dt.month, dt.day) in nearby_md]
def _pick_weighted_random(assets: list[dict]) -> dict:
"""Pick random asset, biased towards favorites and recently added photos."""
"""Pick random asset, biased towards on-this-day memories, favorites, and recents."""
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
favorites = [a for a in assets if a.get("isFavorite")]
recent = []
@ -170,10 +189,18 @@ def _pick_weighted_random(assets: list[dict]) -> dict:
recent.append(a)
except (ValueError, AttributeError):
pass
on_this_day = _on_this_day_candidates(assets)
candidates = [(favorites, 0.2), (recent, 0.4), (assets, 0.4)]
pools, weights = zip(*[(p, w) for p, w in candidates if p])
pool = random.choices(pools, weights=weights)[0]
candidates = [
("on this day", on_this_day, 0.10),
("favorites", favorites, 0.18),
("recent", recent, 0.36),
("all", assets, 0.36),
]
active = [(label, pool, w) for label, pool, w in candidates if pool]
print("Pool sizes: " + ", ".join(f"{label}={len(pool)}" for label, pool, _ in active))
label, pool, _ = random.choices(active, weights=[w for _, _, w in active])[0]
print(f"Picked pool: {label} ({len(pool)} candidates)")
return random.choice(pool)
@ -184,8 +211,8 @@ def _pick_and_download(client: ImmichClient, assets: list[dict],
if not filtered:
raise ValueError(f"No {'portrait' if portrait else 'landscape'} photos in {source_label}")
history = PhotoHistory()
candidates = history.filter_new(filtered)
displayed, created_at = _load_history()
candidates = [a for a in filtered if a.get("id") not in displayed]
if not candidates:
print(f"All {len(filtered)} photos shown, picking from full list")
candidates = filtered
@ -195,7 +222,8 @@ def _pick_and_download(client: ImmichClient, assets: list[dict],
asset = _pick_weighted_random(candidates)
dest = Path(tempfile.gettempdir()) / "immich_photo.jpg"
path = client.download_asset(asset["id"], dest)
history.mark_displayed(asset["id"])
displayed.add(asset["id"])
_save_history(displayed, created_at)
return path, asset

View file

@ -3,17 +3,13 @@ import time
from urllib.error import URLError
from urllib.request import Request, urlopen
RETRY_DELAYS = (3, 10)
def urlopen_with_retry(req: Request, timeout: int = 30):
"""urlopen wrapper that retries transient network failures."""
last_err: Exception | None = None
for attempt in range(len(RETRY_DELAYS) + 1):
"""urlopen wrapper that retries transient network failures (3s, 10s backoff)."""
for delay in (3, 10, None):
try:
return urlopen(req, timeout=timeout)
except (URLError, TimeoutError) as e:
last_err = e
if attempt < len(RETRY_DELAYS):
time.sleep(RETRY_DELAYS[attempt])
raise last_err
except (URLError, TimeoutError):
if delay is None:
raise
time.sleep(delay)

View file

@ -52,7 +52,9 @@ def format_age(asset: dict) -> str | None:
for n, unit in ((365, "year"), (30, "month"), (7, "week")):
if days >= n:
count = max(1, days // n)
return f"{count} {unit}{'s' if count > 1 else ''} ago"
if count == 1:
return f"Last {unit}"
return f"{count} {unit}s ago"
def format_location(asset: dict) -> str | None:

View file

@ -159,23 +159,20 @@ class EPD:
epdconfig.digital_write(self.reset_pin, 1)
epdconfig.delay_ms(20)
def send_command(self, command):
epdconfig.digital_write(self.dc_pin, 0)
def _spi(self, dc: int, payload, batch: bool = False):
epdconfig.digital_write(self.dc_pin, dc)
epdconfig.digital_write(self.cs_pin, 0)
epdconfig.spi_writebyte([command])
(epdconfig.spi_writebyte2 if batch else epdconfig.spi_writebyte)(payload)
epdconfig.digital_write(self.cs_pin, 1)
def send_command(self, command):
self._spi(0, [command])
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)
self._spi(1, [data])
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)
self._spi(1, data, batch=True)
def wait_busy(self):
while epdconfig.digital_read(self.busy_pin) == 0:

1871
uv.lock generated Normal file

File diff suppressed because it is too large Load diff