Compare commits
10 Commits
672599fa3f
...
6884cce3c7
| Author | SHA1 | Date | |
|---|---|---|---|
| 6884cce3c7 | |||
| 858c555c0c | |||
| 57e146fa0b | |||
| 54d241e184 | |||
| 2d481d8e33 | |||
| d879ced6d4 | |||
| 9915e5c86a | |||
| 268b682c9d | |||
| f8b467e9a2 | |||
| 454c64cd77 |
@@ -12,7 +12,7 @@ See it running at [bird.onethreenine.net](https://bird.onethreenine.net).
|
||||
|
||||
| Qty | Description | Price | Link | Notes |
|
||||
|-----|-------------|-------|------| ----- |
|
||||
| 1 | Raspberry Pi (4B / 5 / Zero 2W) | ~$35-80 | [Amazon](https://amzn.to/43yLDZJ) | [See note for RPi20](https://github.com/mcguirepr89/BirdNET-Pi/wiki/RPi0W2-Installation-Guide) |
|
||||
| 1 | Raspberry Pi (4B / 5 / 3A+ / Zero 2W) | ~$25-80 | [Amazon](https://amzn.to/43yLDZJ) | [See note for 512 MB Pis](https://github.com/mcguirepr89/BirdNET-Pi/wiki/RPi0W2-Installation-Guide) |
|
||||
| 1 | Micro SD Card (≥32 GB) | ~$10 | [Amazon](https://amzn.to/4eGy7te) | |
|
||||
| 1 | USB lavalier microphone | $16.95 | [Amazon](https://amzn.to/4vLSaMK) | |
|
||||
| 1 | Pi power supply | ~$10 | - | |
|
||||
|
||||
+52
-5
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
@@ -317,6 +317,14 @@
|
||||
transform: translateX(18px);
|
||||
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
|
||||
a track row underneath. Value sits at the far right with a wide
|
||||
@@ -591,6 +599,30 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* No-birds state: an empty-nest illustration sits where the collage would be,
|
||||
with the status line beneath it. Fills the collage box so the frame's
|
||||
matting treats it like any other collage. */
|
||||
.empty-nest {
|
||||
height: 100%;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
gap: 28px;
|
||||
}
|
||||
.empty-nest .nest-img { width: 78%; max-width: 470px; height: auto; }
|
||||
.empty-nest .empty { padding: 0; margin: 0; }
|
||||
/* Bloom the empty nest in on the same cues as the collage, reusing the tile
|
||||
keyframe (gtile-in): the nest fades + scales, the status line trails it. */
|
||||
.empty-nest.entering .nest-img {
|
||||
animation: gtile-in 420ms cubic-bezier(.2, .7, .3, 1) backwards;
|
||||
}
|
||||
.empty-nest.entering .empty {
|
||||
animation: gtile-in 420ms cubic-bezier(.2, .7, .3, 1) 130ms backwards;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.empty-nest.entering .nest-img,
|
||||
.empty-nest.entering .empty { animation: none; }
|
||||
}
|
||||
|
||||
/* Page header is now shared at .stage > .static-head - see top of CSS. */
|
||||
/* Stats grid: charts stack on the LEFT, text sections on the RIGHT.
|
||||
Each chart is a full-bleed card; each text section is a list with
|
||||
|
||||
+14
-3
@@ -12,12 +12,14 @@ A [Pimoroni Inky Impression 13.3"](https://amzn.to/4xlAWr3) (Spectra 6) mirrorin
|
||||
|
||||
| Qty | Description | Price | Link |
|
||||
|-----|-------------|-------|------|
|
||||
| 1 | Raspberry Pi Zero (2) W | ~$35 | [Amazon](https://amzn.to/49Xp58I) |
|
||||
| 1 | Raspberry Pi 3 A+ or Zero 2 W | ~$25-35 | [Amazon](https://amzn.to/49Xp58I) |
|
||||
| 1 | 13.3" E Ink Display | $299.99 | [Amazon](https://amzn.to/4xlAWr3) |
|
||||
| 1 | A4 Wood Photo Frame | $21.99 | [Amazon](https://amzn.to/3RWFbJE) |
|
||||
| 1 | Long, Flat Micro USB Cable | $7.99 | [Amazon](https://a.co/d/0a59rKSk) |
|
||||
| 1 | Flat USB Brick | $7.59 | [Amazon](https://amzn.to/3S4CtSs) |
|
||||
| | **Total** | **~$372** | | |
|
||||
| | **Total** | **~$365** | | |
|
||||
|
||||
The 3 A+ and Zero 2 W are both tested and set up identically; any Pi with the 40-pin header that runs 64-bit Raspberry Pi OS works. The printed backing pressure-fits either board.
|
||||
|
||||
CAD + 3d print files can be found in [`hardware/`](hardware/).
|
||||
|
||||
@@ -45,6 +47,7 @@ Then install in Pi and power up.
|
||||
|
||||
```bash
|
||||
ssh <your-username>@birdpic.local
|
||||
sudo apt update && sudo apt install -y git
|
||||
git clone https://github.com/Twarner491/AvianVisitors
|
||||
cd AvianVisitors/frame
|
||||
```
|
||||
@@ -64,4 +67,12 @@ Pick how the frame gets its birds:
|
||||
|
||||
Each one enables SPI + I2C, installs the deps and a systemd timer, writes `~/.birdframe/config.toml`, and reboots once to bring SPI up. Full options live in [`config.example.toml`](config.example.toml).
|
||||
|
||||
BirdWeather mode renders on the Pi from this repo's illustrations on GitHub, so there is no image set to copy over. ZIP codes with no station nearby fall back to the closest ones, and an optional `EBIRD_API_KEY` covers the most remote spots.
|
||||
BirdWeather mode renders on the Pi from this repo's illustrations on GitHub, so there is no image set to copy over. ZIP codes with no station nearby fall back to the closest ones. If you are far from any BirdWeather station, add `--ebird-key <key>` (a free key from [ebird.org/api/keygen](https://ebird.org/api/keygen)) and the frame fills from eBird sightings instead.
|
||||
|
||||
The bundled illustrations center on the western U.S. If birds near your ZIP aren't in the set you cloned, the installer flags them and the frame skips them until they exist. To generate them, run [`generate_illustrations.py`](generate_illustrations.py) on a laptop or workstation (it uses the same rembg cutout as the rest of the pipeline, which the Pi can't fit in memory), passing your ZIP and a paid Google Gemini key, then commit the new cutouts or copy them to the Pi:
|
||||
|
||||
```bash
|
||||
python3 generate_illustrations.py --zip 10001 --gemini-key YOUR_GEMINI_KEY
|
||||
```
|
||||
|
||||
It generates only the species you're missing; `--country` and `--sample` carry through for non-US postcodes or a wider region.
|
||||
|
||||
+86
-6
@@ -23,6 +23,7 @@ EBIRD = "https://api.ebird.org/v2/data/obs/geo/recent"
|
||||
APT_JS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "avian", "frontend", "apt.js")
|
||||
|
||||
_drawable = None
|
||||
_GEO_CACHE = os.path.expanduser("~/.birdframe/geocode.json")
|
||||
|
||||
|
||||
def _graphql(query, timeout):
|
||||
@@ -39,12 +40,37 @@ def _graphql(query, timeout):
|
||||
|
||||
|
||||
def geocode(zip_code, country="us", timeout=20):
|
||||
"""ZIP / postal code to (lat, lon) via the keyless zippopotam.us gazetteer."""
|
||||
url = f"{GEOCODER}/{country}/{urllib.parse.quote(zip_code.strip())}"
|
||||
"""ZIP / postal code to (lat, lon) via the keyless zippopotam.us gazetteer.
|
||||
A ZIP's coordinates never move, so the result is cached on disk and the
|
||||
frame geocodes once rather than on every 15-minute refresh."""
|
||||
zip_code = zip_code.strip()
|
||||
key = f"{country}/{zip_code}".lower()
|
||||
store = {}
|
||||
try:
|
||||
with open(_GEO_CACHE) as f:
|
||||
loaded = json.load(f)
|
||||
if isinstance(loaded, dict):
|
||||
store = loaded
|
||||
hit = store.get(key)
|
||||
if isinstance(hit, list) and len(hit) == 2: # shape-guard a hand-edited / stale entry
|
||||
return float(hit[0]), float(hit[1])
|
||||
except Exception:
|
||||
store = {}
|
||||
url = f"{GEOCODER}/{country}/{urllib.parse.quote(zip_code)}"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "AvianVisitors-frame/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
place = json.loads(r.read(200_000))["places"][0]
|
||||
return float(place["latitude"]), float(place["longitude"])
|
||||
lat, lon = float(place["latitude"]), float(place["longitude"])
|
||||
store[key] = [lat, lon]
|
||||
try:
|
||||
os.makedirs(os.path.dirname(_GEO_CACHE), exist_ok=True)
|
||||
tmp = _GEO_CACHE + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(store, f)
|
||||
os.replace(tmp, _GEO_CACHE) # atomic, like display.py's state write
|
||||
except Exception:
|
||||
pass
|
||||
return lat, lon
|
||||
|
||||
|
||||
def bbox(lat, lon, miles):
|
||||
@@ -194,13 +220,67 @@ def species_for_zip(zip_code, country="us", target=10, days=7, radii=(15, 30, 50
|
||||
return found[:target]
|
||||
|
||||
|
||||
def coverage_for_zip(zip_code, country="us", sample=15, days=7, radii=(15, 30, 50),
|
||||
apt_js=APT_JS, timeout=20):
|
||||
"""Split the ZIP's most-detected birds by whether the repo can draw them.
|
||||
|
||||
Same gather as species_for_zip (grow the radius, then triangulate / eBird
|
||||
for sparse spots) but UNFILTERED, capped at the top `sample` by count, then
|
||||
partitioned. Returns (have, missing) as [{sci,com,n}] lists sorted by count:
|
||||
`have` are bundled today, `missing` are the local birds with no cutout yet -
|
||||
the set the installer offers to generate. `sample` sits a little above the
|
||||
render's `target` (10) so a few extra cover BirdWeather count drift without
|
||||
generating birds that never reach the collage. The split is always against
|
||||
the current bundle, so adding illustrations upstream shrinks `missing` free.
|
||||
"""
|
||||
lat, lon = geocode(zip_code, country, timeout)
|
||||
top = []
|
||||
for miles in radii:
|
||||
top = top_species(lat, lon, miles, days, 60, timeout)
|
||||
if len(top) >= sample:
|
||||
break
|
||||
if len(top) < sample:
|
||||
tri = triangulate(lat, lon, 3, days, timeout)
|
||||
if len(tri) > len(top):
|
||||
top = tri
|
||||
if len(top) < sample:
|
||||
eb = ebird_nearby(lat, lon, days * 2, timeout=timeout)
|
||||
if len(eb) > len(top):
|
||||
top = eb
|
||||
top.sort(key=lambda s: -(s["n"] or 0))
|
||||
top = top[:sample]
|
||||
drawable = drawable_slugs(apt_js)
|
||||
have = [s for s in top if slugify(s["sci"]) in drawable]
|
||||
missing = [s for s in top if slugify(s["sci"]) not in drawable]
|
||||
return have, missing
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser(description="Print BirdWeather top drawable species for a ZIP.")
|
||||
import sys
|
||||
ap = argparse.ArgumentParser(description="BirdWeather top species for a ZIP.")
|
||||
ap.add_argument("zip")
|
||||
ap.add_argument("--country", default="us")
|
||||
ap.add_argument("--target", type=int, default=10)
|
||||
ap.add_argument("--days", type=int, default=7)
|
||||
ap.add_argument("--missing", action="store_true",
|
||||
help="Print the ZIP's top LOCAL birds the repo can't draw yet "
|
||||
"as Sci|Com lines (feed to pregen.py --stdin); summary to stderr.")
|
||||
ap.add_argument("--sample", type=int, default=15,
|
||||
help="How many of the ZIP's top birds to weigh for --missing (default 15).")
|
||||
a = ap.parse_args()
|
||||
for s in species_for_zip(a.zip, a.country, a.target, a.days):
|
||||
print(f'{s["n"]:>8} {s["com"]} ({s["sci"]})')
|
||||
if a.missing:
|
||||
try:
|
||||
have, missing = coverage_for_zip(a.zip, a.country, a.sample, a.days)
|
||||
except Exception as e:
|
||||
# Never break the installer: no data just means no generation offer.
|
||||
print(f"coverage lookup failed: {e}", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
for s in missing:
|
||||
print(f'{s["sci"]}|{s["com"]}')
|
||||
total = len(have) + len(missing)
|
||||
print(f"{len(have)} of the top {total} birds near {a.zip} are bundled; "
|
||||
f"{len(missing)} are not.", file=sys.stderr)
|
||||
else:
|
||||
for s in species_for_zip(a.zip, a.country, a.target, a.days):
|
||||
print(f'{s["n"]:>8} {s["com"]} ({s["sci"]})')
|
||||
|
||||
@@ -14,6 +14,14 @@ shoot = true
|
||||
# Or read a PNG a sibling process drops next to us:
|
||||
# image = "~/.birdframe/frame.png"
|
||||
|
||||
# Or render from BirdWeather for any ZIP, no mic (what install.sh --bird-weather
|
||||
# writes). The panel still refreshes only when the local top birds change:
|
||||
# species_source = "birdweather"
|
||||
# zip = "94107"
|
||||
# bw_days = 7 # BirdWeather lookback window, in days
|
||||
# bw_country = "us" # geocoder country for the ZIP
|
||||
# shoot = true # this Pi renders the collage
|
||||
|
||||
hours = 24 # window shown on the frame
|
||||
|
||||
# --- Titles and look (used when this Pi renders, i.e. shoot = true) ----------
|
||||
|
||||
+65
-36
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Frame-Pi client: turn a collage screenshot into Inky panel pixels.
|
||||
|
||||
Runs on the Pi Zero W on a systemd timer. Each run it decides whether a
|
||||
Runs on the frame Pi (a 3 A+ or Zero 2 W) on a systemd timer. Each run it decides whether a
|
||||
refresh is worth it (the species set or call-count brackets changed, and it
|
||||
is not quiet hours), then crops the title and collage from the screenshot,
|
||||
centres and mats them, and pushes the result to the Inky Impression 13.3".
|
||||
@@ -40,14 +40,20 @@ SPECTRA6 = [(236, 234, 223), (26, 26, 28), (165, 60, 56),
|
||||
|
||||
DEFAULTS = {
|
||||
"base_url": "http://birdnet.local",
|
||||
"species_source": "", # "" = the recent API at base_url; "birdweather" = BirdWeather near a ZIP
|
||||
"zip": "", # BirdWeather ZIP / postal code (with species_source = "birdweather")
|
||||
"bw_days": 7, # BirdWeather lookback window, in days
|
||||
"bw_country": "us", # geocoder country for the ZIP
|
||||
"hours": 24,
|
||||
"image": "", # local PNG written by the shooter
|
||||
"image_url": "", # or a published screenshot URL
|
||||
"shoot": False, # or capture inline (needs a browser; the Zero 2 W handles it)
|
||||
"shoot": False, # or capture inline (needs a browser; the 3 A+ and Zero 2 W both handle it)
|
||||
"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
|
||||
@@ -93,6 +99,16 @@ def signature(species):
|
||||
return hashlib.sha256(json.dumps(items).encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def fetch_species(cfg, auth=None):
|
||||
"""The species list the signature is built from: the BirdNET-Pi recent API
|
||||
by default, or BirdWeather's recent detections near a ZIP when
|
||||
species_source = "birdweather"."""
|
||||
if cfg.get("species_source") == "birdweather":
|
||||
import birdweather
|
||||
return birdweather.species_for_zip(cfg["zip"], country=cfg["bw_country"], days=cfg["bw_days"])
|
||||
return fetch_recent(cfg["base_url"], cfg["hours"], cfg["timeout"], auth)
|
||||
|
||||
|
||||
# --- image ------------------------------------------------------------------
|
||||
def get_image(src, timeout, auth=None):
|
||||
if re.match(r"^https?://", src):
|
||||
@@ -117,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)
|
||||
@@ -163,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, empty=False):
|
||||
"""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))
|
||||
@@ -179,7 +211,7 @@ def mat_and_center(img, mat, empty=False):
|
||||
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
|
||||
@@ -189,23 +221,9 @@ def mat_and_center(img, mat, empty=False):
|
||||
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)
|
||||
# No birds: the content under the title is just the one-line empty-state
|
||||
# note. Render a calm title card (a modest title with the small note
|
||||
# below) rather than blowing the lone title up to fill the opening.
|
||||
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 * 0.05)
|
||||
cw = max(title.width, note.width)
|
||||
comp = Image.new("RGB", (cw, title.height + gap + note.height), paper)
|
||||
comp.paste(title, ((cw - title.width) // 2, 0))
|
||||
comp.paste(note, ((cw - note.width) // 2, title.height + gap))
|
||||
canvas = Image.new("RGB", (PANEL_W, PANEL_H), paper)
|
||||
canvas.paste(comp, ((PANEL_W - comp.width) // 2, (PANEL_H - comp.height) // 2))
|
||||
return canvas
|
||||
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)
|
||||
|
||||
@@ -298,7 +317,16 @@ def in_quiet_hours(cfg, hour):
|
||||
|
||||
|
||||
# --- run --------------------------------------------------------------------
|
||||
def obtain_image(cfg):
|
||||
def obtain_image(cfg, species=None):
|
||||
if cfg.get("species_source") == "birdweather":
|
||||
from shoot import shoot_birdweather
|
||||
if species is None: # gate skipped (--no-signature): fetch the list to render
|
||||
species = fetch_species(cfg, _auth(cfg))
|
||||
out = os.path.join(os.path.expanduser(cfg["cache"]), "frame.png")
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
shoot_birdweather(out, species, title=cfg["shoot_title"], subtitle=cfg["shoot_subtitle"],
|
||||
timeout_ms=cfg["timeout"] * 1000)
|
||||
return Image.open(out).convert("RGB")
|
||||
if cfg["shoot"]:
|
||||
from shoot import shoot
|
||||
out = os.path.join(os.path.expanduser(cfg["cache"]), "shot.png")
|
||||
@@ -322,7 +350,7 @@ def run(cfg, preview=None, force=False, use_signature=True, mat_box=False):
|
||||
species = None
|
||||
if use_signature:
|
||||
try:
|
||||
species = fetch_recent(cfg["base_url"], cfg["hours"], cfg["timeout"], _auth(cfg))
|
||||
species = fetch_species(cfg, _auth(cfg))
|
||||
sig = signature(species)
|
||||
except Exception as e:
|
||||
print(f"signature fetch failed: {e}", file=sys.stderr) # treat as no change
|
||||
@@ -338,15 +366,16 @@ def run(cfg, preview=None, force=False, use_signature=True, mat_box=False):
|
||||
print("refresh:", "changed" if changed else "heal")
|
||||
|
||||
try:
|
||||
img = fit_panel(obtain_image(cfg))
|
||||
img = fit_panel(obtain_image(cfg, species))
|
||||
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 == []))
|
||||
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
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the kachō-e illustrations a ZIP needs but the repo doesn't bundle.
|
||||
|
||||
Compares a ZIP's most-detected birds (BirdWeather) against the bundled
|
||||
illustration set and, for the gap, runs the repo's illustration pipeline:
|
||||
|
||||
pregen.py (Gemini, cream ground)
|
||||
-> cutout.py (BiRefNet matte via rembg)
|
||||
-> build_masks.py (so the new birds become drawable)
|
||||
|
||||
The bundled set centers on the western U.S., so an out-of-region frame drops
|
||||
its local birds until they are generated. Run this on a machine with the
|
||||
pipeline deps (rembg + onnxruntime, per avian/scripts/requirements.txt) - a
|
||||
laptop or workstation, not the frame Pi, which can't fit rembg in memory. Then
|
||||
commit the new cutouts (or copy them to the Pi) and the frame draws them.
|
||||
|
||||
Needs a PAID Google Gemini API key: image generation is not on the free tier.
|
||||
Get one at https://ai.google.dev, then pass it by env (keeps it out of shell
|
||||
history) or let it prompt:
|
||||
|
||||
GEMINI_API_KEY=KEY python3 frame/generate_illustrations.py --zip 10001
|
||||
python3 frame/generate_illustrations.py --zip 10001 # prompts for the key
|
||||
python3 frame/generate_illustrations.py --zip 10001 --gemini-key KEY
|
||||
|
||||
--country and --sample carry through for non-US postcodes or a wider region.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SCRIPTS = os.path.join(HERE, "..", "avian", "scripts")
|
||||
ILLUSTRATIONS = os.path.join(HERE, "..", "avian", "assets", "illustrations")
|
||||
|
||||
sys.path.insert(0, HERE)
|
||||
import birdweather # noqa: E402 (local module, after sys.path is set)
|
||||
|
||||
|
||||
def _generate(missing, key):
|
||||
"""Run pregen -> cutout (rembg) -> build_masks for the missing species.
|
||||
Returns 0 on success."""
|
||||
lines = "".join(f'{s["sci"]}|{s["com"]}\n' for s in missing)
|
||||
print(f"\nGenerating {len(missing)} birds x 2 poses with Gemini (a few minutes; "
|
||||
f"~6s between calls to stay under the rate limit)...")
|
||||
# Key via env, not argv, so it doesn't surface in `ps` during the long run.
|
||||
env = dict(os.environ, GEMINI_API_KEY=key)
|
||||
r = subprocess.run([sys.executable, os.path.join(SCRIPTS, "pregen.py"), "--stdin"],
|
||||
input=lines, text=True, env=env)
|
||||
if r.returncode not in (0, 1): # 1 = some species failed; still cut what landed
|
||||
print("generation failed; leaving the bundle unchanged", file=sys.stderr)
|
||||
return r.returncode
|
||||
|
||||
made = []
|
||||
for s in missing:
|
||||
base = birdweather.slugify(s["sci"])
|
||||
made += [g for g in (base, base + "-2")
|
||||
if os.path.isfile(os.path.join(ILLUSTRATIONS, g + ".png"))]
|
||||
if not made:
|
||||
print("no illustrations were produced; nothing to cut", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"\nCutting the cream ground off {len(made)} new illustrations (rembg)...")
|
||||
c = subprocess.run([sys.executable, os.path.join(SCRIPTS, "cutout.py")] + made)
|
||||
if c.returncode != 0:
|
||||
print("cutout failed; not rebuilding masks", file=sys.stderr)
|
||||
return c.returncode
|
||||
|
||||
print("\nRebuilding the collage masks so the new birds become drawable...")
|
||||
b = subprocess.run([sys.executable, os.path.join(SCRIPTS, "build_masks.py")])
|
||||
if b.returncode != 0:
|
||||
return b.returncode
|
||||
print(f"\nDone - {len(made)} new illustrations are cut and drawable. Commit them "
|
||||
f"(or copy them to the frame Pi) and the collage will draw them.")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--zip", required=True)
|
||||
ap.add_argument("--country", default="us")
|
||||
ap.add_argument("--gemini-key", default=os.environ.get("GEMINI_API_KEY", ""),
|
||||
help="Paid Google Gemini API key (or GEMINI_API_KEY env).")
|
||||
ap.add_argument("--sample", type=int, default=15,
|
||||
help="How many of the ZIP's top birds to weigh (default 15).")
|
||||
a = ap.parse_args()
|
||||
|
||||
try:
|
||||
have, missing = birdweather.coverage_for_zip(a.zip, a.country, a.sample)
|
||||
except Exception as e:
|
||||
print(f"coverage lookup for {a.zip} failed ({e})", file=sys.stderr)
|
||||
return 1
|
||||
total = len(have) + len(missing)
|
||||
if not missing:
|
||||
if total == 0:
|
||||
print(f"Couldn't find local birds for {a.zip} (BirdWeather may have no "
|
||||
f"station nearby).")
|
||||
else:
|
||||
print(f"All {total} of the top birds near {a.zip} are already bundled - "
|
||||
f"nothing to generate.")
|
||||
return 0
|
||||
|
||||
names = ", ".join(s["com"] for s in missing[:6]) + (" ..." if len(missing) > 6 else "")
|
||||
print(f"{len(missing)} of the top {total} birds near {a.zip} aren't bundled:\n {names}")
|
||||
|
||||
key = a.gemini_key
|
||||
if not key:
|
||||
if not sys.stdin.isatty():
|
||||
print("\nPass --gemini-key <KEY> (a paid Google Gemini key, https://ai.google.dev) "
|
||||
"to generate them.", file=sys.stderr)
|
||||
return 1
|
||||
key = getpass.getpass("\nPaste your Gemini API key (paid; https://ai.google.dev): ").strip()
|
||||
if not key:
|
||||
print("No key entered.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return _generate(missing, key)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Binary file not shown.
Binary file not shown.
+12244
-22033
File diff suppressed because it is too large
Load Diff
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
|
||||
+58
-31
@@ -8,6 +8,7 @@
|
||||
# ./install.sh --image-url <URL> fetch a ready-made frame PNG instead
|
||||
# (e.g. a public Cloudflare Worker)
|
||||
# ./install.sh --bird-weather --zip <ZIP> standalone from BirdWeather, no mic
|
||||
# (add --ebird-key <KEY> for remote ZIPs)
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
FRAME="$(pwd)"
|
||||
@@ -15,6 +16,7 @@ FRAME="$(pwd)"
|
||||
MODE=local # local | image | birdweather
|
||||
ZIP=""
|
||||
IMAGE_URL=""
|
||||
EBIRD_KEY=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--bird-weather) MODE=birdweather; shift ;;
|
||||
@@ -24,6 +26,9 @@ while [ $# -gt 0 ]; do
|
||||
--image-url) [ $# -ge 2 ] || { echo "--image-url needs a URL, e.g. --image-url https://bird.example/frame.png" >&2; exit 1; }
|
||||
MODE=image; IMAGE_URL="$2"; shift 2 ;;
|
||||
--image-url=*) MODE=image; IMAGE_URL="${1#*=}"; shift ;;
|
||||
--ebird-key) [ $# -ge 2 ] || { echo "--ebird-key needs a value (a free key from ebird.org/api/keygen)" >&2; exit 1; }
|
||||
EBIRD_KEY="$2"; shift 2 ;;
|
||||
--ebird-key=*) EBIRD_KEY="${1#*=}"; shift ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
@@ -32,6 +37,10 @@ if [ -n "$ZIP" ] && [ "$MODE" != birdweather ]; then
|
||||
echo "--zip only applies with --bird-weather" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$EBIRD_KEY" ] && [ "$MODE" != birdweather ]; then
|
||||
echo "--ebird-key only applies with --bird-weather" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate inputs up front: a bad value would otherwise land in a config file or
|
||||
# a systemd unit verbatim. These checks also reject a flag passed as a value
|
||||
@@ -45,6 +54,10 @@ if [ "$MODE" = birdweather ]; then
|
||||
echo "--zip should look like a postal code, e.g. 94107 or SW1A 1AA" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$EBIRD_KEY" ] && ! printf '%s' "$EBIRD_KEY" | LC_ALL=C grep -qE '^[A-Za-z0-9]+$'; then
|
||||
echo "--ebird-key should be the alphanumeric token from ebird.org/api/keygen" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ "$MODE" = image ]; then
|
||||
if [ -z "$IMAGE_URL" ]; then
|
||||
@@ -128,38 +141,34 @@ elif [ "$MODE" = image ]; then
|
||||
printf '%s\n' 'saturation = 0.6'
|
||||
} > "$CONFIG"
|
||||
else
|
||||
{ printf '%s\n' '# birdframe-mode: birdweather'; cat config.example.toml; } > "$CONFIG"
|
||||
# birdweather: this Pi renders from BirdWeather near $ZIP, gated on the same
|
||||
# signature as the other modes - it only redraws when the local top birds change.
|
||||
{
|
||||
printf '%s\n' '# birdframe-mode: birdweather'
|
||||
printf '%s\n' '# AvianVisitors frame, BirdWeather mode: renders the top birds near a ZIP.'
|
||||
printf '%s\n' 'species_source = "birdweather"'
|
||||
printf 'zip = "%s"\n' "$ZIP"
|
||||
printf '%s\n' 'bw_days = 7 # BirdWeather lookback window, in days'
|
||||
printf '%s\n' 'bw_country = "us" # geocoder country for the ZIP'
|
||||
printf '%s\n' 'shoot = true # this Pi renders the collage'
|
||||
printf '%s\n' 'shoot_title = "Avian Visitors"'
|
||||
printf '%s\n' 'shoot_subtitle = "Heard Today"'
|
||||
printf '%s\n' 'rotate = 90 # flip to 270 if the frame hangs the other way up'
|
||||
printf '%s\n' 'saturation = 0.6'
|
||||
} > "$CONFIG"
|
||||
fi
|
||||
|
||||
echo "5/5 Installing systemd service + timer..."
|
||||
if [ "$MODE" = birdweather ]; then
|
||||
PY="$FRAME/.venv/bin/python"
|
||||
PNG="$HOME/.birdframe/frame.png"
|
||||
sudo tee /etc/systemd/system/birdframe.service >/dev/null <<SERVICE
|
||||
[Unit]
|
||||
Description=AvianVisitors frame, BirdWeather mode (ZIP $ZIP)
|
||||
Documentation=https://github.com/Twarner491/AvianVisitors
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=$USER
|
||||
WorkingDirectory=$FRAME
|
||||
ExecStart=/bin/sh -c '$PY $FRAME/shoot.py --bird-weather --zip "$ZIP" --out $PNG && $PY $FRAME/display.py --config $HOME/.birdframe/config.toml --image-url $PNG --no-signature --force'
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Nice=10
|
||||
TimeoutStartSec=300
|
||||
SERVICE
|
||||
# BirdWeather's recent-species list drifts slowly, so refresh a few times a day.
|
||||
sed 's|OnUnitActiveSec=.*|OnUnitActiveSec=6h|' systemd/birdframe.timer \
|
||||
| sudo tee /etc/systemd/system/birdframe.timer >/dev/null
|
||||
else
|
||||
# local + image both run display.py against the config; only the config differs.
|
||||
sed "s|/home/monalisa/AvianVisitors/frame|$FRAME|g; s|/home/monalisa|$HOME|g; s|User=monalisa|User=$USER|" \
|
||||
systemd/birdframe.service | sudo tee /etc/systemd/system/birdframe.service >/dev/null
|
||||
sudo cp systemd/birdframe.timer /etc/systemd/system/birdframe.timer
|
||||
# Every mode runs display.py against the config on the standard 15-minute timer;
|
||||
# only the config differs. display.py renders inline for local + birdweather and
|
||||
# pushes to the panel only when the birds change.
|
||||
sed "s|/home/monalisa/AvianVisitors/frame|$FRAME|g; s|/home/monalisa|$HOME|g; s|User=monalisa|User=$USER|" \
|
||||
systemd/birdframe.service | sudo tee /etc/systemd/system/birdframe.service >/dev/null
|
||||
# BirdWeather's remote-ZIP eBird fallback reads its key from the unit environment.
|
||||
if [ "$MODE" = birdweather ] && [ -n "$EBIRD_KEY" ]; then
|
||||
echo "Environment=EBIRD_API_KEY=$EBIRD_KEY" | sudo tee -a /etc/systemd/system/birdframe.service >/dev/null
|
||||
fi
|
||||
sudo cp systemd/birdframe.timer /etc/systemd/system/birdframe.timer
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now birdframe.timer # --now starts it immediately, not only on the next boot
|
||||
|
||||
@@ -184,10 +193,28 @@ DONE
|
||||
birdweather)
|
||||
cat <<DONE
|
||||
|
||||
Installed in BirdWeather mode for ZIP $ZIP. The frame renders the top birds
|
||||
near you on the Pi and refreshes every 6 hours. Cutouts come from the repo on
|
||||
GitHub, so add illustrations there for any local birds it is missing.
|
||||
Installed in BirdWeather mode for ZIP $ZIP. The frame renders the top birds near
|
||||
you on the Pi and refreshes every 15 min, only when the local top birds change.
|
||||
DONE
|
||||
# The bundled illustrations center on the western U.S. If birds near this ZIP
|
||||
# aren't in the cloned set the frame quietly skips them, which has tripped
|
||||
# people up - surface it here and point at the generator.
|
||||
MISSING="$("$FRAME/.venv/bin/python" "$FRAME/birdweather.py" "$ZIP" --missing 2>/dev/null || true)"
|
||||
if [ -n "$MISSING" ]; then
|
||||
N="$(printf '%s\n' "$MISSING" | grep -c . || true)"
|
||||
NAMES="$(printf '%s\n' "$MISSING" | head -8 | sed 's/.*|/ /')"
|
||||
if [ "$N" -gt 8 ]; then NAMES="$NAMES
|
||||
... and $((N - 8)) more"; fi
|
||||
cat <<FLAG
|
||||
Heads up: $N local bird(s) near you aren't in the illustration set you cloned, so
|
||||
the frame will skip them:
|
||||
$NAMES
|
||||
To add them, run this on a laptop or workstation (it needs rembg, which the Pi
|
||||
can't fit) and commit or copy the new cutouts over:
|
||||
python3 $FRAME/generate_illustrations.py --zip $ZIP --gemini-key <KEY>
|
||||
A paid Google Gemini API key is needed: https://ai.google.dev
|
||||
FLAG
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
+83
-30
@@ -7,9 +7,9 @@ titles, and rewrites a few of the page's own apt.js tunables at capture time
|
||||
(cluster bias, count-to-size exponent, a rare-bird floor). The result is the
|
||||
actual website, framed for the wall, with no changes to AvianVisitors.
|
||||
|
||||
Needs a real headless browser, so it runs on any 64-bit capable machine, NOT
|
||||
the Pi Zero W driving the panel. Writes a 1200x1600 PNG; display.py turns it
|
||||
into panel pixels.
|
||||
Needs a real headless browser, so it runs on any 64-bit capable machine,
|
||||
including the frame's own Pi (3 A+ / Zero 2 W) but NOT an original ARMv6
|
||||
Pi Zero W. Writes a 1200x1600 PNG; display.py turns it into panel pixels.
|
||||
|
||||
pip install playwright && playwright install chromium
|
||||
python3 shoot.py --url https://bird.onethreenine.net \
|
||||
@@ -31,8 +31,10 @@ import urllib.parse
|
||||
from playwright.sync_api import TimeoutError as PWTimeout
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
# --bird-weather pulls cutouts straight from the repo's raw GitHub URLs, so the
|
||||
# Pi never bundles the illustration set and picks up new birds with no redeploy.
|
||||
# --bird-weather resolves cutouts from the local clone first, then falls back to
|
||||
# the repo's raw GitHub URLs: a fresh install needs no illustration redeploy,
|
||||
# upstream additions arrive with a git pull, and cutouts you generate and copy
|
||||
# into the clone render even before they reach GitHub.
|
||||
RAW_ILLUSTRATIONS = ("https://raw.githubusercontent.com/Twarner491/AvianVisitors/"
|
||||
"avian-visitors/avian/assets/illustrations/")
|
||||
|
||||
@@ -118,16 +120,22 @@ def _serve_frontend(directory):
|
||||
return httpd, httpd.server_address[1]
|
||||
|
||||
|
||||
def _make_cutout_handler(base):
|
||||
"""Redirect each cutout.php lookup to the bird's raw illustration on GitHub.
|
||||
Trusts species_for_zip to pre-filter to drawable slugs, so a redirect only
|
||||
lands on a missing file if the repo is mid-update."""
|
||||
def _make_cutout_handler(base, local_dir=None):
|
||||
"""Resolve each cutout.php lookup to the bird's illustration. Serve a local
|
||||
file first when `local_dir` has it - that is how cutouts you generate and copy
|
||||
into the clone render before they reach GitHub - otherwise 302 to the raw
|
||||
GitHub copy. Trusts species_for_zip to pre-filter to drawable slugs, so the
|
||||
GitHub fallback only lands on a missing file if the repo is mid-update."""
|
||||
def handler(route):
|
||||
try:
|
||||
params = urllib.parse.parse_qs(urllib.parse.urlparse(route.request.url).query)
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", (params.get("sci") or [""])[0].lower()).strip("-")
|
||||
if (params.get("pose") or ["1"])[0] == "2":
|
||||
slug += "-2"
|
||||
if local_dir:
|
||||
local = os.path.join(local_dir, slug + ".png")
|
||||
if os.path.isfile(local):
|
||||
return route.fulfill(path=local)
|
||||
route.fulfill(status=302, headers={"location": base + slug + ".png"})
|
||||
except Exception:
|
||||
_safe_continue(route)
|
||||
@@ -158,7 +166,8 @@ def shoot(url, out, *, title=None, subtitle=None, vw=600, vh=800, dsf=2,
|
||||
headline_px=42, eyebrow_px=18, lowercase=False,
|
||||
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):
|
||||
timeout_ms=45000, user=None, password=None, species=None, cutout_base=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
|
||||
|
||||
@@ -173,7 +182,7 @@ def shoot(url, out, *, title=None, subtitle=None, vw=600, vh=800, dsf=2,
|
||||
page.route("**/birdnet-api.php**", _make_api_handler(small_floor, window_hours, auth, species))
|
||||
page.route("**/apt.js*", _make_js_handler(cluster_xbias, cluster_ybias, count_exp, cluster_pad, auth, misses))
|
||||
if cutout_base:
|
||||
page.route("**/cutout.php*", _make_cutout_handler(cutout_base))
|
||||
page.route("**/cutout.php*", _make_cutout_handler(cutout_base, cutout_local))
|
||||
|
||||
css = HIDE_CSS + _frame_css(headline_px, eyebrow_px, lowercase, pad_top, pad_side, pad_bottom, collage_vh)
|
||||
page.add_init_script(
|
||||
@@ -197,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")
|
||||
|
||||
@@ -204,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})
|
||||
@@ -216,6 +236,32 @@ def shoot(url, out, *, title=None, subtitle=None, vw=600, vh=800, dsf=2,
|
||||
return out
|
||||
|
||||
|
||||
def shoot_birdweather(out, species, *, title=None, subtitle=None, timeout_ms=45000, **look):
|
||||
"""Render `species` ([{sci,com,n}]) as the BirdWeather collage into `out`.
|
||||
|
||||
The mic path screenshots a live site; this builds the same page from a
|
||||
species list instead. It serves the bundled frontend on localhost, feeds it
|
||||
the species, and routes cutouts to the local clone first then GitHub, so the
|
||||
--bird-weather CLI and display.py's inline render share one setup. An empty
|
||||
list renders the page's empty-state card, the same as the mic mode. `look`
|
||||
overrides any shoot() tunable (the CLI passes its flags through)."""
|
||||
if species is None:
|
||||
raise RuntimeError("shoot_birdweather needs a species list")
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
_httpd, port = _serve_frontend(os.path.join(here, "..", "avian", "frontend"))
|
||||
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.
|
||||
# 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",
|
||||
species=species, cutout_base=RAW_ILLUSTRATIONS, cutout_local=cutout_local,
|
||||
timeout_ms=timeout_ms, **look)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Screenshot the AvianVisitors collage for the e-ink frame.")
|
||||
ap.add_argument("--url", default="http://birdnet.local")
|
||||
@@ -248,7 +294,6 @@ def main():
|
||||
ap.add_argument("--password")
|
||||
ap.add_argument("--timeout", type=int, default=45000)
|
||||
a = ap.parse_args()
|
||||
url, title, subtitle, species, cutout_base = a.url, a.title, a.subtitle, None, None
|
||||
if a.bird_weather:
|
||||
if not a.zip:
|
||||
print("--bird-weather needs --zip", file=sys.stderr)
|
||||
@@ -259,25 +304,33 @@ def main():
|
||||
if not species:
|
||||
print(f"no drawable birds near {a.zip}; nothing to render", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
front = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "avian", "frontend")
|
||||
_httpd, port = _serve_frontend(front)
|
||||
url = f"http://127.0.0.1:{port}/"
|
||||
cutout_base = RAW_ILLUSTRATIONS
|
||||
title = title or "Avian Visitors"
|
||||
subtitle = subtitle or "Heard Today"
|
||||
# BirdWeather's 7-day counts are flatter than a mic's, so they need a steeper
|
||||
# exponent to get the same hero-bird hierarchy and collage shape.
|
||||
count_exp = a.count_exp if a.count_exp is not None else (1.0 if a.bird_weather else 0.4)
|
||||
headline_px = a.headline_px if a.headline_px is not None else (39 if a.bird_weather else 42)
|
||||
eyebrow_px = a.eyebrow_px if a.eyebrow_px is not None else (17 if a.bird_weather else 18)
|
||||
# Pass the CLI's look flags through; shoot_birdweather fills the bird-weather
|
||||
# defaults (steeper count exponent, smaller titles) for anything left unset.
|
||||
look = {k: v for k, v in (("count_exp", a.count_exp), ("headline_px", a.headline_px),
|
||||
("eyebrow_px", a.eyebrow_px)) if v is not None}
|
||||
look.update(vw=a.width, vh=a.height, dsf=a.dsf, mat=a.mat, collage_vh=a.collage_vh,
|
||||
cluster_xbias=a.cluster_xbias, cluster_ybias=a.cluster_ybias,
|
||||
cluster_pad=a.cluster_pad, small_floor=a.small_floor, lowercase=a.lowercase,
|
||||
window_hours=a.window_hours, user=a.user, password=a.password)
|
||||
try:
|
||||
shoot_birdweather(a.out, species, title=a.title, subtitle=a.subtitle,
|
||||
timeout_ms=a.timeout, **look)
|
||||
except Exception as e:
|
||||
print(f"shoot failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"wrote {a.out}")
|
||||
return
|
||||
# Mic path: screenshot the live AvianVisitors site at --url.
|
||||
count_exp = a.count_exp if a.count_exp is not None else 0.4
|
||||
headline_px = a.headline_px if a.headline_px is not None else 42
|
||||
eyebrow_px = a.eyebrow_px if a.eyebrow_px is not None else 18
|
||||
try:
|
||||
shoot(url, a.out, title=title, subtitle=subtitle, vw=a.width, vh=a.height, dsf=a.dsf,
|
||||
shoot(a.url, a.out, title=a.title, subtitle=a.subtitle, vw=a.width, vh=a.height, dsf=a.dsf,
|
||||
headline_px=headline_px, eyebrow_px=eyebrow_px, lowercase=a.lowercase,
|
||||
mat=a.mat, collage_vh=a.collage_vh, cluster_xbias=a.cluster_xbias,
|
||||
cluster_ybias=a.cluster_ybias, count_exp=count_exp, cluster_pad=a.cluster_pad,
|
||||
small_floor=a.small_floor,
|
||||
window_hours=a.window_hours, timeout_ms=a.timeout, user=a.user, password=a.password,
|
||||
species=species, cutout_base=cutout_base)
|
||||
window_hours=a.window_hours, timeout_ms=a.timeout, user=a.user, password=a.password)
|
||||
except Exception as e:
|
||||
print(f"shoot failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -12,4 +12,4 @@ WorkingDirectory=/home/monalisa/AvianVisitors/frame
|
||||
ExecStart=/home/monalisa/AvianVisitors/frame/.venv/bin/python /home/monalisa/AvianVisitors/frame/display.py --config /home/monalisa/.birdframe/config.toml
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Nice=10
|
||||
TimeoutStartSec=180
|
||||
TimeoutStartSec=300
|
||||
|
||||
Reference in New Issue
Block a user