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:
Chris Sader
2026-07-10 15:53:30 -05:00
parent 858c555c0c
commit 6884cce3c7
4 changed files with 315 additions and 21 deletions
+37 -17
View File
@@ -51,7 +51,9 @@ DEFAULTS = {
"shoot_title": None, "shoot_subtitle": None,
"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,
"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
"saturation": 0.6,
"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))
# The mat opening is an A5 rectangle (1 : sqrt(2)) centred in the panel; the
# content floats inside it with `mat` of inner whitespace.
A5_H = PANEL_H * 0.7071 # A5 is 1/sqrt(2) of the panel height
A5_W = A5_H / 1.41421 # A5 aspect 1 : sqrt(2)
# The mat opening defaults to A5 (1 : sqrt(2)) for the standard kit frame.
# Override with mat_opening_w / mat_opening_h (in inches) for custom frames.
_A5_H = PANEL_H * 0.7071
_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):
s = min(A5_W * (1 - mat) / content.width, A5_H * (1 - mat) / content.height)
def _opening_dims(cfg):
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))
content = content.resize((nw, nh), Image.LANCZOS)
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
def mat_and_center(img, mat):
"""Crop the title and collage, size each to a fraction of the A5 opening,
def mat_and_center(img, mat, opening=None):
"""Crop the title and collage, size each to a fraction of the opening,
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")
paper = _paper(img)
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):
if levels[y] <= 2:
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
while cy < bot and levels[cy] <= 2:
cy += 1
@@ -203,9 +221,9 @@ def mat_and_center(img, mat):
run = 0
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
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):
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)
gap = round(box_h * GAP_FRAC)
# 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")
def _draw_mat_box(img):
"""Dev aid: outline the A5 mat opening so the matte and centring show."""
x0, y0 = round((PANEL_W - A5_W) / 2), round((PANEL_H - A5_H) / 2)
def _draw_mat_box(img, opening=None):
"""Dev aid: outline the mat opening so the matte and centring show."""
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),
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:
print(f"could not get image: {e}", file=sys.stderr) # keep last panel image
return
img = mat_and_center(img, cfg["mat"])
opening = _opening_dims(cfg)
img = mat_and_center(img, cfg["mat"], opening)
if preview:
out = quantize_spectra6(img)
if mat_box:
_draw_mat_box(out)
_draw_mat_box(out, opening)
out.save(preview)
print(f"wrote preview {preview}")
return