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.
89 lines
3.0 KiB
Python
89 lines
3.0 KiB
Python
"""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
|