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:
1 parent
61348be674
commit
bfe4616f20
8 files changed
+232
-4
No files matched your search
@@ -4,5 +4,6 @@ venv/
|
||||
.venv/
|
||||
mock_output/
|
||||
web/static/*.png
|
||||
web/static/uploads/
|
||||
config.json
|
||||
config.json.tmp
|
||||
@@ -1,9 +1,10 @@
|
||||
# Paperwall
|
||||
|
||||
A standalone dashboard for one Waveshare `epd4in0e` (4", 6-color) e-ink panel:
|
||||
Navidrome now-playing, clock, and weather, switchable/controllable from a
|
||||
local web UI. Started as an InkyPi replacement, but doesn't depend on InkyPi
|
||||
at all -- own venv, own vendored copy of the Waveshare driver files.
|
||||
Navidrome now-playing, clock, weather, and pictures (upload or URL),
|
||||
switchable/controllable from a local web UI. Started as an InkyPi
|
||||
replacement, but doesn't depend on InkyPi at all -- own venv, own vendored
|
||||
copy of the Waveshare driver files.
|
||||
|
||||
Confirmed by reading the vendor driver directly: this panel has no
|
||||
partial-refresh mode at all -- every update is a full flash. So the design
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
+55
@@ -2,10 +2,17 @@
|
||||
|
||||
import requests
|
||||
from flask import Flask, redirect, render_template, request, url_for
|
||||
from PIL import Image
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from screens.picture import UPLOAD_DIR
|
||||
|
||||
ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "bmp", "webp"}
|
||||
|
||||
|
||||
def create_app(config, screens: dict, scheduler) -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.config["MAX_CONTENT_LENGTH"] = 20 * 1024 * 1024 # 20 MB, generous for a photo
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
@@ -78,6 +85,54 @@ def create_app(config, screens: dict, scheduler) -> Flask:
|
||||
|
||||
return render_template("settings_weather.html", settings=config.get_screen_settings("weather"), error=error)
|
||||
|
||||
@app.route("/settings/picture", methods=["GET", "POST"])
|
||||
def settings_picture():
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
url = request.form.get("image_url", "").strip()
|
||||
common_updates = {
|
||||
"fit": request.form.get("fit", "cover"),
|
||||
"boost_contrast": "boost_contrast" in request.form,
|
||||
"grayscale": "grayscale" in request.form,
|
||||
}
|
||||
uploaded = request.files.get("image")
|
||||
|
||||
if uploaded and uploaded.filename:
|
||||
filename = secure_filename(uploaded.filename)
|
||||
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||
if ext not in ALLOWED_IMAGE_EXTENSIONS:
|
||||
error = "Unsupported file type -- use PNG, JPG, GIF, BMP, or WebP."
|
||||
else:
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# Only one picture is ever active -- clear any previous
|
||||
# upload first so a different extension doesn't leave an
|
||||
# orphaned file behind.
|
||||
for old in UPLOAD_DIR.glob("current.*"):
|
||||
old.unlink()
|
||||
dest = UPLOAD_DIR / f"current.{ext}"
|
||||
uploaded.save(dest)
|
||||
try:
|
||||
Image.open(dest).verify()
|
||||
except Exception:
|
||||
dest.unlink(missing_ok=True)
|
||||
error = "That file doesn't look like a valid image."
|
||||
else:
|
||||
config.update_screen_settings("picture", {
|
||||
"mode": "upload",
|
||||
"uploaded_filename": dest.name,
|
||||
**common_updates,
|
||||
})
|
||||
return redirect(url_for("settings_picture", saved=1))
|
||||
elif url:
|
||||
config.update_screen_settings("picture", {"mode": "url", "image_url": url, **common_updates})
|
||||
return redirect(url_for("settings_picture", saved=1))
|
||||
else:
|
||||
# Neither a new upload nor a URL change -- just update fit/boost/grayscale.
|
||||
config.update_screen_settings("picture", common_updates)
|
||||
return redirect(url_for("settings_picture", saved=1))
|
||||
|
||||
return render_template("settings_picture.html", settings=config.get_screen_settings("picture"), error=error)
|
||||
|
||||
@app.route("/settings/rotation", methods=["GET", "POST"])
|
||||
def settings_rotation():
|
||||
if request.method == "POST":
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
<a href="{{ url_for('index') }}" class="{{ 'active' if request.endpoint == 'index' }}">Status</a>
|
||||
<a href="{{ url_for('settings_navidrome') }}" class="{{ 'active' if request.endpoint == 'settings_navidrome' }}">Navidrome</a>
|
||||
<a href="{{ url_for('settings_weather') }}" class="{{ 'active' if request.endpoint == 'settings_weather' }}">Weather</a>
|
||||
<a href="{{ url_for('settings_picture') }}" class="{{ 'active' if request.endpoint == 'settings_picture' }}">Picture</a>
|
||||
<a href="{{ url_for('settings_rotation') }}" class="{{ 'active' if request.endpoint == 'settings_rotation' }}">Rotation</a>
|
||||
</nav>
|
||||
{% if request.args.get('saved') %}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Picture</h1>
|
||||
|
||||
{% if error %}
|
||||
<div class="toast error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if settings.mode == "upload" and settings.uploaded_filename %}
|
||||
<div class="card">
|
||||
<div class="section-label">Current picture</div>
|
||||
<div class="preview-wrap">
|
||||
<img class="preview" src="{{ url_for('static', filename='uploads/' + settings.uploaded_filename) }}" alt="current picture">
|
||||
</div>
|
||||
</div>
|
||||
{% elif settings.mode == "url" and settings.image_url %}
|
||||
<div class="card">
|
||||
<div class="section-label">Current picture</div>
|
||||
<p class="hint">Fetched from a URL: {{ settings.image_url }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<div class="card">
|
||||
<div class="section-label">Upload</div>
|
||||
<div class="field">
|
||||
<label for="image">Choose a file</label>
|
||||
<input type="file" id="image" name="image" accept="image/png,image/jpeg,image/gif,image/bmp,image/webp">
|
||||
<p class="hint">PNG, JPG, GIF, BMP, or WebP. Replaces whatever's currently set.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-label">Or use a URL</div>
|
||||
<div class="field">
|
||||
<label for="image_url">Image URL</label>
|
||||
<input type="text" id="image_url" name="image_url" placeholder="https://example.com/photo.jpg">
|
||||
<p class="hint">Re-fetched every 30 minutes. Leave both this and the upload above alone to just change the options below.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-label">Display options</div>
|
||||
<div class="field">
|
||||
<label for="fit">Fit</label>
|
||||
<select id="fit" name="fit" style="width:100%; padding:0.6rem 0.7rem; font-size:0.95rem; font-family:inherit; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:8px;">
|
||||
<option value="cover" {{ "selected" if settings.fit == "cover" }}>Cover -- fill the screen, cropping edges</option>
|
||||
<option value="contain" {{ "selected" if settings.fit == "contain" }}>Contain -- show the whole image, letterboxed</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="checkbox"><input type="checkbox" name="boost_contrast" {{ "checked" if settings.boost_contrast }}> Boost contrast for e-ink</label>
|
||||
<label class="checkbox"><input type="checkbox" name="grayscale" {{ "checked" if settings.grayscale }}> Force grayscale</label>
|
||||
</div>
|
||||
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user