Files

672 lines
29 KiB
Python
Executable File

#!/usr/bin/env python3
"""AvianVisitors - generate kachō-e bird illustrations for a region.
Step 1 of the illustration pipeline:
1. pregen.py render each bird on a uniform cream ground
2. cutout.py remove the ground (BiRefNet) and crop to the bird
3. build_masks.py refresh the collage silhouette masks in apt.js
Reads a species list (BirdNET-Pi's labels.txt, eBird, or stdin),
fetches a Wikipedia reference photo for each species, and generates an
illustration via the Gemini 2.5 Flash Image API. Saves PNGs into
avian/assets/illustrations/.
The prompt renders each bird on a CREAM ground, not a transparent one:
the model can't cut transparency cleanly, but a flat known ground removes
cleanly in step 2. Each species gets two poses: <slug>.png (perched) and
<slug>-2.png (flight). Edit avian/scripts/prompt.template.md to change the
visual style - the prompt body is re-sent verbatim per request with
{sci_name}, {com_name}, and {pose} substituted.
Reference photos:
Cached in avian/assets/references/. The auto-fetch hits the
Wikipedia article's first image. If a reference for the species
doesn't exist locally, pregen.py fetches one and caches it. To use
a hand-picked reference, drop it in references/ named <slug>.jpg
or <slug>.png BEFORE running and pregen.py will use that instead.
Contrastive anti-reference:
For genera where Gemini's prior collapses to a more famous
lookalike, the script attaches a photo of that lookalike as a
negative reference and rewrites the prompt body to tell the model
NOT to copy the lookalike's diagnostic features. Currently wired:
Blue Jay for small blue corvids (Cyanocitta, Aphelocoma, etc.) and
Barn Swallow for other swallows (Tachycineta, Progne, etc.). The
anti-reference photos live at avian/assets/references/_anti_*.jpg
and the registry (ANTI_REFS, ANTI_REF_TRIGGERS) is keyed so adding
a new one is one entry per table.
Usage:
# Every species BirdNET-Pi knows:
python3 pregen.py --labels ~/BirdNET-Pi/model/labels.txt
# Only species observed in an eBird region:
python3 pregen.py --labels ~/BirdNET-Pi/model/labels.txt \\
--ebird-region US-CA --ebird-key YOUR_KEY
# Re-render a single species (useful after editing the prompt):
python3 pregen.py --species "Calypte anna|Anna's Hummingbird" --force
# Re-render everything after a prompt change:
python3 pregen.py --labels ~/BirdNET-Pi/model/labels.txt --force
Set GEMINI_API_KEY in the environment (preferred) or pass --gemini-key.
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
# Gemini's image-out model. The endpoint changes occasionally; if you
# get a 404 here, check Google's model catalog and bump this.
GEMINI_URL = (
"https://generativelanguage.googleapis.com/v1beta/models/"
"gemini-2.5-flash-image:generateContent"
)
POSES = {1: "perched", 2: "in flight with wings spread"}
# Genera where Gemini's prior collapses to Blue Jay markings unless we
# attach a Blue Jay anti-reference. Add to this set if you find another
# blue-songbird genus that needs the contrastive nudge.
JAY_GENERA = {
"Cyanocitta", "Aphelocoma", "Cyanolyca", "Calocitta", "Cyanopica",
"Garrulus", "Cyanocorax", "Gymnorhinus",
}
# Genera where Gemini's prior collapses to Barn Swallow (rufous throat,
# deeply forked tail) unless we attach a Barn Swallow anti-reference.
# Hirundo rustica is itself the Barn Swallow so it's excluded.
SWALLOW_GENERA = {
"Tachycineta", "Riparia", "Progne", "Petrochelidon", "Stelgidopteryx",
}
# Genera where Gemini's prior collapses to American Robin (gray back,
# orange breast) for ground-foraging thrushes. Add as needed.
ROBIN_GENERA = set() # placeholder for future use
# Anti-reference catalogue. Each entry describes one lookalike species
# that Gemini collapses to: the common/scientific names go in IMAGE 2's
# caption and the prompt-body bullet, and `do_not_copy` is the list of
# its diagnostic features the model must avoid. The key matches the
# `_anti_<key>.jpg` filename in the references directory and feeds into
# `ANTI_REF_TRIGGERS` below.
# ---- Style references ----
# Edo-period kachō-e woodblock prints by Ohara Koson and Hiroshi Yoshida,
# kept in a local directory (default avian/assets/references/styles/). Mapped by
# genus + pose. The bird in each print is irrelevant - only the painting
# technique (flat washes, confident outlines, tonal ground) is borrowed.
STYLE_REFS = {
"small_songbird_perched": "01-sparrows-on-bamboo-Koson.jpg",
"dark_bird_perched": "02-cawing-crow-Koson.jpg",
"vivid_perched": "03-jays-on-berry-tree-Koson.jpg",
"vibrant_perched": "04-kingfisher-Koson.jpg",
"owl": "05-owl-on-ginkgo-Koson.jpg",
"large_flight": "06-goose-flying-in-moonlight-Koson.jpg",
"small_flight": "07-swallows-in-flight-Koson.jpg",
"wader": "08-crane-in-small-water-Koson.jpg",
"pale_perched": "09-cockatoo-Yoshida.jpg",
"waterfowl_perched": "10-mandarin-ducks-Yoshida.jpg",
}
# Genus → perched style category. The first match wins. Fallback is
# "small_songbird_perched" (Koson sparrows-on-bamboo) for every uncategorized
# genus - covers passer/melospiza/spizella/junco/etc.
GENUS_STYLE_PERCHED = {
# Owls
"Tyto":"owl","Bubo":"owl","Asio":"owl","Megascops":"owl","Athene":"owl",
"Strix":"owl","Glaucidium":"owl","Aegolius":"owl",
# Hummingbirds + jays + colorful crested (vibrant color anchor)
"Calypte":"vibrant_perched","Archilochus":"vibrant_perched",
"Selasphorus":"vibrant_perched","Calothorax":"vibrant_perched",
"Cyanocitta":"vibrant_perched","Aphelocoma":"vibrant_perched",
"Pica":"vibrant_perched","Nucifraga":"vibrant_perched",
"Perisoreus":"vibrant_perched",
# Waxwings + orioles + tanagers (vivid perching with berry-tree composition feel)
"Bombycilla":"vivid_perched","Icterus":"vivid_perched",
"Piranga":"vivid_perched","Pheucticus":"vivid_perched",
"Passerina":"vivid_perched","Cardellina":"vivid_perched",
"Setophaga":"vivid_perched","Icteria":"vivid_perched",
# Corvids + vultures (dark perching)
"Corvus":"dark_bird_perched","Coragyps":"dark_bird_perched",
"Cathartes":"dark_bird_perched","Gymnogyps":"dark_bird_perched",
# Waterfowl perched (mandarin-ducks anchor)
"Anas":"waterfowl_perched","Aix":"waterfowl_perched","Mareca":"waterfowl_perched",
"Spatula":"waterfowl_perched","Branta":"waterfowl_perched","Anser":"waterfowl_perched",
"Cygnus":"waterfowl_perched","Aythya":"waterfowl_perched",
"Bucephala":"waterfowl_perched","Lophodytes":"waterfowl_perched",
"Mergus":"waterfowl_perched","Oxyura":"waterfowl_perched",
"Podiceps":"waterfowl_perched","Podilymbus":"waterfowl_perched",
"Aechmophorus":"waterfowl_perched","Gavia":"waterfowl_perched",
"Pelecanus":"waterfowl_perched","Phalacrocorax":"waterfowl_perched",
"Urile":"waterfowl_perched",
# Waders + herons (crane-in-reeds anchor)
"Ardea":"wader","Egretta":"wader","Bubulcus":"wader","Butorides":"wader",
"Nycticorax":"wader","Plegadis":"wader","Limosa":"wader","Numenius":"wader",
"Himantopus":"wader","Recurvirostra":"wader","Charadrius":"wader",
"Actitis":"wader","Calidris":"wader","Tringa":"wader",
# Pale-bodied (gulls, terns, skimmer - cockatoo anchor)
"Larus":"pale_perched","Leucophaeus":"pale_perched","Sterna":"pale_perched",
"Thalasseus":"pale_perched","Hydroprogne":"pale_perched","Rynchops":"pale_perched",
}
# Genera that should use large_flight (instead of small_flight) for pose 2.
LARGE_FLIGHT_GENERA = {
"Tyto","Bubo","Asio","Megascops","Athene","Strix","Glaucidium","Aegolius",
"Anas","Aix","Mareca","Spatula","Branta","Anser","Cygnus","Aythya",
"Bucephala","Lophodytes","Mergus","Oxyura","Pelecanus","Phalacrocorax",
"Urile","Ardea","Egretta","Bubulcus","Butorides","Nycticorax","Plegadis",
"Limosa","Numenius","Himantopus","Recurvirostra",
"Buteo","Accipiter","Aquila","Circus","Falco","Cathartes","Coragyps",
"Haliaeetus","Pandion","Elanus","Gymnogyps","Corvus",
}
def select_style_ref(sci: str, pose: int) -> str:
"""Pick the style reference filename for a (sci, pose) pair."""
genus = sci.split()[0]
# Custom overrides for hard species
if sci == "Aeronautes saxatalis":
return STYLE_REFS["vibrant_perched"] # Koson kingfisher (vertical aerial-feeder posture, no swallow bias)
if pose == 2:
return STYLE_REFS["large_flight" if genus in LARGE_FLIGHT_GENERA else "small_flight"]
return STYLE_REFS[GENUS_STYLE_PERCHED.get(genus, "small_songbird_perched")]
ANTI_REFS = {
"bluejay": {
"common_name": "Blue Jay",
"sci_name": "Cyanocitta cristata",
"do_not_copy": (
"its facial mask, its white wingbars, its black necklace, "
"its crest pattern, or its white-tipped tail"
),
},
"barnswallow": {
"common_name": "Barn Swallow",
"sci_name": "Hirundo rustica",
"do_not_copy": (
"its deep rufous throat, its long deeply forked outer tail "
"streamers, or its blue-black back"
),
},
}
# Which anti-ref to attach for which genus, and the species to exclude
# (the lookalike itself - we don't want to tell a Barn Swallow generation
# not to look like a Barn Swallow). Order matters only if a genus appears
# in more than one set; first match wins.
ANTI_REF_TRIGGERS = (
(JAY_GENERA, "bluejay", "Cyanocitta cristata"),
(SWALLOW_GENERA, "barnswallow", "Hirundo rustica"),
)
USER_AGENT = "AvianVisitors/1.0 (https://github.com/Twarner491/AvianVisitors)"
def slugify(sci: str) -> str:
"""Match avian/frontend/apt.js slugify() exactly."""
return re.sub(r"[^a-z0-9]+", "-", sci.lower()).strip("-")
def parse_species_line(line: str) -> tuple[str, str] | None:
"""Accept any of: 'Sci|Com', 'Sci_Com', 'Sci,Com'. Skip blanks + #."""
line = line.strip()
if not line or line.startswith("#"):
return None
for sep in ("|", "_", ","):
if sep in line:
sci, com = line.split(sep, 1)
sci, com = sci.strip(), com.strip()
if sci and com:
return (sci, com)
return None
def parse_species_list(lines: list[str]) -> tuple[list[tuple[str, str]], int]:
"""Returns (parsed, skipped_count)."""
out, skipped = [], 0
for line in lines:
parsed = parse_species_line(line)
if parsed:
out.append(parsed)
elif line.strip() and not line.lstrip().startswith("#"):
skipped += 1
return out, skipped
def load_prompt(path: Path) -> str:
"""Return everything after the `## Prompt` heading, stripped to the
next `##` heading (so doc preamble or trailing sections don't bleed
into the API call)."""
text = path.read_text()
m = re.search(r"##\s*Prompt\s*\n(.+?)(?=\n##\s|\Z)", text, flags=re.DOTALL)
return (m.group(1) if m else text).strip()
def ebird_filter(species, region: str, key: str):
"""Intersect a label set with the eBird species list for a region.
Region codes: US-CA (state), US-CA-085 (county)."""
url = f"https://api.ebird.org/v2/product/spplist/{region}"
req = urllib.request.Request(url, headers={"X-eBirdApiToken": key})
with urllib.request.urlopen(req, timeout=30) as r:
ebird_codes = set(json.loads(r.read()))
tax_url = "https://api.ebird.org/v2/ref/taxonomy/ebird?fmt=json"
req2 = urllib.request.Request(tax_url, headers={"X-eBirdApiToken": key})
with urllib.request.urlopen(req2, timeout=60) as r:
taxonomy = json.loads(r.read())
code_to_sci = {t["speciesCode"]: t["sciName"] for t in taxonomy}
allowed = {code_to_sci[c] for c in ebird_codes if c in code_to_sci}
return [(s, c) for s, c in species if s in allowed]
# ---- Reference photo handling ----
# Extensions to scan/write for cached reference photos. .jpg first matches
# the JPEG majority of Wikipedia infoboxes and any legacy hand-placed
# plates; .png covers PNG infoboxes (range maps, some illustrations) and
# hand-placed PNG plates. Each extension here must have a matching MIME
# in _mime_for - keep the two in sync.
REF_EXTS = (".jpg", ".png")
def fetch_wikipedia_thumb(sci: str, com: str) -> tuple[bytes, str] | None:
"""Fetch the Wikipedia article's lead/infobox image bytes.
Returns (bytes, ext) where ext is '.jpg' or '.png' sniffed from the
magic bytes - Wikipedia's infobox image can be either, and shipping
PNG bytes labeled as JPEG to Gemini gets the reference silently
rejected. Returns None if no usable image. Pi-friendly: pulls a
1024-wide thumbnail via the REST summary endpoint (a few KB to MB,
not the original-sized image).
"""
titles = [sci.replace(" ", "_"), com.replace(" ", "_"), com.split()[0]]
for title in titles:
url = (
"https://en.wikipedia.org/api/rest_v1/page/summary/"
+ urllib.parse.quote(title)
)
try:
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=20) as r:
meta = json.loads(r.read())
except (urllib.error.HTTPError, urllib.error.URLError):
continue
# Prefer originalimage (higher res) over thumbnail.
for k in ("originalimage", "thumbnail"):
src = (meta.get(k) or {}).get("source")
if not src or not src.lower().endswith((".jpg", ".jpeg", ".png")):
continue
try:
req2 = urllib.request.Request(src, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req2, timeout=30) as r:
data = r.read()
except (urllib.error.HTTPError, urllib.error.URLError):
continue
# Magic-byte sniff - URL extension is a hint, the bytes are
# what Gemini's MIME check sees. Skip unknown formats rather
# than mis-label them.
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return data, ".png"
if data.startswith(b"\xff\xd8\xff"):
return data, ".jpg"
return None
def ensure_reference(refs_dir: Path, slug: str, sci: str, com: str) -> Path | None:
"""Cache-or-fetch a reference photo. Returns the path if we have one,
None if Wikipedia had no usable image. Pre-existing references (e.g.
hand-picked Audubon plates dropped in by the user as either
<slug>.jpg or <slug>.png) are respected; the file is saved with the
extension that matches its actual format so _mime_for ships the
right MIME to Gemini."""
refs_dir.mkdir(parents=True, exist_ok=True)
for ext in REF_EXTS:
cached = refs_dir / f"{slug}{ext}"
if cached.exists() and cached.stat().st_size > 1024:
return cached
fetched = fetch_wikipedia_thumb(sci, com)
if not fetched:
return None
data, ext = fetched
path = refs_dir / f"{slug}{ext}"
path.write_bytes(data)
return path
def select_anti_ref_key(sci: str) -> str | None:
"""Return the ANTI_REFS key for the lookalike that Gemini drifts
toward for this species, or None if no anti-ref is needed. The key
matches `_anti_<key>.jpg` in the references directory."""
genus = sci.split()[0]
for genera, key, exclude in ANTI_REF_TRIGGERS:
if genus in genera and sci != exclude:
return key
return None
def load_species_notes(notes_path: Path) -> dict[str, str]:
"""Load per-species prompt addenda. Keys are scientific names; values
are 1-2 sentence clarifications to inject when generating that
species. Returns {} if the notes file doesn't exist."""
if not notes_path.exists():
return {}
raw = json.loads(notes_path.read_text())
return {k: v for k, v in raw.items()
if not k.startswith("_") and isinstance(v, str)}
def load_anti_ref(refs_dir: Path, key: str = "bluejay") -> Path | None:
"""Return path to the bundled anti-reference for the given key,
if present. Known keys: bluejay, barnswallow."""
p = refs_dir / f"_anti_{key}.jpg"
return p if p.exists() else None
# ---- Gemini call ----
def _anti_ref_line(anti_ref_key: str | None) -> str:
"""Render the `{anti_ref_line}` substitution for the prompt body.
Returns the IMAGE 2 bullet describing which species is attached and
which of its features the model must avoid - or an empty string
when no anti-ref is attached for this species."""
info = ANTI_REFS.get(anti_ref_key or "")
if not info:
return ""
return (
f"- IMAGE 2 (negative, when attached) is a {info['common_name']} "
f"({info['sci_name']}). It is NOT what you are drawing. Do NOT "
f"copy {info['do_not_copy']}. If your output looks more like "
f"IMAGE 2 than IMAGE 1, the output is wrong."
)
def gen_one(
api_key: str,
prompt: str,
sci: str,
com: str,
pose: int,
positive_ref: Path | None = None,
anti_ref: Path | None = None,
anti_ref_key: str | None = None,
species_note: str | None = None,
style_ref: Path | None = None,
) -> bytes:
"""Single Gemini call with bounded retry on 429 + transient 5xx.
Returns raw PNG bytes.
positive_ref: Wikipedia/Audubon photo of the target species.
anti_ref: lookalike photo to attach as IMAGE 2. The companion
anti_ref_key (a key into ANTI_REFS) must match what's in
the file - it drives the IMAGE 2 caption and the
{anti_ref_line} substitution in the prompt body. Pass
both or neither; passing the path without the key would
caption the image as an unnamed "another species".
species_note: optional 1-2 sentence clarifier for difficult species,
appended as the last paragraph before the reference
block.
"""
body = (prompt
.replace("{sci_name}", sci)
.replace("{com_name}", com)
.replace("{pose}", POSES[pose])
.replace("{anti_ref_line}", _anti_ref_line(anti_ref_key)))
if species_note:
body = body + "\n\nSpecies-specific note: " + species_note
parts: list[dict] = [{"text": body}]
if positive_ref:
# Downscale the anatomy reference to 384px on the long side
# before encoding. Big Wikipedia photos visually dominate as a
# style signal even though the prompt says they're anatomy-only;
# at 384px the model still reads species/markings/colors but
# has less photographic detail to mimic.
try:
from PIL import Image
from io import BytesIO
img = Image.open(positive_ref).convert("RGB")
w, h = img.size
if max(w, h) > 384:
scale = 384 / max(w, h)
img = img.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
buf = BytesIO()
img.save(buf, format="PNG", optimize=True)
ref_bytes = buf.getvalue()
ref_mime = "image/png"
except Exception:
ref_bytes = positive_ref.read_bytes()
ref_mime = _mime_for(positive_ref)
parts.append({"text": "IMAGE 1 (positive, target species):"})
parts.append({"inline_data": {
"mime_type": ref_mime,
"data": base64.b64encode(ref_bytes).decode(),
}})
if anti_ref:
anti_name = (ANTI_REFS.get(anti_ref_key or "") or {}).get(
"common_name", "lookalike species"
)
parts.append({"text": f"IMAGE 2 (negative, {anti_name}, do NOT copy):"})
parts.append({"inline_data": {
"mime_type": _mime_for(anti_ref),
"data": base64.b64encode(anti_ref.read_bytes()).decode(),
}})
if style_ref:
parts.append({"text": (
"IMAGE 3 (positive STYLE reference - Edo-period kachō-e woodblock "
"print). The species in IMAGE 3 is irrelevant; only its painting "
"technique is borrowed (flat washes, confident outlines, tonal "
"mineral-pigment ground). DO NOT copy any branches, leaves, water, "
"moon, or scenery from IMAGE 3.")})
parts.append({"inline_data": {
"mime_type": _mime_for(style_ref),
"data": base64.b64encode(style_ref.read_bytes()).decode(),
}})
payload = {
"contents": [{"parts": parts}],
# TEXT included so Gemini can surface safety messaging without
# rejecting the request shape (image-only sometimes errors).
"generationConfig": {"responseModalities": ["TEXT", "IMAGE"]},
}
# API key as header, NOT URL - keeps the key out of Google's
# request logs, proxy logs, and shell history.
req = urllib.request.Request(
GEMINI_URL,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", "x-goog-api-key": api_key},
method="POST",
)
backoff = 4.0
for attempt in range(4):
try:
with urllib.request.urlopen(req, timeout=180) as r:
resp = json.loads(r.read())
break
except urllib.error.HTTPError as e:
if e.code in (429, 500, 502, 503, 504) and attempt < 3:
ra = e.headers.get("Retry-After")
try:
retry_after = float(ra) if ra else backoff
except (TypeError, ValueError):
retry_after = backoff # HTTP-date format, fall back
time.sleep(retry_after)
backoff *= 2
continue
raise
except urllib.error.URLError:
if attempt < 3:
time.sleep(backoff)
backoff *= 2
continue
raise
for cand in resp.get("candidates", []):
for part in cand.get("content", {}).get("parts", []):
inline = part.get("inlineData") or part.get("inline_data")
if inline and inline.get("data"):
return base64.b64decode(inline["data"])
# No image - surface the blocking reason so users know what to fix.
finish = (resp.get("candidates", [{}])[0]).get("finishReason", "?")
block = resp.get("promptFeedback", {}).get("blockReason", "")
raise RuntimeError(f"no image (finish={finish} block={block})")
def _mime_for(p: Path) -> str:
ext = p.suffix.lower()
if ext in (".jpg", ".jpeg"):
return "image/jpeg"
if ext == ".png":
return "image/png"
if ext == ".webp":
return "image/webp"
return "application/octet-stream"
def main() -> int:
ap = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
src = ap.add_mutually_exclusive_group(required=True)
src.add_argument("--labels", type=Path, help="Path to BirdNET-Pi labels.txt (or any file of Sci|Com lines)")
src.add_argument("--species", action="append", default=[],
help="Manual 'Sci|Com' (repeatable)")
src.add_argument("--stdin", action="store_true", help="Read Sci|Com lines from stdin")
ap.add_argument("--ebird-region", help="eBird region code (e.g. US-CA, US-CA-085) to filter labels")
ap.add_argument("--ebird-key", help="eBird API key (or EBIRD_API_KEY env)")
ap.add_argument("--gemini-key", help="Gemini API key (or GEMINI_API_KEY env)")
ap.add_argument("--out", type=Path,
default=Path(__file__).resolve().parents[1] / "assets" / "illustrations",
help="Output directory (default: avian/assets/illustrations/)")
ap.add_argument("--refs", type=Path,
default=Path(__file__).resolve().parents[1] / "assets" / "references",
help="Reference photo cache directory (default: avian/assets/references/)")
ap.add_argument("--styles", type=Path,
default=Path(__file__).resolve().parents[1] / "assets" / "references" / "styles",
help="Style reference directory (default: avian/assets/references/styles/)")
ap.add_argument("--prompt", type=Path,
default=Path(__file__).resolve().parent / "prompt.template.md",
help="Prompt template path")
ap.add_argument("--notes", type=Path,
default=Path(__file__).resolve().parent / "species-notes.json",
help="Per-species prompt addenda for difficult cases (e.g. similar-species drift)")
ap.add_argument("--poses", nargs="+", type=int, default=[1, 2],
choices=list(POSES.keys()),
help="Which poses to render. 1=perched, 2=flight. Default: both.")
ap.add_argument("--force", action="store_true", help="Re-render even if file exists")
ap.add_argument("--no-refs", action="store_true",
help="Skip the Wikipedia reference fetch (faster, lower-quality output)")
ap.add_argument("--sleep", type=float, default=6.0,
help="Seconds between API calls (default 6 = headroom under free-tier RPM cap)")
ap.add_argument("--limit", type=int, default=0, help="Cap species count for testing")
args = ap.parse_args()
gemini_key = args.gemini_key or os.environ.get("GEMINI_API_KEY", "")
if not gemini_key:
print("error: GEMINI_API_KEY required (--gemini-key or env)", file=sys.stderr)
return 2
# Build species list
if args.labels:
species, skipped = parse_species_list(args.labels.read_text().splitlines())
elif args.stdin:
species, skipped = parse_species_list(sys.stdin.read().splitlines())
else:
species, skipped = parse_species_list(args.species)
if skipped:
print(f"[parse] skipped {skipped} malformed line(s)", file=sys.stderr)
if not species:
print("error: no species resolved", file=sys.stderr)
return 2
if args.ebird_region:
ek = args.ebird_key or os.environ.get("EBIRD_API_KEY", "")
if not ek:
print("error: --ebird-region requires --ebird-key or EBIRD_API_KEY", file=sys.stderr)
return 2
print(f"[ebird] filtering {len(species)} species against {args.ebird_region}...")
species = ebird_filter(species, args.ebird_region, ek)
if args.limit:
species = species[:args.limit]
prompt = load_prompt(args.prompt)
args.out.mkdir(parents=True, exist_ok=True)
anti_paths: dict[str, Path] = {}
if not args.no_refs:
for key in ANTI_REFS:
p = load_anti_ref(args.refs, key)
if p:
anti_paths[key] = p
notes = load_species_notes(args.notes)
if notes:
print(f"[notes] loaded per-species addenda for {len(notes)} species")
total = len(species) * len(args.poses)
print(f"generating up to {total} illustrations into {args.out}/")
for key, p in anti_paths.items():
print(f"[refs] {ANTI_REFS[key]['common_name']} anti-reference: {p.name}")
done = skipped_existing = failed = 0
first_fail = None
for idx, (sci, com) in enumerate(species):
slug = slugify(sci)
pos_ref = None
if not args.no_refs:
pos_ref = ensure_reference(args.refs, slug, sci, com)
if not pos_ref:
print(f" [warn] no Wikipedia photo for {sci} - proceeding without positive ref", file=sys.stderr)
anti_key = select_anti_ref_key(sci)
anti = anti_paths.get(anti_key) if anti_key else None
# Pair the key with the path so gen_one captions IMAGE 2 with the
# right species (the bug this fixes: stale Blue-Jay caption on
# an attached Barn-Swallow anti-ref).
anti_key_for_call = anti_key if anti else None
for pose in args.poses:
fname = f"{slug}.png" if pose == 1 else f"{slug}-{pose}.png"
path = args.out / fname
if path.exists() and not args.force:
skipped_existing += 1
continue
try:
style_ref_path = args.styles / select_style_ref(sci, pose)
if not style_ref_path.exists():
style_ref_path = None
data = gen_one(gemini_key, prompt, sci, com, pose,
positive_ref=pos_ref, anti_ref=anti,
anti_ref_key=anti_key_for_call,
species_note=notes.get(sci),
style_ref=style_ref_path)
path.write_bytes(data)
done += 1
refs_tag = "+ref" if pos_ref else ""
anti_tag = "+anti" if anti else ""
note_tag = "+note" if notes.get(sci) else ""
print(f" [ok] {fname} ({len(data)//1024} KB){refs_tag}{anti_tag}{note_tag}")
except (urllib.error.HTTPError, urllib.error.URLError, RuntimeError) as e:
failed += 1
first_fail = first_fail or fname
print(f" [fail] {fname}: {e}", file=sys.stderr)
# Don't sleep after the last species' last pose.
if not (idx == len(species) - 1 and pose == args.poses[-1]):
time.sleep(args.sleep)
print(f"\ngenerated {done} · skipped {skipped_existing} · failed {failed}")
if first_fail:
print(f"first failure: {first_fail} (re-run without --force to retry only the misses)", file=sys.stderr)
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())