Status: working · hardware-software · View on GitHub →
#wildlife #ai #computer-vision #nas #raspberry-pi
What It Does
Automated wildlife detection pipeline for security camera and camera trap footage. Videos are pulled from a network-attached storage (NAS), run through AI detection (MegaDetector) and species identification (SpeciesNet), and browsed through a local web dashboard. Footage with detections gets archived in an organised structure; blank footage is purged on a configurable schedule.
Supports dual-lens cameras (fixed wide + telephoto) with synchronised playback and automatic lens pairing.
Hardware
| Component | Notes |
|---|---|
| Linux machine (Ubuntu 20.04+) | Runs the detection pipeline and dashboard |
| Network-attached storage (NAS) | Accessible via NFS or SMB — stores raw and archived footage |
| Security cameras / camera traps | Any that write video to the NAS |
| GPU (optional) | NVIDIA CUDA 11/12 — ~10x faster than CPU |
How It Works
nas_sync.shpulls recent video from the NAS to local staging- MegaDetector V6 scans each frame for animals, people, and vehicles
- SpeciesNet identifies species (2,000+ species, 65M training images) with state/province-level geo-filtering to cut impossible IDs
- Each animal crop is scored for image quality (sharpness, brightness, contrast)
- Kept footage is archived back to the NAS under
camera/year/month/day/ - Blank footage is purged on a configurable retention schedule
- Web dashboard (FastAPI) lets you browse species, gallery crops, videos, and activity trends
Runs automatically at 6 AM daily via systemd. Dashboard starts on boot.
Installation
See GitHub repo for full instructions.
Quick start:
chmod +x setup.sh nas_connect.sh nas_sync.sh
./setup.sh
./nas_connect.sh # interactive NAS setup wizard
./nas_sync.sh --then-process --country US --admin1-region US-UT
Build Log
2026-08-16 — FIX-02: 14,354 stale file-path references repaired in production
Closed out a data-quality gap that had been sitting quietly in the database since before this project started tracking its own history: about 22% of crops.crop_path and videos.thumbnail_path references — 14,354 rows out of roughly 65,000 — pointed at a /home/nash/... directory prefix that hadn’t existed on disk in years. The box’s home directory was renamed to /home/twostar/ at some point during this project’s early life, and whatever did that rename never touched the database rows that stored full paths under the old name. Nobody had noticed, because nothing had gone looking. It surfaced by accident, mid-rehearsal, during Phase 9’s dedup backfill audit — a completely unrelated operation that happened to read every crop and thumbnail path on its way through. Confirmed unrelated to and unaffected by that backfill before either was allowed near production.
The actual fix is a one-line idea: replace a stale string prefix with the current one, on two columns, no row deletion, no schema change. That idea got four plans and its own phase anyway, because this project’s standing rule is that every production write earns a harness first, full stop — dry-run-first, snapshot-before-write, an operator go/no-go checkpoint, and post-write reconciliation against a baseline captured before the transaction opened. The cost of that discipline on a change this small was low: one script, a 37-case fixture harness, a rehearsal against a live copy of the data. The cost of skipping it once, on the day the “obvious” one-liner turns out not to be so obvious, is unbounded. That asymmetry is the whole argument.
And it did turn out not to be quite so obvious. The first-instinct implementation — a SQL UPDATE ... WHERE crop_path LIKE '/home/nash/%' doing a REPLACE on the matched rows — is subtly wrong. The LIKE clause controls which rows get touched, but SQL’s substring replace doesn’t know or care where in the string the substitution lands. A path containing /home/nash/ a second time later in its own value — plausible if a filename or a nested directory ever echoed the old username — would get corrupted in the tail, not just repaired at the head. The fix that avoids the failure mode entirely: compute the new value in Python as a leading-prefix slice, so only the first, known-position occurrence is ever touched, structurally. The fixture harness carries a dedicated case asserting exactly this — a path with the stale string appearing twice, confirmed rewritten only at the front.
The roadmap’s Success Criterion 3 — “a sample of previously-broken image URLs resolves correctly in the dashboard after migration” — assumed the stale paths meant broken images in the browser. Reading web_app.py‘s serving code before writing anything showed that assumption was shaky: the dashboard’s media routes reduce every stored path to its basename before serving the file, so the directory prefix never actually reached them. The pre-migration baseline measurement confirmed it empirically — all five sampled dashboard URLs (3 crops, 2 thumbnails) already returned HTTP 200 before the migration ran. The dashboard was never broken by this bug. The real damage was elsewhere: the full-path consumers, wildlife_processor.py and backfill_dedup_videos.py, which do use the complete stored path and would have failed to find any of these 14,354 files on disk. Reporting the criterion as “passed, but for a boring reason — no regression, not a visible repair” felt more honest than quietly checking the box.
The production write itself, once rehearsed, was exactly as uneventful as the plan wanted it to be: 14,354 rows rewritten (4,274 crops, 10,080 thumbnails) in a single invocation, no service stopped — this is a two-column string UPDATE, not a DELETE-based consolidation, so there was never a window where a row briefly didn’t exist. Post-write, every figure reconciled exactly against the pre-write baseline: row counts unchanged (17,298 crops, 48,464 videos), both non-path-column fingerprints byte-identical before and after, zero foreign-key violations, and all 14,354 rewritten paths individually confirmed to resolve against the live filesystem — not sampled, checked. A snapshot backup was taken before the transaction opened regardless, cheap insurance on a 94MB database, matching this project’s practice for every prior production write.
2026-08-10 — Historical dedup backfill: 19,289 duplicate video rows consolidated in production
Closed out the last and highest-risk phase of the v1.2 milestone: a one-time data-surgery script to clean up ~19,291 duplicate videos rows left behind by an old archive-collision bug (fixed at the source back in Phase 5, but the historical mess was explicitly deferred until now). Deliberately sequenced last — irreversible production writes only happen after everything else has stabilized.
The plan itself was built in layers on purpose: a read-only audit against real production data first (before any delete logic existed), then a tracer proving one full consolidation path end-to-end on a fixture, then widening that tracer to handle every real shape the audit found — operator corrections, empty winners, a hazard where crops silently migrate to the wrong sibling via INSERT OR REPLACE, dual-lens pairings. Two hazards surfaced just from reading the existing code before any of this ran: nearly every duplicate group (98.7%, it turned out) shares one identical thumbnail file with no uniquifier, so a naive delete would blank the surviving row’s gallery tile; and crops could end up orphaned on the wrong sibling entirely. Both got dedicated reference-guarded handling instead of being discovered the hard way in production.
The fixture harness — 53 cases across 10 suites, all synthetic — passed clean the whole way through. It proved the logic was right. It said nothing about whether the logic was fast enough, and that gap showed up hard during the full-scale rehearsal against a real production copy: a first dry-run attempt was killed after 33+ minutes of runaway CPU, root-caused to an N+1 query pattern invisible at fixture scale (six rows) but catastrophic at real scale (19,291 groups, ~64,000 rows). Fixed with a bulk-prefetch cache. Then the same shape of bug reappeared in the actual write path — an unindexed DELETE ... WHERE detection_id IN (subquery) — fixed by deleting on primary key instead. And then a third layer: even with the query-level fixes, SQLite’s own foreign-key enforcement was still doing full-table scans on every delete, because three FK columns (two on crops/species, one a self-referencing paired_video_id on videos) had never been indexed. That one required stepping outside the phase’s own files into the shared database.py schema — flagged for explicit sign-off rather than treated as a same-file auto-fix, given the blast radius. Three schema-level fixes later, the full 19,291-group pass went from a projected ~2.3 hours down to 42 seconds.
The rehearsal earned its keep: none of those three bugs were reachable from a fixture database small enough to write by hand. Real data volume is a different animal, and “the tests pass” turned out to be a necessary but insufficient claim for anything touching the live database.
The actual production run was almost anticlimactic by comparison — 41 seconds, services stopped for a few minutes total, every post-run check clean (zero FK violations, zero broken pairings, deltas reconciling exactly). 19,289 of 19,291 groups consolidated; the remaining 2 were left alone on purpose, reported by name rather than force-resolved, because both plausible automatic answers would have either destroyed a video’s only crops or duplicated its detections. Quarantine directory ended up empty — turns out almost nothing was actually safe to delete once the shared-thumbnail guard was accounted for, which says something about how deep that particular bug’s fingerprints ran through the data.
2026-05-07 — Project initialized and milestone planned
Started using a proper planning workflow for the first time on this project, which has been running and evolving organically for a while now. The codebase reached a point where it works well enough to trust the output but has enough rough edges that fixing one thing breaks another — the classic “it works on my machine, in this exact order” problem.
The planning pass surfaced six silent failure modes that have been lurking in the code. The most insidious: a NameError: log in database.py that crashes any startup against a database that needs a filepath migration. It never showed up during normal operation — only when something changed in the environment. Similar story with SETTINGS_FILE using a global path instead of honoring --data-dir, meaning settings saved in the dashboard silently went to the wrong place when the service was running with a custom data directory.
The dual-lens sync situation turned out to be three separate bugs stacked on each other: a SQL LIKE wildcard matching _ as any character (so camera_01 could pair with camera_X01), a JavaScript isSyncing guard that resets synchronously and immediately lets the next timeupdate event fire (feedback loop), and no database migration to repair the pairs that got wrongly matched by the SQL bug. All three need to be fixed in the right order.
Planning broke into four phases: fix the six blocking bugs first (Phase 1), then run monitoring + email alerts (Phase 2), scheduling UI + gallery UX improvements (Phase 3), and the dual-lens overhaul (Phase 4). Phases 2–4 can run in parallel once Phase 1 clears. The constraint on dependencies is worth noting: the dual-lens database fix must land before the JavaScript player rewrite, and Phase 1 must land before anything else, because until those six bugs are fixed the codebase isn’t safe to extend.
Key architectural constraint I locked in early: no new pip dependencies. The project already has a heavy ML stack (MegaDetector + SpeciesNet), and adding smtplib-style packages that are already in stdlib, or <dialog> elements that are now baseline browser-native, keeps the installation story clean. Everything new goes into Python stdlib or browser APIs.
Total scope: 26 requirements across the four phases.