Files
AvianVisitors/avian/api/cutout.php
T
Twarner491 d3241f92cf Pre-share fixes from final review pass
Blockers
- http://birdnet.local/avian/ now serves the collage (added avian/index.html
  redirect to frontend/; previously the symlink target had no top-level
  index, so users saw a directory listing of api/ assets/ forwarding/
  frontend/ scripts/).
- Gemini model bumped from gemini-2.5-flash-image-preview (deprecated
  2026-01-15) to the stable gemini-2.5-flash-image. Pregen was non-
  functional out of the box.
- avian-mqtt.service: dropped %h (which expands to /root for system-mode
  units regardless of User=). Use WorkingDirectory=~ which DOES respect
  User=, plus a relative ExecStart.
- birdnet-api.php case 'lifelist' now aliases COUNT(*) AS n (was 'total'),
  matching what apt.js reads for atlas card counts. Atlas previously
  always displayed 0.

Bugs
- Retry-After parsing handles HTTP-date format without crashing.
- apt.js no longer fetches firstseen/timeseries (declared in state but
  never consumed). Two wasted round-trips per page load removed.
- update_birdnet.sh defaults branch=avian-visitors so the (opt-in)
  Sunday auto-update cron doesn't reset HEAD to upstream main and
  delete the avian/ overlay.
- recording.php: removed dead $BIRDS_JSON_CANDIDATES.

Polish
- Pregen --sleep default raised 4→6s (more headroom under free-tier RPM).
- en-dash in README BOM (~$35–80) replaced with hyphen for style match.
- Slop sweep across user-facing docs: no em-dashes, no en-dashes, no
  AI-slop word hits (robust/comprehensive/seamless/etc.).
2026-05-28 11:11:20 -07:00

170 lines
5.8 KiB
PHP

