[FEAT] collage: empty nest illustration when no birds have been heard (#25)

This commit is contained in:
Teddy Warner
2026-07-06 16:53:12 -07:00
committed by GitHub
parent 2d481d8e33
commit 54d241e184
5 changed files with 39 additions and 25 deletions
+2 -18
View File
@@ -177,7 +177,7 @@ def _centroid_x(img, paper):
TITLE_H_FRAC, COLLAGE_FRAC, GAP_FRAC = 0.065, 0.66, 0.1
def mat_and_center(img, mat, empty=False):
def mat_and_center(img, mat):
"""Crop the title and collage, size each to a fraction of the A5 opening,
stack with a gap, and centre on the panel."""
img = img.convert("RGB")
@@ -204,22 +204,6 @@ def mat_and_center(img, mat, empty=False):
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)
# No birds yet: hold the title where a full collage would put it and float
# the one-line note where the birds would be, so a birdless frame reads like
# a full one with the collage removed, not a title card drifted to the middle.
if empty and tb and cb:
title = _scale_h(img.crop(tb), box_h * TITLE_H_FRAC)
note = _scale_w(img.crop(cb), box_w * 0.30)
gap = round(box_h * GAP_FRAC)
region = box_w * COLLAGE_FRAC # stand in for a usual-size collage
cw = max(title.width, note.width)
comp = Image.new("RGB", (cw, round(title.height + gap + region)), paper)
comp.paste(title, ((cw - title.width) // 2, 0))
ny = title.height + gap + max(0, (round(region) - note.height) // 2)
comp.paste(note, ((cw - note.width) // 2, ny))
canvas = Image.new("RGB", (PANEL_W, PANEL_H), paper)
canvas.paste(comp, ((PANEL_W - comp.width) // 2, (PANEL_H - comp.height) // 2))
return canvas
if not (tb and cb):
return _place(img.crop(full), paper, mat)
title = _scale_h(img.crop(tb), box_h * TITLE_H_FRAC)
@@ -367,7 +351,7 @@ 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"], empty=(species == []))
img = mat_and_center(img, cfg["mat"])
if preview:
out = quantize_spectra6(img)
if mat_box:
+19 -6
View File
@@ -167,7 +167,7 @@ def shoot(url, out, *, title=None, subtitle=None, vw=600, vh=800, dsf=2,
mat=0.04, collage_vh=52, cluster_xbias=1.0, cluster_ybias=1.2,
count_exp=0.4, cluster_pad=1, small_floor=0.04, window_hours=None,
timeout_ms=45000, user=None, password=None, species=None, cutout_base=None,
cutout_local=None):
cutout_local=None, empty_text="listening for birds…"):
pad_side, pad_top, pad_bottom = int(vw * mat), int(vh * mat * 0.92), int(vh * mat)
auth = "Basic " + base64.b64encode(f"{user}:{password or ''}".encode()).decode() if user else None
@@ -206,6 +206,16 @@ def shoot(url, out, *, title=None, subtitle=None, vw=600, vh=800, dsf=2,
timeout=timeout_ms)
except PWTimeout:
print("some illustrations did not finish loading; capturing anyway", file=sys.stderr)
elif page.query_selector(".nest-img") is not None:
# Birdless empty state: wait for the nest illustration to load so
# the frame never captures a blank collage area.
try:
page.wait_for_function(
"() => { const n=document.querySelector('.nest-img');"
" return n && n.complete && n.naturalWidth>0; }",
timeout=timeout_ms)
except PWTimeout:
print("nest illustration did not finish loading; capturing anyway", file=sys.stderr)
if misses:
raise RuntimeError(f"apt.js tunables not found ({len(misses)}); refusing to ship a half-tuned frame")
@@ -213,10 +223,11 @@ def shoot(url, out, *, title=None, subtitle=None, vw=600, vh=800, dsf=2,
page.evaluate("t=>{const e=document.querySelector('.static-head .pre'); if(e)e.textContent=t;}", title)
if subtitle is not None:
page.evaluate("s=>{const e=document.querySelector('.static-head h1'); if(e)e.textContent=s;}", subtitle)
# Soften the empty-state line for a fresh frame whose mic hasn't
# heard a bird yet, and darken it so it survives the e-ink dither and
# the matting step's ink detection (a no-op once there are birds).
page.evaluate("() => { const e = document.querySelector('.empty'); if (e) { e.textContent = 'listening for birds…'; e.style.color = '#555'; } }")
# Set the empty-state line for a birdless frame (the mic hasn't heard
# anything yet, or BirdWeather has no recent detections nearby) and
# darken it so it survives the e-ink dither and the matting step's ink
# detection (a no-op once there are birds).
page.evaluate("(t) => { const e = document.querySelector('.empty'); if (e) { e.textContent = t; e.style.color = '#555'; } }", empty_text)
page.wait_for_timeout(250)
# clip is CSS px; device_scale_factor scales the PNG to vw*dsf by vh*dsf = 1200x1600
page.screenshot(path=out, clip={"x": 0, "y": 0, "width": vw, "height": vh})
@@ -241,7 +252,9 @@ def shoot_birdweather(out, species, *, title=None, subtitle=None, timeout_ms=450
cutout_local = os.path.join(here, "..", "avian", "assets", "illustrations")
# BirdWeather's flat 7-day counts need a steeper exponent for the same hero
# hierarchy; the slightly smaller titles match the mic frame's optical weight.
for k, v in (("count_exp", 1.0), ("headline_px", 39), ("eyebrow_px", 17)):
# A birdless BirdWeather frame says "no recent detections nearby", not "listening".
for k, v in (("count_exp", 1.0), ("headline_px", 39), ("eyebrow_px", 17),
("empty_text", "no recent detections nearby")):
look.setdefault(k, v)
return shoot(f"http://127.0.0.1:{port}/", out,
title=title or "Avian Visitors", subtitle=subtitle or "Heard Today",