Add Picture screen: upload a file or point at a URL

New screen alongside clock/weather/navidrome. Upload goes through the web
UI (validated with PIL before accepting, stored under web/static/uploads/,
gitignored), or point it at a URL re-fetched every 30 minutes. Cover/contain
fit modes (new fit_cover/fit_contain helpers in render_utils.py, generalized
from the square-only cover-art cropping already used by the Navidrome
screen), plus the same contrast-boost/grayscale options that screen has.
This commit is contained in:
Honey committed 2026-07-21 22:56:32 +02:00
1 parent 61348be674
commit bfe4616f20
8 files changed
+232 -4

No files matched your search

+2
View File
@@ -1,10 +1,12 @@
from screens.clock import ClockScreen
from screens.navidrome import NavidromeScreen
from screens.picture import PictureScreen
from screens.weather import WeatherScreen
SCREEN_CLASSES = {
ClockScreen.id: ClockScreen,
WeatherScreen.id: WeatherScreen,
PictureScreen.id: PictureScreen,
NavidromeScreen.id: NavidromeScreen,
}
+88
View File
@@ -0,0 +1,88 @@
"""Shows a picture -- either uploaded through the web UI or fetched from a
URL on a timer. Same contrast-boost/grayscale treatment as the Navidrome
cover art, since the same e-ink legibility concerns apply.
"""
import logging
from io import BytesIO
from pathlib import Path
import requests
from PIL import Image, ImageDraw, ImageEnhance
from screens.base import Screen
from screens.render_utils import fit_contain, fit_cover, load_font, text_width
logger = logging.getLogger(__name__)
HTTP_TIMEOUT = 15
UPLOAD_DIR = Path(__file__).resolve().parent.parent / "web" / "static" / "uploads"
class PictureScreen(Screen):
id = "picture"
display_name = "Picture"
poll_interval = 1800 # 30 minutes -- only matters for url mode
def default_settings(self) -> dict:
return {
"mode": "url", # "url" or "upload"
"image_url": "",
"uploaded_filename": "",
"fit": "cover", # "cover" or "contain"
"boost_contrast": True,
"grayscale": False,
}
def check(self, settings: dict):
mode = settings.get("mode", "url")
if mode == "upload":
filename = settings.get("uploaded_filename")
if not filename:
return None
path = UPLOAD_DIR / filename
if not path.exists():
logger.warning("Uploaded picture %s is missing", path)
return None
try:
return Image.open(path).convert("RGB")
except OSError as exc:
logger.warning("Could not open uploaded picture %s: %s", path, exc)
return None
url = (settings.get("image_url") or "").strip()
if not url:
return None
try:
response = requests.get(url, timeout=HTTP_TIMEOUT)
response.raise_for_status()
return Image.open(BytesIO(response.content)).convert("RGB")
except (requests.exceptions.RequestException, OSError) as exc:
logger.warning("Could not load picture from %s: %s", url, exc)
return None
def render(self, state, settings: dict, resolution: tuple[int, int]) -> Image.Image:
width, height = resolution
if state is None:
image = Image.new("RGB", (width, height), "white")
draw = ImageDraw.Draw(image)
font = load_font(size=int(min(width, height) * 0.06))
msg = "Kein Bild konfiguriert"
mw = text_width(draw, msg, font)
draw.text(((width - mw) / 2, height / 2), msg, font=font, fill=(140, 140, 140))
return image
if settings.get("fit", "cover") == "contain":
image = fit_contain(state, width, height)
else:
image = fit_cover(state, width, height)
if settings.get("boost_contrast", True):
image = ImageEnhance.Contrast(image).enhance(1.35)
image = ImageEnhance.Brightness(image).enhance(1.05)
if settings.get("grayscale", False):
image = image.convert("L").convert("RGB")
return image
+24 -1
View File
@@ -8,7 +8,7 @@ text and full-color cover art without worrying about the palette themselves.
from pathlib import Path
from PIL import ImageFont
from PIL import Image, ImageFont
FONTS_DIR = Path(__file__).resolve().parent.parent / "fonts"
@@ -77,3 +77,26 @@ def wrap_to_lines(draw, text: str, font: ImageFont.FreeTypeFont, max_width: int,
lines.append(current)
return lines[:max_lines]
def fit_cover(image: Image.Image, width: int, height: int) -> Image.Image:
"""Resize+crop to fill exactly width x height, mirroring CSS's
object-fit: cover -- no letterboxing, but edges may be cropped off."""
src_w, src_h = image.size
scale = max(width / src_w, height / src_h)
scaled = image.resize((round(src_w * scale), round(src_h * scale)), Image.LANCZOS)
left = (scaled.width - width) // 2
top = (scaled.height - height) // 2
return scaled.crop((left, top, left + width, top + height))
def fit_contain(image: Image.Image, width: int, height: int, background=(255, 255, 255)) -> Image.Image:
"""Resize to fit entirely within width x height, centered on a solid
background -- mirrors CSS's object-fit: contain, full image always
visible, letterboxed instead of cropped."""
src_w, src_h = image.size
scale = min(width / src_w, height / src_h)
scaled = image.resize((round(src_w * scale), round(src_h * scale)), Image.LANCZOS)
canvas = Image.new("RGB", (width, height), background)
canvas.paste(scaled, ((width - scaled.width) // 2, (height - scaled.height) // 2))
return canvas