<?php
// AvianVisitors - bird image resolver.
//
// Lookup chain for /avian/api/cutout.php?sci=Calypte+anna:
// 1. ../assets/illustrations/<slug>.png (450+ bundled kachō-e renders)
// 2. ../assets/cutouts/<slug>.png (background-removed photo)
// 3. cached rembg of a Wikipedia photo at $HOME/BirdSongs/Extracted/cutouts/
// 4. fresh Wikipedia → rembg → cache (skipped gracefully if rembg unset)
//
// The frontend's <img src> points here for every species - bundled
// hits return instantly; cold misses fall through to the dynamic path.
//
// Default LAN deploy ships without auth. To expose publicly, gate
// /avian/api/* with basic_auth in your Caddyfile - see avian/forwarding/.
declare(strict_types=1);
$sci = trim((string)($_GET['sci'] ?? ''));
if ($sci === '') {
http_response_code(400);
echo 'sci required';
exit;
}
// Binomial / trinomial pattern. Rejects path-traversal payloads and
// junk before any filesystem or upstream lookup.
if (!preg_match('/^[A-Za-z]{2,40}(?:[ ][a-z]{2,40}){1,3}$/', $sci)) {
http_response_code(400);
echo 'invalid sci';
exit;
}
// Slugify scientific name for filename + cache key.
$slug = preg_replace('/[^a-z0-9]+/', '-', strtolower($sci));
$slug = trim((string)$slug, '-');
function serve_png(string $path): void {
header('Content-Type: image/png');
header('Cache-Control: public, max-age=86400');
header('Content-Length: ' . (string)filesize($path));
readfile($path);
exit;
}
// 1. Bundled illustration (the kachō-e PNG the repo ships with).
$bundled = dirname(__DIR__) . "/assets/illustrations/$slug.png";
if (is_file($bundled) && filesize($bundled) > 1024) {
serve_png($bundled);
}
// 2. Bundled cutout (background-removed photo, fallback for species
// without an illustration).
$cutout = dirname(__DIR__) . "/assets/cutouts/$slug.png";
if (is_file($cutout) && filesize($cutout) > 1024) {
serve_png($cutout);
}
// 3. Dynamic cache from a previous Wikipedia + rembg run.
$cacheDir = getenv('HOME') . '/BirdSongs/Extracted/cutouts';
$cachePath = "$cacheDir/$slug.png";
if (is_file($cachePath) && filesize($cachePath) > 1024) {
serve_png($cachePath);
}
// 4. Fresh Wikipedia fetch + rembg. Skipped if rembg-cli isn't on
// PATH - the resolver simply returns a 404 in that case rather
// than burning a Wikipedia request we can't use.
$rembg = '/usr/local/bin/rembg-cli';
if (!is_executable($rembg)) {
http_response_code(404);
echo 'no illustration bundled for ' . htmlspecialchars($sci) . ' (install rembg-cli to enable Wikipedia fallback)';
exit;
}
if (!is_dir($cacheDir)) @mkdir($cacheDir, 0755, true);
// Wikipedia's REST API asks for a contact-able identifier. Override
// via the AV_USER_AGENT env var (set in /etc/php/*/fpm/pool.d/www.conf
// or your shell) if your install hammers their endpoint at scale.
$ua = getenv('AV_USER_AGENT') ?: 'AvianVisitors/1.0 (+https://github.com/Twarner491/AvianVisitors)';
$ctx = stream_context_create([
'http' => ['header' => "User-Agent: $ua\r\n", 'timeout' => 12],
]);
$wpUrl = 'https://en.wikipedia.org/api/rest_v1/page/summary/' . rawurlencode($sci);
$wpJson = @file_get_contents($wpUrl, false, $ctx);
$srcUrl = null;
if ($wpJson !== false) {
$j = json_decode($wpJson, true);
$srcUrl = $j['originalimage']['source'] ?? $j['thumbnail']['source'] ?? null;
}
// Defensive: only follow URLs on Wikimedia / Wikipedia hosts so a
// poisoned summary endpoint can't redirect us to arbitrary servers.
if ($srcUrl !== null) {
$host = parse_url((string)$srcUrl, PHP_URL_HOST) ?: '';
if (!preg_match('/(?:^|\.)(?:wikimedia\.org|wikipedia\.org)$/i', $host)) {
$srcUrl = null;
}
}
if (!$srcUrl) {
http_response_code(404);
echo 'no Wikipedia photo for ' . htmlspecialchars($sci);
exit;
}
$imgBytes = @file_get_contents($srcUrl, false, $ctx);
if (!$imgBytes || strlen($imgBytes) < 1024) {
http_response_code(503);
echo 'failed to fetch source image';
exit;
}
// rembg via the wrapper. u2netp = lightweight model (~50MB peak RAM -
// matters on the Pi 3B+). Temp files because rembg's CLI prefers
// real paths.
$tmpInBase = tempnam(sys_get_temp_dir(), 'rembg-in-');
$tmpOutBase = tempnam(sys_get_temp_dir(), 'rembg-out-');
@unlink($tmpInBase); @unlink($tmpOutBase);
$tmpIn = $tmpInBase . '.jpg';
$tmpOut = $tmpOutBase . '.png';
file_put_contents($tmpIn, $imgBytes);
$cmd = sprintf(
'%s i -m u2netp -ppm %s %s 2>&1',
escapeshellarg($rembg),
escapeshellarg($tmpIn),
escapeshellarg($tmpOut)
);
$out = shell_exec($cmd);
@unlink($tmpIn);
if (!is_file($tmpOut) || filesize($tmpOut) < 1024) {
@unlink($tmpOut);
http_response_code(500);
header('Content-Type: text/plain');
echo "rembg failed (see your Pi's logs for details)";
error_log("rembg failed for $sci: " . ($out ?? '(no output)'));
exit;
}
// Tight-crop to the bird's bounding box + downscale to 800px max edge
// so cache stays small.
$im = @imagecreatefrompng($tmpOut);
if ($im !== false) {
$cropped = @imagecropauto($im, IMG_CROP_TRANSPARENT);
if ($cropped !== false) {
imagedestroy($im);
$im = $cropped;
}
$w = imagesx($im); $h = imagesy($im);
$max = 800;
if ($w > $max || $h > $max) {
$scale = $max / max($w, $h);
$nw = (int)($w * $scale); $nh = (int)($h * $scale);
$resized = imagecreatetruecolor($nw, $nh);
imagealphablending($resized, false);
imagesavealpha($resized, true);
imagecopyresampled($resized, $im, 0, 0, 0, 0, $nw, $nh, $w, $h);
imagedestroy($im);
$im = $resized;
}
imagealphablending($im, false);
imagesavealpha($im, true);
imagepng($im, $tmpOut, 6);
imagedestroy($im);
}
// Atomic install: rename is atomic on the same filesystem, so any
// concurrent reader either sees the old cached file or the new one,
// never a half-written PNG.
@rename($tmpOut, $cachePath);
serve_png($cachePath);