Compare commits

...

5 commits

36 changed files with 3432 additions and 2026 deletions

View file

@ -30,23 +30,32 @@ 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)
- `_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
- `_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
- Asset lists (people-search and album) are cached on disk in `/tmp/frame_cache/` for 1 hour
- `urlopen` calls retry transient failures twice (3s, 10s backoff)
**`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)
- Enhances saturation/contrast/gamma for e-ink (caller passes values; CLI defaults live in `display.py`: saturation=1.3, contrast=1.05, gamma=0.90)
- Atkinson dithering to 6-color palette using numba JIT; produces palette indices directly (no Pillow quantize round-trip)
- Packs into 4-bit-per-pixel buffer (two pixels per byte) via numpy
**`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.
**`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
@ -55,4 +64,5 @@ python3 display.py --saturation 1.5 --contrast 1.1 --gamma 0.85
- **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
- **Single-instance lock** at `/tmp/frame.lock` (fcntl) — overlapping cron runs exit cleanly
- `sys.path.append` is used to add `lib/` to the path from display.py

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.schmelczer.dev"
DEFAULT_IMMICH_API_KEY = "6crxVS1JLTJxsfGlzVhN2kefdL4EP7HPkkoMk9L6ZOE"
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

File diff suppressed because one or more lines are too long

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

@ -1,41 +1,28 @@
#!/usr/bin/env python3
import argparse
import fcntl
import os
import sys
from datetime import datetime
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from PIL import Image
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
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.
IMMICH_URL = os.environ.get("IMMICH_URL", "https://immich.schmelczer.dev")
IMMICH_API_KEY = os.environ.get("IMMICH_API_KEY", "6crxVS1JLTJxsfGlzVhN2kefdL4EP7HPkkoMk9L6ZOE")
HA_URL = os.environ.get("HA_URL", "https://homeassistant.schmelczer.dev")
HA_TOKEN = os.environ.get("HA_TOKEN", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJmZjk3OTNmOWMzOWU0YjdmYmRjYTc5YmJkMTUyODcyNSIsImlhdCI6MTc2OTIwMjg1NCwiZXhwIjoyMDg0NTYyODU0fQ.IiL_1vTrGMlOoPMksN6lAopE0aInlY_wRnL4Jc-CeBs")
HA_PRESENCE_ENTITIES = ["person.andras", "person.ruby"]
DEFAULT_SATURATION = 1.3
DEFAULT_CONTRAST = 1.05
DEFAULT_GAMMA = 0.90
def display_image(image_path: Path, orientation: int, saturation: float,
contrast: float, gamma: float, enhance: bool) -> None:
epd = epd7in3e.EPD()
try:
epd.init()
img = Image.open(image_path).convert("RGB")
if orientation:
img = img.rotate(orientation, expand=True)
buf = epd.getbuffer(img, saturation=saturation, contrast=contrast,
gamma=gamma, enhance=enhance)
epd.display(buf)
finally:
epd.sleep()
HA_PRESENCE = {"Andras": "person.andras", "Ruby": "person.ruby"}
def main() -> None:
@ -45,21 +32,28 @@ def main() -> None:
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")
parser.add_argument("--saturation", type=float, default=1.3)
parser.add_argument("--contrast", type=float, default=1.05)
parser.add_argument("--gamma", type=float, default=0.90)
args = parser.parse_args()
lock_fd = open("/tmp/frame.lock", "w")
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print("Another instance running, skipping")
sys.exit(0)
from waveshare_epd import epd7in3e
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)
@ -67,16 +61,35 @@ def main() -> None:
client = ImmichClient(IMMICH_URL, IMMICH_API_KEY)
if args.album:
image_path = get_random_photo_from_album(client, args.album, args.orientation)
image_path, asset = 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)
image_path, asset = get_random_photo_of_people(client, names, args.orientation)
print(f"People: {', '.join(names)}")
left_text = format_age(asset)
right_text = format_location(asset)
if left_text or right_text:
print(f"Overlay: {left_text or '-'} | {right_text or '-'}")
try:
display_image(image_path, args.orientation, args.saturation,
args.contrast, args.gamma, not args.no_enhance)
epd = epd7in3e.EPD()
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,
gamma=args.gamma, left_text=left_text, right_text=right_text,
orientation=args.orientation)
epd.display(buf)
finally:
epd.sleep()
finally:
image_path.unlink(missing_ok=True)

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

@ -1,6 +1,8 @@
#!/usr/bin/env python3
import json
from urllib.request import Request, urlopen
from urllib.request import Request
from net import urlopen_with_retry
class HomeAssistantClient:
@ -8,18 +10,14 @@ class HomeAssistantClient:
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:
req = Request(
f"{self.base_url}/api/states/{entity_id}",
headers={"Authorization": f"Bearer {self.token}"},
)
try:
return self.get_state(entity_id).get("state") == "home"
with urlopen_with_retry(req, timeout=30) as resp:
return json.loads(resp.read().decode()).get("state") == "home"
except Exception as e:
print(f"Failed to check {entity_id}: {e}")
return False

View file

@ -2,247 +2,244 @@
import json
import random
import tempfile
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.request import Request
from progress import ProgressBar
from net import urlopen_with_retry
HISTORY_FILE = Path(__file__).parent.parent / "photo_history.json"
HISTORY_MAX_AGE_DAYS = 7
CACHE_DIR = Path(tempfile.gettempdir()) / "frame_cache"
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
def _cache_get(key: str) -> list[dict] | None:
path = CACHE_DIR / f"{key}.json"
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()
if time.time() - path.stat().st_mtime > 3600:
return None
return json.loads(path.read_text())
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
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),
def _cache_set(key: str, value: list[dict]) -> None:
CACHE_DIR.mkdir(exist_ok=True)
(CACHE_DIR / f"{key}.json").write_text(json.dumps(value))
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 _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))
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('/')}"
def __post_init__(self):
self.base_url = self.base_url.rstrip("/")
def _request(self, method: str, endpoint: str, data: dict | None = None) -> dict:
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())
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())
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():
for person in self._request("GET", "/people")["people"]:
if person["name"].lower() == name.lower():
return person["id"]
return None
def search_assets_by_people(self, person_ids: list[str]) -> list[dict]:
key = "people_" + "_".join(sorted(person_ids))
cached = _cache_get(key)
if cached is not None:
return cached
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)
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"
def download_asset(self, asset_id: str, dest: Path) -> Path:
url = f"{self.base_url}/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:
with urlopen_with_retry(req, timeout=30) as resp:
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():
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 get_album_assets(self, album_id: str) -> list[dict]:
key = f"album_{album_id}"
cached = _cache_get(key)
if cached is not None:
return cached
assets = self._request("GET", f"/albums/{album_id}").get("assets", [])
_cache_set(key, assets)
return assets
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
"""Keep assets matching the requested orientation. Skips assets without EXIF dimensions."""
out = []
for a in assets:
exif = a.get("exifInfo") or {}
w = exif.get("exifImageWidth") or 0
h = exif.get("exifImageHeight") or 0
if not (w and h):
continue
if exif.get("orientation") in (6, 8, "6", "8"):
w, h = h, w
if (h > w) == portrait:
out.append(a)
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, 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)
"""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 = []
for asset in assets:
date_str = asset.get("fileCreatedAt") or asset.get("createdAt", "")
for a in assets:
try:
if datetime.fromisoformat(date_str.replace("Z", "+00:00")) >= one_week_ago:
recent.append(asset)
if datetime.fromisoformat(a.get("createdAt", "").replace("Z", "+00:00")) >= cutoff:
recent.append(a)
except (ValueError, AttributeError):
pass
on_this_day = _on_this_day_candidates(assets)
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)
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)
def _download_random_asset(client: ImmichClient, assets: list[dict]) -> Path:
history = get_history()
new_assets = history.filter_new(assets)
def _pick_and_download(client: ImmichClient, assets: list[dict],
orientation: int, source_label: str) -> tuple[Path, dict]:
portrait = orientation in (90, 270)
filtered = _filter_by_orientation(assets, portrait)
if not filtered:
raise ValueError(f"No {'portrait' if portrait else 'landscape'} photos in {source_label}")
if new_assets:
print(f"Photos: {len(new_assets)} new / {len(assets)} total")
asset = _pick_weighted_random(new_assets)
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
else:
print(f"All {len(assets)} photos shown, picking from full list")
asset = _pick_weighted_random(assets)
print(f"Photos: {len(candidates)} new / {len(filtered)} total")
history.mark_displayed(asset["id"])
asset = _pick_weighted_random(candidates)
dest = Path(tempfile.gettempdir()) / "immich_photo.jpg"
return client.download_asset(asset["id"], dest)
path = client.download_asset(asset["id"], dest)
displayed.add(asset["id"])
_save_history(displayed, created_at)
return path, asset
def get_random_photo_of_people(client: ImmichClient, names: list[str], orientation: int = 0) -> Path:
def get_random_photo_of_people(client: ImmichClient, names: list[str], orientation: int = 0) -> tuple[Path, dict]:
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)
return _pick_and_download(client, assets, orientation, f"photos for {', '.join(names)}")
def get_random_photo_from_album(client: ImmichClient, album_name: str, orientation: int = 0) -> Path:
def get_random_photo_from_album(client: ImmichClient, album_name: str, orientation: int = 0) -> tuple[Path, dict]:
album_id = client.get_album_id(album_name)
if not album_id:
raise ValueError(f"Album not found: {album_name}")
@ -251,10 +248,4 @@ def get_random_photo_from_album(client: ImmichClient, album_name: str, orientati
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)
return _pick_and_download(client, assets, orientation, f"album: {album_name}")

15
src/lib/net.py Normal file
View file

@ -0,0 +1,15 @@
#!/usr/bin/env python3
import time
from urllib.error import URLError
from urllib.request import Request, urlopen
def urlopen_with_retry(req: Request, timeout: int = 30):
"""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):
if delay is None:
raise
time.sleep(delay)

108
src/lib/overlay.py Normal file
View file

@ -0,0 +1,108 @@
"""Text overlay rendering for the e-ink frame.
Paints aliased white-on-black-stroke text into the dithered palette index
array; black/white survive Atkinson dithering so edges stay crisp on e-ink.
"""
import os
from datetime import datetime, timezone
import numpy as np
from PIL import Image, ImageDraw, ImageFont
FONT_CANDIDATES = (
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
"/System/Library/Fonts/Helvetica.ttc",
)
PALETTE_BLACK = 0
PALETTE_WHITE = 1
def _load_font(size: int) -> ImageFont.ImageFont:
for path in FONT_CANDIDATES:
if os.path.exists(path):
return ImageFont.truetype(path, size)
return ImageFont.load_default()
def format_age(asset: dict) -> str | None:
"""Photo capture age as 'N days/weeks/months/years ago'."""
exif = asset.get("exifInfo") or {}
date_str = exif.get("dateTimeOriginal") or asset.get("fileCreatedAt")
if not date_str:
return None
try:
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
except (ValueError, AttributeError):
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
days = (datetime.now(timezone.utc) - dt).days
if days < 0:
return None
if days == 0:
return "Today"
if days == 1:
return "Yesterday"
if days < 7:
return f"{days} days ago"
for n, unit in ((365, "year"), (30, "month"), (7, "week")):
if days >= n:
count = max(1, days // n)
if count == 1:
return f"Last {unit}"
return f"{count} {unit}s ago"
def format_location(asset: dict) -> str | None:
"""Most specific location available from EXIF."""
exif = asset.get("exifInfo") or {}
return exif.get("city") or exif.get("state") or exif.get("country") or None
def render_text_into_indices(indices: np.ndarray,
left_text: str | None,
right_text: str | None,
orientation: int = 0) -> None:
"""Paint white-on-black-stroke text into a (height, width) palette-index array.
Text is laid out viewer-bottom-left/right, then rotated by `orientation`
so labels land at the viewer's bottom regardless of frame mounting.
"""
font_size, margin, stroke_width = 20, 18, 2
buffer_h, buffer_w = indices.shape
if orientation in (90, 270):
view_w, view_h = buffer_h, buffer_w
else:
view_w, view_h = buffer_w, buffer_h
fill_layer = Image.new("L", (view_w, view_h), 0)
full_layer = Image.new("L", (view_w, view_h), 0)
fill_draw = ImageDraw.Draw(fill_layer)
full_draw = ImageDraw.Draw(full_layer)
font = _load_font(font_size)
baseline = view_h - margin
if left_text:
pos = (margin, baseline)
fill_draw.text(pos, left_text, font=font, fill=255, anchor="lb")
full_draw.text(pos, left_text, font=font, fill=255, anchor="lb",
stroke_width=stroke_width, stroke_fill=255)
if right_text:
pos = (view_w - margin, baseline)
fill_draw.text(pos, right_text, font=font, fill=255, anchor="rb")
full_draw.text(pos, right_text, font=font, fill=255, anchor="rb",
stroke_width=stroke_width, stroke_fill=255)
if orientation:
fill_layer = fill_layer.rotate(orientation, expand=True)
full_layer = full_layer.rotate(orientation, expand=True)
fill_mask = np.asarray(fill_layer) >= 128
stroke_mask = (np.asarray(full_layer) >= 128) & ~fill_mask
indices[stroke_mask] = PALETTE_BLACK
indices[fill_mask] = PALETTE_WHITE

View file

@ -1,54 +1,23 @@
"""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):
def __init__(self, total: int, desc: str = ""):
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)
value = min(value, self.total)
percent = int(100 * value / 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}")
filled = int(30 * value / self.total)
bar = "" * filled + "" * (30 - filled)
end = "\n" if value >= self.total else ""
prefix = f"{self.desc}: " if self.desc else ""
print(f"\r{prefix}|{bar}| {percent:3d}%", end=end, flush=True)

View file

@ -1,39 +1,50 @@
#!/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 progress import ProgressBar
from overlay import render_text_into_indices
from . import epdconfig
EPD_WIDTH = 800
EPD_HEIGHT = 480
DEFAULT_SATURATION = 1.4
DEFAULT_CONTRAST = 1.2
DEFAULT_GAMMA = 0.9
# 6-color e-ink encoding: indices 0,1,2,3,5,6 are wire-format colors;
# 4 is reserved/unused (filled with BLACK so nearest-color never picks it).
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
[0, 0, 0], # 0: BLACK
[255, 255, 255], # 1: WHITE
[255, 255, 0], # 2: YELLOW
[255, 0, 0], # 3: RED
[0, 0, 0], # 4: unused
[0, 0, 255], # 5: BLUE
[0, 255, 0], # 6: GREEN
], dtype=np.float64)
PERCEPTUAL_WEIGHTS = np.array([0.299, 0.587, 0.114], dtype=np.float64)
INIT_SEQUENCE = (
(0xAA, [0x49, 0x55, 0x20, 0x08, 0x09, 0x18]),
(0x01, [0x3F]),
(0x00, [0x5F, 0x69]),
(0x03, [0x00, 0x54, 0x00, 0x44]),
(0x05, [0x40, 0x1F, 0x1F, 0x2C]),
(0x06, [0x6F, 0x1F, 0x17, 0x49]),
(0x08, [0x6F, 0x1F, 0x1F, 0x22]),
(0x30, [0x03]),
(0x50, [0x3F]),
(0x60, [0x02, 0x00]),
(0x61, [0x03, 0x20, 0x01, 0xE0]),
(0x84, [0x01]),
(0xE3, [0x2F]),
)
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
def _enhance_for_eink(image: Image.Image, saturation: float,
contrast: float, gamma: float) -> Image.Image:
img = image.convert('RGB')
if saturation != 1.0:
img = ImageEnhance.Color(img).enhance(saturation)
@ -45,11 +56,8 @@ def _enhance_for_eink(image: Image.Image, saturation: float = None,
return img
def _crop_center(image: Image.Image, target_w: int, target_h: int,
show_progress: bool = True) -> Image.Image:
if show_progress:
def _crop_center(image: Image.Image, target_w: int, target_h: int) -> Image.Image:
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
@ -66,18 +74,6 @@ def _crop_center(image: Image.Image, target_w: int, target_h: int,
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
@ -92,14 +88,14 @@ def _find_nearest_color(r, g, b, palette, weights):
@jit(nopython=True, cache=True)
def _atkinson_dither_rows(img, palette, weights, start_row, end_row):
def _atkinson_dither_rows(img, palette, weights, indices, 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)
indices[y, x] = idx
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
@ -127,23 +123,23 @@ def _atkinson_dither_rows(img, palette, weights, start_row, end_row):
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:
def _dither_atkinson(image: Image.Image) -> np.ndarray:
"""Atkinson-dither to the e-ink palette and return a uint8 array of palette indices."""
img = np.array(image.convert('RGB'), dtype=np.float64)
height = img.shape[0]
if show_progress:
height, width = img.shape[:2]
indices = np.zeros((height, width), dtype=np.uint8)
print("Dithering...")
progress = ProgressBar(height, desc="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)
_atkinson_dither_rows(img, PALETTE_RGB, PERCEPTUAL_WEIGHTS, indices, start, end)
progress.set(end)
return Image.fromarray(np.clip(img, 0, 255).astype(np.uint8), 'RGB')
return indices
class EPD:
@ -163,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:
@ -196,106 +189,45 @@ class EPD:
self.wait_busy()
def init(self):
if epdconfig.module_init() != 0:
return -1
epdconfig.module_init()
self.reset()
self.wait_busy()
epdconfig.delay_ms(30)
self.send_command(0xAA)
for v in [0x49, 0x55, 0x20, 0x08, 0x09, 0x18]:
for cmd, data in INIT_SEQUENCE:
self.send_command(cmd)
for v in data:
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)
def getbuffer(self, image, saturation: float, contrast: float, gamma: float,
left_text: str | None = None, right_text: str | None = None,
orientation: int = 0):
image = image.convert('RGB')
imwidth, imheight = image.size
if image.size != (self.width, self.height):
print(f"Input: {image.size[0]}x{image.size[1]}{self.width}x{self.height}")
image = _crop_center(image, self.width, self.height)
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)
indices = _dither_atkinson(image)
if left_text or right_text:
print("Rendering overlay...")
render_text_into_indices(indices, left_text, right_text, orientation)
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
flat = indices.reshape(-1)
return ((flat[0::2].astype(np.uint8) << 4) | flat[1::2].astype(np.uint8)).tolist()
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)

1871
uv.lock generated Normal file

File diff suppressed because it is too large Load diff