[FEAT] frame: flag and generate the local birds the bundle is missing
This commit is contained in:
@@ -66,3 +66,11 @@ 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).
|
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. 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.
|
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.
|
||||||
|
|||||||
+57
-3
@@ -194,13 +194,67 @@ def species_for_zip(zip_code, country="us", target=10, days=7, radii=(15, 30, 50
|
|||||||
return found[:target]
|
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__":
|
if __name__ == "__main__":
|
||||||
import argparse
|
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("zip")
|
||||||
ap.add_argument("--country", default="us")
|
ap.add_argument("--country", default="us")
|
||||||
ap.add_argument("--target", type=int, default=10)
|
ap.add_argument("--target", type=int, default=10)
|
||||||
ap.add_argument("--days", type=int, default=7)
|
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()
|
a = ap.parse_args()
|
||||||
for s in species_for_zip(a.zip, a.country, a.target, a.days):
|
if a.missing:
|
||||||
print(f'{s["n"]:>8} {s["com"]} ({s["sci"]})')
|
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"]})')
|
||||||
|
|||||||
@@ -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())
|
||||||
+21
-3
@@ -201,10 +201,28 @@ DONE
|
|||||||
birdweather)
|
birdweather)
|
||||||
cat <<DONE
|
cat <<DONE
|
||||||
|
|
||||||
Installed in BirdWeather mode for ZIP $ZIP. The frame renders the top birds
|
Installed in BirdWeather mode for ZIP $ZIP. The frame renders the top birds near
|
||||||
near you on the Pi and refreshes every 6 hours. Cutouts come from the repo on
|
you on the Pi and refreshes every 6 hours.
|
||||||
GitHub, so add illustrations there for any local birds it is missing.
|
|
||||||
DONE
|
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
|
esac
|
||||||
|
|
||||||
|
|||||||
+22
-10
@@ -31,8 +31,10 @@ import urllib.parse
|
|||||||
from playwright.sync_api import TimeoutError as PWTimeout
|
from playwright.sync_api import TimeoutError as PWTimeout
|
||||||
from playwright.sync_api import sync_playwright
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
# --bird-weather pulls cutouts straight from the repo's raw GitHub URLs, so the
|
# --bird-weather resolves cutouts from the local clone first, then falls back to
|
||||||
# Pi never bundles the illustration set and picks up new birds with no redeploy.
|
# 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/"
|
RAW_ILLUSTRATIONS = ("https://raw.githubusercontent.com/Twarner491/AvianVisitors/"
|
||||||
"avian-visitors/avian/assets/illustrations/")
|
"avian-visitors/avian/assets/illustrations/")
|
||||||
|
|
||||||
@@ -118,16 +120,22 @@ def _serve_frontend(directory):
|
|||||||
return httpd, httpd.server_address[1]
|
return httpd, httpd.server_address[1]
|
||||||
|
|
||||||
|
|
||||||
def _make_cutout_handler(base):
|
def _make_cutout_handler(base, local_dir=None):
|
||||||
"""Redirect each cutout.php lookup to the bird's raw illustration on GitHub.
|
"""Resolve each cutout.php lookup to the bird's illustration. Serve a local
|
||||||
Trusts species_for_zip to pre-filter to drawable slugs, so a redirect only
|
file first when `local_dir` has it - that is how cutouts you generate and copy
|
||||||
lands on a missing file if the repo is mid-update."""
|
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):
|
def handler(route):
|
||||||
try:
|
try:
|
||||||
params = urllib.parse.parse_qs(urllib.parse.urlparse(route.request.url).query)
|
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("-")
|
slug = re.sub(r"[^a-z0-9]+", "-", (params.get("sci") or [""])[0].lower()).strip("-")
|
||||||
if (params.get("pose") or ["1"])[0] == "2":
|
if (params.get("pose") or ["1"])[0] == "2":
|
||||||
slug += "-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"})
|
route.fulfill(status=302, headers={"location": base + slug + ".png"})
|
||||||
except Exception:
|
except Exception:
|
||||||
_safe_continue(route)
|
_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,
|
headline_px=42, eyebrow_px=18, lowercase=False,
|
||||||
mat=0.04, collage_vh=52, cluster_xbias=1.0, cluster_ybias=1.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,
|
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):
|
||||||
pad_side, pad_top, pad_bottom = int(vw * mat), int(vh * mat * 0.92), int(vh * mat)
|
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
|
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("**/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))
|
page.route("**/apt.js*", _make_js_handler(cluster_xbias, cluster_ybias, count_exp, cluster_pad, auth, misses))
|
||||||
if cutout_base:
|
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)
|
css = HIDE_CSS + _frame_css(headline_px, eyebrow_px, lowercase, pad_top, pad_side, pad_bottom, collage_vh)
|
||||||
page.add_init_script(
|
page.add_init_script(
|
||||||
@@ -248,7 +257,7 @@ def main():
|
|||||||
ap.add_argument("--password")
|
ap.add_argument("--password")
|
||||||
ap.add_argument("--timeout", type=int, default=45000)
|
ap.add_argument("--timeout", type=int, default=45000)
|
||||||
a = ap.parse_args()
|
a = ap.parse_args()
|
||||||
url, title, subtitle, species, cutout_base = a.url, a.title, a.subtitle, None, None
|
url, title, subtitle, species, cutout_base, cutout_local = a.url, a.title, a.subtitle, None, None, None
|
||||||
if a.bird_weather:
|
if a.bird_weather:
|
||||||
if not a.zip:
|
if not a.zip:
|
||||||
print("--bird-weather needs --zip", file=sys.stderr)
|
print("--bird-weather needs --zip", file=sys.stderr)
|
||||||
@@ -263,6 +272,9 @@ def main():
|
|||||||
_httpd, port = _serve_frontend(front)
|
_httpd, port = _serve_frontend(front)
|
||||||
url = f"http://127.0.0.1:{port}/"
|
url = f"http://127.0.0.1:{port}/"
|
||||||
cutout_base = RAW_ILLUSTRATIONS
|
cutout_base = RAW_ILLUSTRATIONS
|
||||||
|
# Locally generated cutouts may not be on GitHub yet; serve the clone first.
|
||||||
|
cutout_local = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
"..", "avian", "assets", "illustrations")
|
||||||
title = title or "Avian Visitors"
|
title = title or "Avian Visitors"
|
||||||
subtitle = subtitle or "Heard Today"
|
subtitle = subtitle or "Heard Today"
|
||||||
# BirdWeather's 7-day counts are flatter than a mic's, so they need a steeper
|
# BirdWeather's 7-day counts are flatter than a mic's, so they need a steeper
|
||||||
@@ -277,7 +289,7 @@ def main():
|
|||||||
cluster_ybias=a.cluster_ybias, count_exp=count_exp, cluster_pad=a.cluster_pad,
|
cluster_ybias=a.cluster_ybias, count_exp=count_exp, cluster_pad=a.cluster_pad,
|
||||||
small_floor=a.small_floor,
|
small_floor=a.small_floor,
|
||||||
window_hours=a.window_hours, timeout_ms=a.timeout, user=a.user, password=a.password,
|
window_hours=a.window_hours, timeout_ms=a.timeout, user=a.user, password=a.password,
|
||||||
species=species, cutout_base=cutout_base)
|
species=species, cutout_base=cutout_base, cutout_local=cutout_local)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"shoot failed: {e}", file=sys.stderr)
|
print(f"shoot failed: {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user