Add configurable title, matte dimensions, and interactive installer
- Configurable collage title via localStorage (bird:title) with admin settings UI - Configurable matte opening dimensions in display.py (mat_opening_w/mat_opening_h) - Interactive frame installer (install-interactive.sh) with guided setup prompts Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+30
-4
File diff suppressed because one or more lines are too long
@@ -317,6 +317,14 @@
|
|||||||
transform: translateX(18px);
|
transform: translateX(18px);
|
||||||
background: var(--pill);
|
background: var(--pill);
|
||||||
}
|
}
|
||||||
|
.title-input {
|
||||||
|
font: 13px/1.3 ui-serif, "Iowan Old Style", Georgia, serif;
|
||||||
|
background: var(--paper-2); border: 0; border-radius: 6px;
|
||||||
|
box-shadow: var(--recess);
|
||||||
|
padding: 5px 10px; color: var(--ink);
|
||||||
|
width: 140px; text-align: right;
|
||||||
|
}
|
||||||
|
.title-input:focus { outline: 2px solid var(--ink-soft); outline-offset: 1px; }
|
||||||
|
|
||||||
/* Slider - title row (label + value badge with breathing room) and
|
/* Slider - title row (label + value badge with breathing room) and
|
||||||
a track row underneath. Value sits at the far right with a wide
|
a track row underneath. Value sits at the far right with a wide
|
||||||
|
|||||||
+37
-17
@@ -51,7 +51,9 @@ DEFAULTS = {
|
|||||||
"shoot_title": None, "shoot_subtitle": None,
|
"shoot_title": None, "shoot_subtitle": None,
|
||||||
"shoot_headline_px": 42, "shoot_eyebrow_px": 18, "shoot_lowercase": False,
|
"shoot_headline_px": 42, "shoot_eyebrow_px": 18, "shoot_lowercase": False,
|
||||||
"shoot_mat": 0.04, "shoot_small_floor": 0.04, "shoot_count_exp": 0.65,
|
"shoot_mat": 0.04, "shoot_small_floor": 0.04, "shoot_count_exp": 0.65,
|
||||||
"mat": 0.0, # extra global shrink of the content inside the A5 opening
|
"mat": 0.0, # extra global shrink of the content inside the opening
|
||||||
|
"mat_opening_w": 0, # matte opening width in inches (0 = use A5 default)
|
||||||
|
"mat_opening_h": 0, # matte opening height in inches (0 = use A5 default)
|
||||||
"rotate": 90, # 90 or 270 if the frame hangs the other way up
|
"rotate": 90, # 90 or 270 if the frame hangs the other way up
|
||||||
"saturation": 0.6,
|
"saturation": 0.6,
|
||||||
"panel": "", # "el133uf1" forces the 13.3" driver if auto() fails
|
"panel": "", # "el133uf1" forces the 13.3" driver if auto() fails
|
||||||
@@ -131,14 +133,27 @@ def _paper(img):
|
|||||||
return tuple(int(statistics.median(c)) for c in zip(*px))
|
return tuple(int(statistics.median(c)) for c in zip(*px))
|
||||||
|
|
||||||
|
|
||||||
# The mat opening is an A5 rectangle (1 : sqrt(2)) centred in the panel; the
|
# The mat opening defaults to A5 (1 : sqrt(2)) for the standard kit frame.
|
||||||
# content floats inside it with `mat` of inner whitespace.
|
# Override with mat_opening_w / mat_opening_h (in inches) for custom frames.
|
||||||
A5_H = PANEL_H * 0.7071 # A5 is 1/sqrt(2) of the panel height
|
_A5_H = PANEL_H * 0.7071
|
||||||
A5_W = A5_H / 1.41421 # A5 aspect 1 : sqrt(2)
|
_A5_W = _A5_H / 1.41421
|
||||||
|
|
||||||
|
# Panel physical dimensions in inches (13.3" diagonal, 4:3 aspect in portrait)
|
||||||
|
_PANEL_W_IN = 8.4
|
||||||
|
_PANEL_H_IN = 11.2
|
||||||
|
|
||||||
|
|
||||||
def _place(content, paper, mat):
|
def _opening_dims(cfg):
|
||||||
s = min(A5_W * (1 - mat) / content.width, A5_H * (1 - mat) / content.height)
|
w = float(cfg.get("mat_opening_w") or 0)
|
||||||
|
h = float(cfg.get("mat_opening_h") or 0)
|
||||||
|
if w > 0 and h > 0:
|
||||||
|
return PANEL_W * (w / _PANEL_W_IN), PANEL_H * (h / _PANEL_H_IN)
|
||||||
|
return _A5_W, _A5_H
|
||||||
|
|
||||||
|
|
||||||
|
def _place(content, paper, mat, opening):
|
||||||
|
op_w, op_h = opening
|
||||||
|
s = min(op_w * (1 - mat) / content.width, op_h * (1 - mat) / content.height)
|
||||||
nw, nh = max(1, round(content.width * s)), max(1, round(content.height * s))
|
nw, nh = max(1, round(content.width * s)), max(1, round(content.height * s))
|
||||||
content = content.resize((nw, nh), Image.LANCZOS)
|
content = content.resize((nw, nh), Image.LANCZOS)
|
||||||
canvas = Image.new("RGB", (PANEL_W, PANEL_H), paper)
|
canvas = Image.new("RGB", (PANEL_W, PANEL_H), paper)
|
||||||
@@ -177,9 +192,12 @@ def _centroid_x(img, paper):
|
|||||||
TITLE_H_FRAC, COLLAGE_FRAC, GAP_FRAC = 0.065, 0.66, 0.1
|
TITLE_H_FRAC, COLLAGE_FRAC, GAP_FRAC = 0.065, 0.66, 0.1
|
||||||
|
|
||||||
|
|
||||||
def mat_and_center(img, mat):
|
def mat_and_center(img, mat, opening=None):
|
||||||
"""Crop the title and collage, size each to a fraction of the A5 opening,
|
"""Crop the title and collage, size each to a fraction of the opening,
|
||||||
stack with a gap, and centre on the panel."""
|
stack with a gap, and centre on the panel."""
|
||||||
|
if opening is None:
|
||||||
|
opening = (_A5_W, _A5_H)
|
||||||
|
op_w, op_h = opening
|
||||||
img = img.convert("RGB")
|
img = img.convert("RGB")
|
||||||
paper = _paper(img)
|
paper = _paper(img)
|
||||||
mask = ImageChops.difference(img, Image.new("RGB", img.size, paper))
|
mask = ImageChops.difference(img, Image.new("RGB", img.size, paper))
|
||||||
@@ -193,7 +211,7 @@ def mat_and_center(img, mat):
|
|||||||
for y in range(top, bot):
|
for y in range(top, bot):
|
||||||
if levels[y] <= 2:
|
if levels[y] <= 2:
|
||||||
run += 1
|
run += 1
|
||||||
if run >= 60: # split below the headline; a 60px band clears the ~30px eyebrow/headline gap so the title stays whole
|
if run >= 60:
|
||||||
cy = y
|
cy = y
|
||||||
while cy < bot and levels[cy] <= 2:
|
while cy < bot and levels[cy] <= 2:
|
||||||
cy += 1
|
cy += 1
|
||||||
@@ -203,9 +221,9 @@ def mat_and_center(img, mat):
|
|||||||
run = 0
|
run = 0
|
||||||
tb = _region_bbox(img, paper, top, split[0]) if split else None
|
tb = _region_bbox(img, paper, top, split[0]) if split else None
|
||||||
cb = _region_bbox(img, paper, split[1], bot + 1) if split else None
|
cb = _region_bbox(img, paper, split[1], bot + 1) if split else None
|
||||||
box_w, box_h = A5_W * (1 - mat), A5_H * (1 - mat)
|
box_w, box_h = op_w * (1 - mat), op_h * (1 - mat)
|
||||||
if not (tb and cb):
|
if not (tb and cb):
|
||||||
return _place(img.crop(full), paper, mat)
|
return _place(img.crop(full), paper, mat, opening)
|
||||||
title = _scale_h(img.crop(tb), box_h * TITLE_H_FRAC)
|
title = _scale_h(img.crop(tb), box_h * TITLE_H_FRAC)
|
||||||
gap = round(box_h * GAP_FRAC)
|
gap = round(box_h * GAP_FRAC)
|
||||||
# Size the collage to fill the room left under the fixed-size title,
|
# Size the collage to fill the room left under the fixed-size title,
|
||||||
@@ -242,9 +260,10 @@ def quantize_spectra6(img):
|
|||||||
return img.convert("RGB").quantize(palette=pal, dither=Image.Dither.FLOYDSTEINBERG).convert("RGB")
|
return img.convert("RGB").quantize(palette=pal, dither=Image.Dither.FLOYDSTEINBERG).convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
def _draw_mat_box(img):
|
def _draw_mat_box(img, opening=None):
|
||||||
"""Dev aid: outline the A5 mat opening so the matte and centring show."""
|
"""Dev aid: outline the mat opening so the matte and centring show."""
|
||||||
x0, y0 = round((PANEL_W - A5_W) / 2), round((PANEL_H - A5_H) / 2)
|
op_w, op_h = opening if opening else (_A5_W, _A5_H)
|
||||||
|
x0, y0 = round((PANEL_W - op_w) / 2), round((PANEL_H - op_h) / 2)
|
||||||
ImageDraw.Draw(img).rectangle((x0, y0, PANEL_W - x0 - 1, PANEL_H - y0 - 1),
|
ImageDraw.Draw(img).rectangle((x0, y0, PANEL_W - x0 - 1, PANEL_H - y0 - 1),
|
||||||
outline=(170, 60, 56), width=2)
|
outline=(170, 60, 56), width=2)
|
||||||
|
|
||||||
@@ -351,11 +370,12 @@ def run(cfg, preview=None, force=False, use_signature=True, mat_box=False):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"could not get image: {e}", file=sys.stderr) # keep last panel image
|
print(f"could not get image: {e}", file=sys.stderr) # keep last panel image
|
||||||
return
|
return
|
||||||
img = mat_and_center(img, cfg["mat"])
|
opening = _opening_dims(cfg)
|
||||||
|
img = mat_and_center(img, cfg["mat"], opening)
|
||||||
if preview:
|
if preview:
|
||||||
out = quantize_spectra6(img)
|
out = quantize_spectra6(img)
|
||||||
if mat_box:
|
if mat_box:
|
||||||
_draw_mat_box(out)
|
_draw_mat_box(out, opening)
|
||||||
out.save(preview)
|
out.save(preview)
|
||||||
print(f"wrote preview {preview}")
|
print(f"wrote preview {preview}")
|
||||||
return
|
return
|
||||||
|
|||||||
Executable
+240
@@ -0,0 +1,240 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Interactive installer for the AvianVisitors e-ink frame.
|
||||||
|
# Walks you through configuration with prompts and sensible defaults,
|
||||||
|
# then hands off to install.sh with the right flags.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# --- helpers ----------------------------------------------------------------
|
||||||
|
ask() {
|
||||||
|
local prompt="$1" default="$2" var="$3"
|
||||||
|
if [ -n "$default" ]; then
|
||||||
|
printf '%s [%s]: ' "$prompt" "$default" >&2
|
||||||
|
else
|
||||||
|
printf '%s: ' "$prompt" >&2
|
||||||
|
fi
|
||||||
|
read -r input
|
||||||
|
eval "$var=\"\${input:-\$default}\""
|
||||||
|
}
|
||||||
|
|
||||||
|
pick() {
|
||||||
|
local prompt="$1" var="$2"
|
||||||
|
shift 2
|
||||||
|
echo "" >&2
|
||||||
|
echo "$prompt" >&2
|
||||||
|
local i=1
|
||||||
|
for opt in "$@"; do
|
||||||
|
printf ' %d) %s\n' "$i" "$opt" >&2
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
printf 'Choice [1]: ' >&2
|
||||||
|
read -r choice
|
||||||
|
choice="${choice:-1}"
|
||||||
|
eval "$var=\"$choice\""
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
printf '%s [Y/n]: ' "$1" >&2
|
||||||
|
read -r yn
|
||||||
|
case "$yn" in
|
||||||
|
[Nn]*) return 1 ;;
|
||||||
|
*) return 0 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
hr() { echo "────────────────────────────────────────────────" >&2; }
|
||||||
|
|
||||||
|
# --- start ------------------------------------------------------------------
|
||||||
|
echo ""
|
||||||
|
echo " ╔═══════════════════════════════════════╗"
|
||||||
|
echo " ║ AvianVisitors e-ink frame setup ║"
|
||||||
|
echo " ╚═══════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --- mode -------------------------------------------------------------------
|
||||||
|
pick "How will the frame get its birds?" MODE_CHOICE \
|
||||||
|
"Local BirdNET-Pi on my network (default)" \
|
||||||
|
"BirdWeather — no mic, uses nearby stations" \
|
||||||
|
"Image URL — fetch a pre-rendered frame PNG"
|
||||||
|
|
||||||
|
INSTALL_FLAGS=""
|
||||||
|
MODE="local"
|
||||||
|
BASE_URL=""
|
||||||
|
ZIP=""
|
||||||
|
IMAGE_URL=""
|
||||||
|
EBIRD_KEY=""
|
||||||
|
|
||||||
|
case "$MODE_CHOICE" in
|
||||||
|
2)
|
||||||
|
MODE="birdweather"
|
||||||
|
echo "" >&2
|
||||||
|
ask "ZIP / postal code" "" ZIP
|
||||||
|
if [ -z "$ZIP" ]; then
|
||||||
|
echo "A ZIP code is required for BirdWeather mode." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
INSTALL_FLAGS="--bird-weather --zip $ZIP"
|
||||||
|
echo "" >&2
|
||||||
|
if confirm "Do you have an eBird API key? (helps if no BirdWeather stations nearby)"; then
|
||||||
|
ask "eBird API key" "" EBIRD_KEY
|
||||||
|
if [ -n "$EBIRD_KEY" ]; then
|
||||||
|
INSTALL_FLAGS="$INSTALL_FLAGS --ebird-key $EBIRD_KEY"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
MODE="image"
|
||||||
|
echo "" >&2
|
||||||
|
ask "Frame image URL" "" IMAGE_URL
|
||||||
|
if [ -z "$IMAGE_URL" ]; then
|
||||||
|
echo "A URL is required for image mode." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
INSTALL_FLAGS="--image-url $IMAGE_URL"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
MODE="local"
|
||||||
|
echo "" >&2
|
||||||
|
ask "BirdNET-Pi address" "birdnet.local" BASE_URL
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- frame & matte ----------------------------------------------------------
|
||||||
|
hr
|
||||||
|
pick "What frame and matte are you using?" FRAME_CHOICE \
|
||||||
|
"A4 frame with A5 matte (stock kit)" \
|
||||||
|
"11×14 frame with 8×10 matte" \
|
||||||
|
"Full panel, no matte" \
|
||||||
|
"Custom dimensions"
|
||||||
|
|
||||||
|
MAT_W=0
|
||||||
|
MAT_H=0
|
||||||
|
|
||||||
|
case "$FRAME_CHOICE" in
|
||||||
|
2) MAT_W=8; MAT_H=10 ;;
|
||||||
|
3) MAT_W=8.4; MAT_H=11.2 ;;
|
||||||
|
4)
|
||||||
|
echo "" >&2
|
||||||
|
echo "Enter the matte opening size (the visible area, in inches):" >&2
|
||||||
|
ask " Opening width" "8" MAT_W
|
||||||
|
ask " Opening height" "10" MAT_H
|
||||||
|
;;
|
||||||
|
*) MAT_W=0; MAT_H=0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- titles -----------------------------------------------------------------
|
||||||
|
hr
|
||||||
|
echo "" >&2
|
||||||
|
TITLE="Avian Visitors"
|
||||||
|
SUBTITLE="Heard Today"
|
||||||
|
if confirm "Customize the frame title? (default: \"Avian Visitors\" / \"Heard Today\")"; then
|
||||||
|
ask "Title (top line)" "Avian Visitors" TITLE
|
||||||
|
ask "Subtitle (large text)" "Heard Today" SUBTITLE
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- rotation ---------------------------------------------------------------
|
||||||
|
hr
|
||||||
|
pick "Which way does the display hang?" ROTATE_CHOICE \
|
||||||
|
"USB cable exits bottom-right (rotate = 90)" \
|
||||||
|
"USB cable exits top-left (rotate = 270)"
|
||||||
|
|
||||||
|
ROTATE=90
|
||||||
|
[ "$ROTATE_CHOICE" = "2" ] && ROTATE=270
|
||||||
|
|
||||||
|
# --- quiet hours ------------------------------------------------------------
|
||||||
|
hr
|
||||||
|
QUIET_START=0
|
||||||
|
QUIET_END=0
|
||||||
|
if confirm "Set quiet hours? (skip refreshes overnight to avoid e-ink noise)"; then
|
||||||
|
ask "Quiet start hour (0-23)" "22" QUIET_START
|
||||||
|
ask "Quiet end hour (0-23)" "6" QUIET_END
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- basic auth -------------------------------------------------------------
|
||||||
|
hr
|
||||||
|
BASIC_USER=""
|
||||||
|
BASIC_PASS=""
|
||||||
|
if [ "$MODE" = "local" ]; then
|
||||||
|
if confirm "Is your BirdNET-Pi behind basic auth?"; then
|
||||||
|
ask "Username" "" BASIC_USER
|
||||||
|
ask "Password" "" BASIC_PASS
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- summary ----------------------------------------------------------------
|
||||||
|
hr
|
||||||
|
echo "" >&2
|
||||||
|
echo " Configuration summary:" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo " Mode: $MODE" >&2
|
||||||
|
case "$MODE" in
|
||||||
|
local) echo " BirdNET URL: http://$BASE_URL" >&2 ;;
|
||||||
|
birdweather) echo " ZIP code: $ZIP" >&2 ;;
|
||||||
|
image) echo " Image URL: $IMAGE_URL" >&2 ;;
|
||||||
|
esac
|
||||||
|
if [ "$MAT_W" != "0" ] && [ "$MAT_H" != "0" ]; then
|
||||||
|
echo " Matte: ${MAT_W}\" × ${MAT_H}\"" >&2
|
||||||
|
else
|
||||||
|
echo " Matte: A5 (stock)" >&2
|
||||||
|
fi
|
||||||
|
echo " Title: $TITLE / $SUBTITLE" >&2
|
||||||
|
echo " Rotation: $ROTATE" >&2
|
||||||
|
if [ "$QUIET_START" != "0" ] || [ "$QUIET_END" != "0" ]; then
|
||||||
|
echo " Quiet hours: ${QUIET_START}:00 – ${QUIET_END}:00" >&2
|
||||||
|
fi
|
||||||
|
echo "" >&2
|
||||||
|
|
||||||
|
if ! confirm "Proceed with installation?"; then
|
||||||
|
echo "Cancelled." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- run install.sh ---------------------------------------------------------
|
||||||
|
hr
|
||||||
|
echo "" >&2
|
||||||
|
echo "Running install.sh $INSTALL_FLAGS ..." >&2
|
||||||
|
echo "" >&2
|
||||||
|
./install.sh $INSTALL_FLAGS
|
||||||
|
|
||||||
|
# --- write extra config on top of what install.sh generated -----------------
|
||||||
|
CONFIG="$HOME/.birdframe/config.toml"
|
||||||
|
|
||||||
|
# Patch base_url for local mode if not birdnet.local
|
||||||
|
if [ "$MODE" = "local" ] && [ "$BASE_URL" != "birdnet.local" ]; then
|
||||||
|
sed -i "s|^base_url = .*|base_url = \"http://$BASE_URL\"|" "$CONFIG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Matte opening
|
||||||
|
if [ "$MAT_W" != "0" ] && [ "$MAT_H" != "0" ]; then
|
||||||
|
printf '\n# Matte opening size (inches). Overrides the default A5.\nmat_opening_w = %s\nmat_opening_h = %s\n' \
|
||||||
|
"$MAT_W" "$MAT_H" >> "$CONFIG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Titles
|
||||||
|
if [ "$TITLE" != "Avian Visitors" ] || [ "$SUBTITLE" != "Heard Today" ]; then
|
||||||
|
sed -i "s|^shoot_title = .*|shoot_title = \"$TITLE\"|" "$CONFIG"
|
||||||
|
sed -i "s|^shoot_subtitle = .*|shoot_subtitle = \"$SUBTITLE\"|" "$CONFIG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rotation
|
||||||
|
sed -i "s|^rotate = .*|rotate = $ROTATE|" "$CONFIG"
|
||||||
|
|
||||||
|
# Quiet hours
|
||||||
|
if [ "$QUIET_START" != "0" ] || [ "$QUIET_END" != "0" ]; then
|
||||||
|
if grep -q "^quiet_start" "$CONFIG" 2>/dev/null; then
|
||||||
|
sed -i "s|^quiet_start = .*|quiet_start = $QUIET_START|" "$CONFIG"
|
||||||
|
sed -i "s|^quiet_end = .*|quiet_end = $QUIET_END|" "$CONFIG"
|
||||||
|
else
|
||||||
|
printf '\nquiet_start = %s\nquiet_end = %s\n' "$QUIET_START" "$QUIET_END" >> "$CONFIG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Basic auth
|
||||||
|
if [ -n "$BASIC_USER" ]; then
|
||||||
|
printf '\nbasic_user = "%s"\nbasic_pass = "%s"\n' "$BASIC_USER" "$BASIC_PASS" >> "$CONFIG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >&2
|
||||||
|
echo "Config written to $CONFIG" >&2
|
||||||
|
echo "Edit it any time: nano $CONFIG" >&2
|
||||||
|
echo "" >&2
|
||||||
Reference in New Issue
Block a user