diff --git a/server/dither.py b/server/dither.py new file mode 100644 index 0000000..929611a --- /dev/null +++ b/server/dither.py @@ -0,0 +1,142 @@ +""" +Floyd-Steinberg dithering for E Ink Spectra 6 (6-color) display. + +This is a 1:1 Python port of the C++ dithering in firmware/src/dither.h. +Same palette RGB values, same error diffusion coefficients, same color +distance metric. The output should be pixel-identical to the firmware. + +Native panel color codes (what the controller expects over SPI): + 0x00 = Black + 0x01 = White + 0x02 = Yellow + 0x03 = Red + 0x04 = Orange (not used in Spectra 6, but accepted by controller) + 0x05 = Blue + 0x06 = Green + +Pixel packing: 4bpp, 2 pixels per byte. + High nibble (bits 7-4) = LEFT pixel + Low nibble (bits 3-0) = RIGHT pixel + Scan order: left-to-right, top-to-bottom + +Data path on the ESP32: + JPEG → decode to RGB888 → Floyd-Steinberg dither → 4bpp native buffer + → writeNative(buf, invert=true) → SPI command 0x10 → panel refresh +""" + +import numpy as np +from PIL import Image + +# Palette: (R, G, B, native_panel_index) +# These RGB values must match firmware/src/dither.h PALETTE[] exactly. +PALETTE = [ + ( 0, 0, 0, 0), # Black + (255, 255, 255, 1), # White + (200, 30, 30, 3), # Red + ( 0, 145, 0, 6), # Green + ( 0, 0, 160, 5), # Blue + (230, 210, 0, 2), # Yellow +] + +# Extract just the RGB values as a numpy array for vectorized distance calc +PALETTE_RGB = np.array([(r, g, b) for r, g, b, _ in PALETTE], dtype=np.int32) +PALETTE_INDICES = [idx for _, _, _, idx in PALETTE] + +# Display RGB values — what the e-paper pigments actually look like. +# Used for rendering the simulator output. These may differ from the palette +# values above (which are what we compare against during dithering). +# For now they're the same, but can be tuned independently. +DISPLAY_RGB = { + 0: ( 0, 0, 0), # Black + 1: (255, 255, 255), # White + 2: (230, 210, 0), # Yellow + 3: (200, 30, 30), # Red + 5: ( 0, 0, 160), # Blue + 6: ( 0, 145, 0), # Green +} + + +def find_closest_color(r: int, g: int, b: int) -> int: + """Find the palette index with minimum squared Euclidean distance.""" + best_idx = 0 + best_dist = float('inf') + for i, (pr, pg, pb, _) in enumerate(PALETTE): + dr = r - pr + dg = g - pg + db = b - pb + dist = dr * dr + dg * dg + db * db + if dist < best_dist: + best_dist = dist + best_idx = i + return best_idx + + +def dither_floyd_steinberg(img: Image.Image) -> tuple[np.ndarray, Image.Image]: + """ + Apply Floyd-Steinberg dithering to an 800x480 RGB image. + + Returns: + native_buffer: numpy array of shape (480, 400) dtype uint8 + Each byte = 2 pixels packed as 4bpp native panel indices. + High nibble = left pixel, low nibble = right pixel. + preview_img: PIL Image showing what the e-paper display would look like. + """ + assert img.size == (800, 480), f"Image must be 800x480, got {img.size}" + + width, height = 800, 480 + # Work with int16 to allow negative error values + pixels = np.array(img, dtype=np.int16) + + # Output: what native panel index each pixel gets + result = np.zeros((height, width), dtype=np.uint8) + + for y in range(height): + for x in range(width): + r = int(np.clip(pixels[y, x, 0], 0, 255)) + g = int(np.clip(pixels[y, x, 1], 0, 255)) + b = int(np.clip(pixels[y, x, 2], 0, 255)) + + ci = find_closest_color(r, g, b) + pr, pg, pb, native_idx = PALETTE[ci] + result[y, x] = native_idx + + # Quantization error + er = r - pr + eg = g - pg + eb = b - pb + + # Floyd-Steinberg error diffusion (same coefficients as C++) + # Right: 7/16, Bottom-left: 3/16, Bottom: 5/16, Bottom-right: 1/16 + if x + 1 < width: + pixels[y, x + 1, 0] += er * 7 // 16 + pixels[y, x + 1, 1] += eg * 7 // 16 + pixels[y, x + 1, 2] += eb * 7 // 16 + if y + 1 < height: + if x - 1 >= 0: + pixels[y + 1, x - 1, 0] += er * 3 // 16 + pixels[y + 1, x - 1, 1] += eg * 3 // 16 + pixels[y + 1, x - 1, 2] += eb * 3 // 16 + pixels[y + 1, x, 0] += er * 5 // 16 + pixels[y + 1, x, 1] += eg * 5 // 16 + pixels[y + 1, x, 2] += eb * 5 // 16 + if x + 1 < width: + pixels[y + 1, x + 1, 0] += er * 1 // 16 + pixels[y + 1, x + 1, 1] += eg * 1 // 16 + pixels[y + 1, x + 1, 2] += eb * 1 // 16 + + # Pack into 4bpp buffer (2 pixels per byte, high nibble = left) + native_buffer = np.zeros((height, width // 2), dtype=np.uint8) + for y in range(height): + for x in range(0, width, 2): + left = result[y, x] + right = result[y, x + 1] + native_buffer[y, x // 2] = (left << 4) | right + + # Render preview image using display RGB values + preview = np.zeros((height, width, 3), dtype=np.uint8) + for y in range(height): + for x in range(width): + preview[y, x] = DISPLAY_RGB[result[y, x]] + preview_img = Image.fromarray(preview) + + return native_buffer, preview_img diff --git a/server/requirements.txt b/server/requirements.txt index db83c46..d9c2646 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -1,2 +1,3 @@ flask==3.1.* +numpy>=1.26 pillow==11.* diff --git a/server/server.py b/server/server.py index f6a401a..fcfce51 100644 --- a/server/server.py +++ b/server/server.py @@ -5,14 +5,20 @@ Serves photos for the ESP32 photo frame and provides a web UI for management. Tracks frame status via heartbeat reports from the ESP32. Endpoints: - GET / Web UI — gallery, upload, frame status - GET /photo Random JPEG resized to 800x480 (called by ESP32) - POST /heartbeat ESP32 reports what it displayed - GET /api/status Frame status (for Home Assistant REST sensor) - GET /api/photos List all photos as JSON - POST /api/upload Upload new photos - DELETE /api/photos/ Delete a photo - GET /health Health check + GET / Web UI — gallery, upload, frame status, settings + GET /photo Random JPEG processed for the display (called by ESP32) + POST /heartbeat ESP32 reports what it displayed + GET /api/status Frame status (for Home Assistant REST sensor) + GET /api/photos List all photos as JSON + POST /api/upload Upload new photos + DELETE /api/photos/ Delete a photo + GET /api/settings Get frame settings + PUT /api/settings Update frame settings + GET /api/photos//settings Get per-image settings + PUT /api/photos//settings Update per-image settings + GET /preview/ Dithered e-paper preview image + GET /simulate/ Full simulator page with controls + GET /health Health check """ import argparse @@ -20,7 +26,6 @@ import io import json import random import sys -import time from datetime import datetime, timezone from pathlib import Path @@ -28,34 +33,156 @@ from flask import Flask, Response, jsonify, request, render_template_string from werkzeug.utils import secure_filename from PIL import Image +import numpy as np + +from dither import dither_floyd_steinberg app = Flask(__name__) PHOTOS_DIR: Path = Path(".") -TARGET_WIDTH = 800 -TARGET_HEIGHT = 480 +PHYSICAL_WIDTH = 800 +PHYSICAL_HEIGHT = 480 SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".bmp", ".tiff"} -MAX_UPLOAD_SIZE = 20 * 1024 * 1024 # 20MB -# Frame state — persisted to disk so it survives restarts -STATE_FILE = Path("/data/frame_state.json") +DATA_DIR = Path("/data") +STATE_FILE = DATA_DIR / "frame_state.json" +SETTINGS_FILE = DATA_DIR / "settings.json" +IMAGE_SETTINGS_FILE = DATA_DIR / "image_settings.json" -def load_state() -> dict: +# --- Persistence helpers --- + +def _load_json(path: Path, default): try: - if STATE_FILE.exists(): - return json.loads(STATE_FILE.read_text()) + if path.exists(): + return json.loads(path.read_text()) except Exception: pass - return {"last_update": None, "current_photo": None, "ip": None, "updates": 0} + return default() if callable(default) else default -def save_state(state: dict): +def _save_json(path: Path, data): try: - STATE_FILE.parent.mkdir(parents=True, exist_ok=True) - STATE_FILE.write_text(json.dumps(state)) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2)) except Exception as e: - print(f"Warning: could not save state: {e}", file=sys.stderr) + print(f"Warning: could not save {path}: {e}", file=sys.stderr) -frame_state = load_state() +# Frame state (runtime, updated by heartbeats) +frame_state = _load_json(STATE_FILE, lambda: { + "last_update": None, "current_photo": None, "ip": None, "updates": 0 +}) + +# Frame settings (user-configured) +DEFAULT_SETTINGS = { + "orientation": "landscape", # landscape | portrait_cw | portrait_ccw + "default_mode": "zoom", # zoom | letterbox + "letterbox_color": [255, 255, 255], # white — looks like e-paper background +} + +frame_settings = _load_json(SETTINGS_FILE, lambda: dict(DEFAULT_SETTINGS)) + +# Per-image settings: { "filename.jpg": { "mode": "zoom", "pan_x": 0.5, "pan_y": 0.3 }, ... } +image_settings: dict = _load_json(IMAGE_SETTINGS_FILE, dict) + + +def get_image_settings(name: str) -> dict: + """Get effective settings for an image (per-image overrides merged with defaults).""" + defaults = {"mode": None, "pan_x": 0.5, "pan_y": 0.5} + per_image = image_settings.get(name, {}) + merged = {**defaults, **per_image} + if merged["mode"] is None: + merged["mode"] = frame_settings.get("default_mode", "zoom") + return merged + + +# --- Image processing --- + +def get_logical_dimensions() -> tuple[int, int]: + """Get the logical display dimensions based on frame orientation.""" + orientation = frame_settings.get("orientation", "landscape") + if orientation == "landscape": + return PHYSICAL_WIDTH, PHYSICAL_HEIGHT # 800x480 + else: + return PHYSICAL_HEIGHT, PHYSICAL_WIDTH # 480x800 + + +def process_image(img: Image.Image, settings: dict) -> Image.Image: + """ + Resize and transform an image for the display. + + Takes frame orientation into account: + - Determines logical dimensions (800x480 or 480x800) + - Applies zoom+pan or letterbox based on settings + - Rotates to match physical 800x480 buffer if portrait + + Returns an 800x480 image ready for dithering / JPEG encoding. + """ + logical_w, logical_h = get_logical_dimensions() + mode = settings.get("mode", "zoom") + pan_x = settings.get("pan_x", 0.5) + pan_y = settings.get("pan_y", 0.5) + + if mode == "zoom": + img = _zoom_and_pan(img, logical_w, logical_h, pan_x, pan_y) + else: + img = _letterbox(img, logical_w, logical_h) + + # Rotate to match physical buffer if portrait + orientation = frame_settings.get("orientation", "landscape") + if orientation == "portrait_cw": + img = img.rotate(-90, expand=True) + elif orientation == "portrait_ccw": + img = img.rotate(90, expand=True) + + return img + + +def _zoom_and_pan(img: Image.Image, tw: int, th: int, + pan_x: float, pan_y: float) -> Image.Image: + """Scale to cover target, then crop at the specified pan position.""" + target_ratio = tw / th + img_ratio = img.width / img.height + + if img_ratio > target_ratio: + # Image is wider — fit height, crop width + new_height = th + new_width = int(img.width * (th / img.height)) + else: + # Image is taller — fit width, crop height + new_width = tw + new_height = int(img.height * (tw / img.width)) + + img = img.resize((new_width, new_height), Image.LANCZOS) + + # Pan-aware crop: pan_x/pan_y are 0.0–1.0, controlling crop position + max_left = new_width - tw + max_top = new_height - th + left = int(max_left * pan_x) + top = int(max_top * pan_y) + return img.crop((left, top, left + tw, top + th)) + + +def _letterbox(img: Image.Image, tw: int, th: int) -> Image.Image: + """Scale to fit inside target, pad with letterbox color.""" + lb_color = tuple(frame_settings.get("letterbox_color", [255, 255, 255])) + target_ratio = tw / th + img_ratio = img.width / img.height + + if img_ratio > target_ratio: + # Image is wider — fit width + new_width = tw + new_height = int(img.height * (tw / img.width)) + else: + # Image is taller — fit height + new_height = th + new_width = int(img.width * (th / img.height)) + + img = img.resize((new_width, new_height), Image.LANCZOS) + + canvas = Image.new("RGB", (tw, th), lb_color) + paste_x = (tw - new_width) // 2 + paste_y = (th - new_height) // 2 + canvas.paste(img, (paste_x, paste_y)) + return canvas def get_photo_list() -> list[Path]: @@ -66,25 +193,6 @@ def get_photo_list() -> list[Path]: return sorted(photos, key=lambda p: p.name) -def resize_and_crop(img: Image.Image) -> Image.Image: - target_ratio = TARGET_WIDTH / TARGET_HEIGHT - img_ratio = img.width / img.height - - if img_ratio > target_ratio: - new_height = TARGET_HEIGHT - new_width = int(img.width * (TARGET_HEIGHT / img.height)) - else: - new_width = TARGET_WIDTH - new_height = int(img.height * (TARGET_WIDTH / img.width)) - - img = img.resize((new_width, new_height), Image.LANCZOS) - - left = (new_width - TARGET_WIDTH) // 2 - top = (new_height - TARGET_HEIGHT) // 2 - img = img.crop((left, top, left + TARGET_WIDTH, top + TARGET_HEIGHT)) - return img - - def make_thumbnail(img: Image.Image, size: int = 300) -> bytes: img.thumbnail((size, size), Image.LANCZOS) buf = io.BytesIO() @@ -102,9 +210,9 @@ def random_photo(): chosen = random.choice(photos) try: - img = Image.open(chosen) - img = img.convert("RGB") - img = resize_and_crop(img) + img = Image.open(chosen).convert("RGB") + settings = get_image_settings(chosen.name) + img = process_image(img, settings) buf = io.BytesIO() img.save(buf, format="JPEG", quality=85) @@ -119,7 +227,6 @@ def random_photo(): @app.route("/heartbeat", methods=["POST"]) def heartbeat(): - """ESP32 reports after displaying a photo.""" global frame_state data = request.get_json(silent=True) or {} frame_state["last_update"] = datetime.now(timezone.utc).isoformat() @@ -127,15 +234,57 @@ def heartbeat(): frame_state["ip"] = request.remote_addr frame_state["free_heap"] = data.get("free_heap", None) frame_state["updates"] = frame_state.get("updates", 0) + 1 - save_state(frame_state) + _save_json(STATE_FILE, frame_state) return jsonify({"ok": True}) -# --- API endpoints --- +# --- Settings API --- + +@app.route("/api/settings", methods=["GET"]) +def api_get_settings(): + return jsonify(frame_settings) + +@app.route("/api/settings", methods=["PUT"]) +def api_put_settings(): + global frame_settings + data = request.get_json() + allowed_keys = {"orientation", "default_mode", "letterbox_color"} + for k, v in data.items(): + if k in allowed_keys: + frame_settings[k] = v + _save_json(SETTINGS_FILE, frame_settings) + return jsonify(frame_settings) + +@app.route("/api/photos//settings", methods=["GET"]) +def api_get_image_settings(name: str): + fname = secure_filename(name) + return jsonify(get_image_settings(fname)) + +@app.route("/api/photos//settings", methods=["PUT"]) +def api_put_image_settings(name: str): + fname = secure_filename(name) + data = request.get_json() + allowed_keys = {"mode", "pan_x", "pan_y"} + current = image_settings.get(fname, {}) + for k, v in data.items(): + if k in allowed_keys: + current[k] = v + image_settings[fname] = current + _save_json(IMAGE_SETTINGS_FILE, image_settings) + return jsonify(get_image_settings(fname)) + +@app.route("/api/photos//settings", methods=["DELETE"]) +def api_delete_image_settings(name: str): + fname = secure_filename(name) + image_settings.pop(fname, None) + _save_json(IMAGE_SETTINGS_FILE, image_settings) + return jsonify(get_image_settings(fname)) + + +# --- Other API endpoints --- @app.route("/api/status") def api_status(): - """Frame status for Home Assistant REST sensor.""" photos = get_photo_list() return jsonify({ "state": "online" if frame_state.get("last_update") else "waiting", @@ -144,13 +293,20 @@ def api_status(): "frame_ip": frame_state.get("ip"), "total_photos": len(photos), "total_updates": frame_state.get("updates", 0), + "orientation": frame_settings.get("orientation", "landscape"), }) @app.route("/api/photos") def api_photos(): photos = get_photo_list() - return jsonify([{"name": p.name, "size": p.stat().st_size} for p in photos]) + result = [] + for p in photos: + entry = {"name": p.name, "size": p.stat().st_size} + if p.name in image_settings: + entry["settings"] = image_settings[p.name] + result.append(entry) + return jsonify(result) @app.route("/api/upload", methods=["POST"]) @@ -176,6 +332,8 @@ def api_delete(name: str): if not path.exists(): return jsonify({"error": "not found"}), 404 path.unlink() + image_settings.pop(fname, None) + _save_json(IMAGE_SETTINGS_FILE, image_settings) return jsonify({"deleted": fname}) @@ -186,20 +344,238 @@ def thumbnail(name: str): if not path.exists(): return "", 404 try: + size = request.args.get("size", 300, type=int) + size = min(size, 1600) img = Image.open(path).convert("RGB") - data = make_thumbnail(img) + data = make_thumbnail(img, size) return Response(data, mimetype="image/jpeg") except Exception: return "", 500 +# --- Simulator / Preview --- + +@app.route("/preview/") +def preview_photo(name: str): + fname = secure_filename(name) + path = PHOTOS_DIR / fname + if not path.exists(): + return jsonify({"error": "not found"}), 404 + + # Allow query param overrides for interactive simulator + settings = get_image_settings(fname) + if "mode" in request.args: + settings["mode"] = request.args["mode"] + if "pan_x" in request.args: + settings["pan_x"] = float(request.args["pan_x"]) + if "pan_y" in request.args: + settings["pan_y"] = float(request.args["pan_y"]) + + try: + img = Image.open(path).convert("RGB") + img = process_image(img, settings) + _, preview = dither_floyd_steinberg(img) + buf = io.BytesIO() + preview.save(buf, format="PNG") + buf.seek(0) + return Response(buf.getvalue(), mimetype="image/png") + except Exception as e: + print(f"Preview error: {e}", file=sys.stderr) + return jsonify({"error": str(e)}), 500 + + +@app.route("/preview") +def preview_random(): + photos = get_photo_list() + if not photos: + return jsonify({"error": "No photos found"}), 404 + chosen = random.choice(photos) + return preview_photo(chosen.name) + + +@app.route("/simulate/") +def simulate_page(name: str): + fname = secure_filename(name) + path = PHOTOS_DIR / fname + if not path.exists(): + return "", 404 + settings = get_image_settings(fname) + orientation = frame_settings.get("orientation", "landscape") + return render_template_string(SIMULATE_UI, photo_name=fname, + settings=settings, orientation=orientation) + + +SIMULATE_UI = """ + + + + +E-Paper Simulator — {{ photo_name }} + + + +

E-Paper Display Simulator

+ +
+ E-paper preview +
+ +
+

Display Settings

+ +
+ + +
+ +
+
+ + + {{ '%.0f' % (settings.pan_x * 100) }}% +
+
+ + + {{ '%.0f' % (settings.pan_y * 100) }}% +
+
+ +
+ + + Rendering... +
+
+ + ← Back to gallery + + + + +""" + + # --- Web UI --- @app.route("/") def index(): photos = get_photo_list() + orientation = frame_settings.get("orientation", "landscape") + default_mode = frame_settings.get("default_mode", "zoom") return render_template_string(WEB_UI, photos=photos, state=frame_state, - photo_count=len(photos)) + photo_count=len(photos), orientation=orientation, + default_mode=default_mode) @app.route("/health") @@ -225,12 +601,17 @@ WEB_UI = """ .status-card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 1.5rem; display: flex; gap: 2rem; flex-wrap: wrap; + align-items: flex-end; } .status-card .item { display: flex; flex-direction: column; gap: 0.2rem; } .status-card .label { font-size: 0.75rem; text-transform: uppercase; color: #888; } .status-card .value { font-size: 0.95rem; } .status-card .online { color: var(--accent); } .status-card .waiting { color: #f80; } + .status-card select { + background: #333; color: #eee; border: 1px solid var(--border); border-radius: 4px; + padding: 0.25rem 0.4rem; font-size: 0.85rem; + } .upload-area { background: var(--card); border: 2px dashed var(--border); border-radius: 8px; @@ -250,11 +631,15 @@ WEB_UI = """ padding: 0.5rem 0.6rem; display: flex; justify-content: space-between; align-items: center; font-size: 0.8rem; } - .photo-card .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 70%; } - .photo-card .delete { - background: none; border: none; color: #f55; cursor: pointer; font-size: 0.85rem; + .photo-card .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 65%; } + .photo-card .actions { display: flex; gap: 0.3rem; } + .photo-card .btn { + background: none; border: none; cursor: pointer; font-size: 0.85rem; padding: 0.2rem 0.4rem; border-radius: 4px; } + .photo-card .simulate { color: #6c8; } + .photo-card .simulate:hover { background: rgba(102,204,136,0.15); } + .photo-card .delete { color: #f55; } .photo-card .delete:hover { background: rgba(255,85,85,0.15); } .photo-card.current { border-color: var(--accent); } @@ -287,8 +672,19 @@ WEB_UI = """ {{ photo_count }}
- Total Refreshes - {{ state.get('updates', 0) }} + Orientation + +
+
+ Default Mode +
@@ -304,7 +700,10 @@ WEB_UI = """ {{ p.name }}
{{ p.name }} - +
+ + +
{% endfor %} @@ -336,12 +735,16 @@ function deletePhoto(name) { if (!confirm('Delete ' + name + '?')) return; fetch('/api/photos/' + encodeURIComponent(name), { method: 'DELETE' }) .then(r => r.json()) - .then(() => { - document.querySelector('[data-name="' + name + '"]')?.remove(); - }); + .then(() => { document.querySelector('[data-name="' + name + '"]')?.remove(); }); +} + +function updateSetting(key, value) { + fetch('/api/settings', { + method: 'PUT', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({[key]: value}) + }); } -// Convert UTC timestamps to local time document.querySelectorAll('[data-utc]').forEach(el => { const d = new Date(el.dataset.utc); el.textContent = d.toLocaleString(); @@ -359,11 +762,24 @@ if __name__ == "__main__": args = parser.parse_args() PHOTOS_DIR = Path(args.photos_dir) + DATA_DIR = Path(args.photos_dir) / ".data" + STATE_FILE = DATA_DIR / "frame_state.json" + SETTINGS_FILE = DATA_DIR / "settings.json" + IMAGE_SETTINGS_FILE = DATA_DIR / "image_settings.json" + if not PHOTOS_DIR.exists(): PHOTOS_DIR.mkdir(parents=True, exist_ok=True) + # Reload with correct paths + frame_state = _load_json(STATE_FILE, lambda: { + "last_update": None, "current_photo": None, "ip": None, "updates": 0 + }) + frame_settings = _load_json(SETTINGS_FILE, lambda: dict(DEFAULT_SETTINGS)) + image_settings = _load_json(IMAGE_SETTINGS_FILE, dict) + print(f"Serving photos from: {PHOTOS_DIR.resolve()}") print(f"Found {len(get_photo_list())} photos") + print(f"Orientation: {frame_settings['orientation']}, Default mode: {frame_settings['default_mode']}") print(f"Listening on port {args.port}") app.run(host="0.0.0.0", port=args.port)