Rendered from a pipeline run on real footage, not screen-recorded: a hummingbird flies in to a feeder, perches and flies off (Pixabay video by ZacharyCrespin, Pixabay Content License; source in data/samples/real/CREDITS.md). The input is on the left, the preview's overlays on the right. The pipeline ran at real-time pace (the clip's 30 fps, as from a camera) on a laptop CPU, with the default config (run_id 720a5997). The perch plays at 2x.
There is a bird that lives somewhere near my apartment and has been using my car as a toilet for months, always at a time of day I never managed to catch, and I got curious enough about its schedule that I wanted to know exactly when it shows up so I could be sitting in the driver's seat waiting for it one morning and give it the fright of its life. So I pointed my DJI Action 4 at the car as the sensor, wrote a pipeline that watches for motion and asks a small bird detector whether the moving thing is a bird, and started logging every visit with a timestamp and a snapshot. The bird has not been caught yet, but the log is getting longer.
CatchThatBird keeps a passive log of birds visiting a parked car, seen through a webcam (a DJI Osmo Pocket 3 in webcam mode, in the author's setup). It writes one line per visit to data/events.jsonl, plus a snapshot of each bird, so visit times can be analysed later. It sends no alerts and records no video. Detection is two-stage so that a laptop CPU is enough. Cheap background subtraction (MOG2) watches a region around the car on every frame, and the YOLOv8n neural network runs only on small crops around motion and around birds already being tracked, about once a second.
This runs the whole pipeline on a generated video, with no camera. You need Python 3.12 or newer and git. Commands are the same in PowerShell and bash, except where two versions are shown.
1. Clone
git clone https://github.com/SamJ12138/CatchThatBird.git
cd CatchThatBird
2. Create and activate a virtual environment
PowerShell (Windows):
python -m venv .venv
.\.venv\Scripts\Activate.ps1
bash (Linux, macOS):
python3 -m venv .venv
source .venv/bin/activate
The prompt now starts with (.venv). From here on, python means the environment's Python in both shells.
- PowerShell refuses to run
Activate.ps1("running scripts is disabled on this system", the default on a new Windows install): runSet-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass, then.\.venv\Scripts\Activate.ps1again. The policy change lasts only for the current window. - Git Bash on Windows: the activate command is
source .venv/Scripts/activate.
3. Install the dependencies
python -m pip install -r requirements.txt
This installs the CPU build of PyTorch with Ultralytics, OpenCV and the smaller packages. requirements.txt explains how to use an NVIDIA GPU instead. Minimal Linux images may also need OpenCV's system libraries: sudo apt-get install libgl1 libglib2.0-0.
What you will see: pip downloads and installs about 50 packages and ends with Successfully installed .... pip may also suggest upgrading itself; that is optional. On two test runs from a fresh clone (Windows, Python 3.14, empty pip cache) this step took 75 s and 86 s, and the PyTorch wheel was torch-2.12.0+cpu, 125 MB.
4. Generate a test video
python scripts/make_synth_video.py
What you will see: after a few seconds, one line (paths shortened here):
Wrote ...\data\samples\synth_bird.mp4 (600 frames, 1280x720@30); a house sparrow photo lands, perches and leaves inside ROI x=473 y=284 w=417 h=314 (scaled from roi.example.json)
The clip is synthetic: 20 s of a plain grey background with sensor-like noise. A real photo of a house sparrow is composited into it (public domain, U.S. Fish and Wildlife Service; source and license in data/samples/assets/CREDITS.md). The bird flies into the region of interest after 3 s, perches for about 13 s and flies off. --no-bird makes the older clip instead, a dark blob that YOLO never calls a bird.
Rendered from a pipeline run, not screen-recorded: the quickstart's synthetic clip (a noisy grey background with a public-domain house sparrow photo composited in) on the left, the preview's overlays on the right. The perch plays at 2x.
5. Run the pipeline on it
python main.py --source data/samples/synth_bird.mp4 --headless --no-pace --yes
--source reads a file instead of the camera. --headless opens no windows. --no-pace processes every frame as fast as possible instead of at the video's 30 fps. --yes skips the camera checklist.
What you will see: a few seconds of log lines, then the prompt. The exit code is 0. Abridged:
INFO | Run 10b9b584: structured log -> ...\logs\run_10b9b584.jsonl
WARNING | YOLO device=cpu -- torch was not built with CUDA. It will still work; ...
INFO | Loading YOLO model 'yolov8n.pt' (auto-downloads on first run)
Downloading https://github.com/ultralytics/assets/releases/download/v8.4.0/yolov8n.pt to 'yolov8n.pt': 100% 6.2MB
INFO | Opened video file data/samples/synth_bird.mp4: 1280x720 @ 30.0fps, 600 frames
WARNING | No roi.json yet: using the example ROI in roi.example.json, which was drawn for another camera. ...
WARNING | Saved ROI was for 1920x1080, current frame is 1280x720 (same aspect ratio): rescaled to x=473 y=284 w=417 h=314
INFO | MOG2 warmup complete (60 frames). Detection pipeline active.
INFO | BIRD detected (conf=0.92, bbox=583,457,103,67)
INFO | BIRD detected (conf=0.91, bbox=583,457,103,68)
... (14 "BIRD detected" lines, one per second while the bird perches)
INFO | End of video file after 600 frames
INFO | FrameGrabber stopped (captured=600, failures=0)
INFO | Bird visit logged: 2026-10-04T01:28:33.933-04:00 seq=121 frames=14 conf=0.9216
data/events.jsonl now holds one visit. This is the line from that run: the real YOLOv8n on CPU, the synthetic clip with the real photo:
{"ts": "2026-10-04T01:28:33.933-04:00", "run_id": "10b9b584", "frame_seq": 121, "class": "bird", "confidence": 0.9216, "bbox_xywh": [583, 457, 103, 67], "snapshot_crop": "snapshots/20261004T012833.933_seq000121_d0_crop.jpg", "snapshot_full": "snapshots/20261004T012833.933_seq000121_d0_full.jpg", "last_seen": "2026-10-04T01:28:46.933-04:00", "visit_frames": 14, "visit_id": "10b9b584-1", "truncated": false, "recovered": false, "concurrent_max": 1}
The crop snapshot it points to (data/snapshots/..._crop.jpg) is the padded motion crop that YOLO classified when the visit opened:
The quickstart's crop snapshot. The bird is a real photograph; the grey background around it is synthetic.
- First-run output. The first run downloads the YOLOv8n weights (6.2 MB) to
yolov8n.ptin the repository root. Ultralytics may also print a one-time notice about its settings file. - Expected warnings. The CPU warning is expected with the CPU build. The two ROI warnings come from the example region of interest, which was drawn on a 1920x1080 camera and is rescaled here.
- Numbers vary. Your run id, timestamps and confidences will differ.
- One visit covers the whole perch. The bird lands just before 4 s into the clip and leaves at about 17 s. It was confirmed on 14 gated frames (
visit_frames), andlast_seenis 13 s afterts. The bird sits still, so the motion detector stops seeing it after landing. The visit stays open because YOLO re-checks the visit's last box once a second (see How it works). - Timestamps follow the clip. For a video file,
tsandlast_seenare the time the run started reading plus the position in the clip.--no-pacetherefore keeps the clip's timing, although the run takes only a few seconds.
6. Read the run log
python scripts/failure_report.py --latest
What you will see: a summary of the run you just made (abridged):
Runs: 1 Lines: 174 Unparseable lines: 0
10b9b584: 2026-10-04T05:28:27.541+00:00 .. 2026-10-04T05:28:34.182+00:00 run success, exit_code=0, 6.6s
== Errors: stage x error_type (fail/skip lines carrying an error_type) ==
stage input_invalid external_api parse timeout hardware unknown total
roi_load 1 . . . . . 1
== Skips: stage x reason ==
gate_check cadence 522
gate_check warmup 60
morph_contour no_contours 17
detection_map yolo_empty 2
persist dedupe 13
render headless 600
== Stages: totals and duration_ms ==
stage success fail skip p50_ms p95_ms
detector_init 1 0 0 2362.20 2362.20
morph_contour 1 0 17 0.41 0.65
yolo_infer 16 0 0 56.96 89.08
persist 1 0 13 8.43 8.43
mog2_apply * 600 0 0 ~1.90 ~3.87
How to read it:
- The one
roi_load/input_invalidline is the example ROI's resolution not matching the clip. The ROI was rescaled, as the warning said. - After the 60-frame warm-up, the motion gate ran on 18 frames (522 frames were skipped for cadence).
- Only the landing frame had motion. On the other 17 there was none (
no_contours). - YOLO still ran 16 times:
- once on the motion crop at the landing;
- once a second on the open visit's last box, confirming the bird 13 more times (
persist/dedupe: merged into the visit); - twice more after the bird had flown off (
yolo_empty).
persistis the visit being written when the run ended.docs/architecture.mdexplains these costs.
7. Your own camera
To watch a real scene, go to Running with a real camera below. Stop that run with q, Esc or Ctrl-C, then read data/events.jsonl and python scripts/failure_report.py --latest as above.
The pipeline was built for a DJI Osmo Pocket 3 connected over USB-C in webcam (UVC) mode, pointed at the car.
-
Find the camera's device index. Windows lists devices by name. On other systems the entries are
<device N>, and you identify the camera by elimination.python main.py --list-devices -
Start with that device. Or set
camera.device_indexinconfig.yamlinstead of passing--device.python main.py --device 1Before the camera opens, a checklist is printed and the program waits for Enter:
- gimbal mode set to Lock
- ActiveTrack off
- manual focus, on the car
- 4x digital zoom (about 80 mm equivalent)
- USB-C connected in Webcam mode
- no other application using the camera
- the selected index is the Pocket 3
--yesskips the checklist for unattended runs. -
Draw the region to watch. On the first run, a window opens on the first frame: drag a rectangle around the car, then press Enter or Space.
Cmeans "use the whole frame". The rectangle is saved todata/roi.json(not tracked by git) and reused. If only the resolution changed, it is rescaled. To redraw it:python main.py --select-roiA headless run with no
data/roi.jsonusesdata/roi.example.jsonand prints a warning. That example was drawn for the author's camera. -
Watch the preview. It shows the ROI, detections and a three-line status display.
qorEscquits, andssaves the current frame todata/snapshots/manual_*.jpg. Ctrl-C, SIGTERM (Linux/macOS) and Ctrl-Break (Windows) also stop the run cleanly, and any visit still in progress is written first.
Other UVC webcams are untested but should work. Pass their index with --device, and ignore the Pocket-specific checklist items.
If the camera stops delivering frames for 2 s, the capture is closed and reopened, waiting 1, 2, 4, 8, 16 and then 30 s between attempts.
The design, the threading model and the measured costs are in docs/architecture.md. Every stage and its failure modes are listed in docs/pipeline-stages.md. One frame's path:
camera (UVC, MJPG) or --source file
|
v
grabber thread: cap.read() -> Frame(image, seq, capture time) -> latest-frame slot
|
v main thread takes the newest frame
MOG2 background subtraction on the ROI ................ every frame
|
| warm-up done, and the first or every Nth frame after it? no -> next frame
v yes (about once a second)
morphology, contours >= motion_min_area? no -> next frame
| yes: padded, overlapping ones merged, the 4 largest regions
v
for each open visit: padded crop around its last box -> YOLOv8n
(every gated frame, motion or not, so a bird that sits still stays confirmed)
+ one padded crop per motion region -> YOLOv8n, target classes only
(at most 5 such calls a second; the rest wait a few frames, logged)
+ if YOLO finds nothing in a motion region: that crop again on every third
of the next 30 frames, kept out of the background model meanwhile, so a bird
that was blurred in flight is found as it lands, before it becomes background
|
v
EventLogger: the frame's birds matched to the open visits, one to one, at the lowest
cost (overlap and distance); a pair may match if IoU >= 0.3 or the centre is within
2x the visit's box size, within 10 s
| matched -> extend that visit unmatched -> open a visit, save snapshots
v
data/events.jsonl: one line per visit, written when the visit closes
On real footage. The headline clip (fetched with python scripts/fetch_real_clip.py, not stored in the repository) is a hummingbird at a feeder, 1920x1080 at 30 fps. The ROI in data/samples/real/roi.json was computed from a YOLO pass. On a paced run with the default config (run_id 493402ed, log in docs/runs/):
- One visit, the whole perch. The bird arrived at about 3.0 s and left at 14.0 s. The visit opened at 3.03 s, every one of its 11 gated frames confirmed the bird (
visit_frames11), andlast_seenwas 10.7 s afterts(13.77 s into the clip). It was never split or lost while the bird sat still. - YOLO rate. Before the visit there was one gated frame, with no motion large enough, so no call. With the visit open, YOLO ran 25 times in 13.6 s, 1.84 calls/s: 2 per gated frame after the first, one on the track crop and one on the motion crop. While the bird perched, that motion was its bill, which sticks out of its box.
- A blurred arrival is re-checked. In another run of the same clip, before the re-check existed (run_id
c2c24c8d, with--annotate-out), the gated frames fell elsewhere: one on the bird in flight, blurred, and one a second after landing, when the still body had been absorbed into the background and only the wing moved. The visit opened at 5.93 s, 2.9 s late. Since2cfe133, the crop YOLO rejected is classified again on the following frames and kept out of the background model. With the gated frames forced onto the same frames, the visit now opens at 3.57 s (run_id83ae1c57), where it opened at 5.97 s (run_id49d65294). Details: docs/observations.md, "The R2 fix".
Multiple birds. Several birds can be in the region at once: each motion region gets its own YOLO crop, and the birds found on a frame are matched to the open visits jointly, so two birds side by side or crossing keep their own visits. Each line carries a visit_id and concurrent_max, the most visits open at once during it. Measured on eight real clips with ground truth written by hand (docs/corpus-baseline.md; three paced runs each, default config, logs in docs/runs/corpus-multi/):
- Two mynas touching on a car roof are two visits in all three runs (run_ids
6da0ba42,d504c0e2,08a203ed); before this change they were one visit every time. - Over the 24 runs: no bird missed (4 before), 9 merged into another bird's visit (12 before), 13 extra visits from split birds (7 before), 1 false visit (none before). The clip that got worse is the bird bath: an out-of-focus goldfinch boxed by YOLO as two side-by-side boxes becomes two visits (split 0-3 per run, run_ids
653b4177,c6251709). The false visit is a branch scored 0.57 on the doves clip (run_idabbcda9b). - A bird already there at the start is found sooner: on the six clips with one, its visit opens a median 3.3 s after its first frame instead of 6.1 s (2.0-6.1 s, was 2.3-11.2 s). The first two seconds teach the background model, bird included.
- Cost. At most 5 YOLO calls start on a gated frame (
detection.max_yolo_calls_per_s; 63 crops waited a few frames over the 24 runs), 5-9 calls in the busiest second with a re-check window. On 1080p footage every call takes longer than a frame (58 ms against 33 ms), so a paced run skips frames: 6-36 % of them on these clips, against 3-40 % before. Details and the open problems: docs/observations.md, "Multi-bird findings".
Several birds at once, rendered from a pipeline run on real footage (run_id 223e111a, paced, default config): a hanging bird bath with a chickadee, a pale bird and a goldfinch (Pexels video by David Clausen, Pexels License; source in data/samples/corpus/CREDITS.md). The middle plays at 3x.
All settings are in config.yaml. Unknown keys and wrong types stop the program at startup with a one-line message. Use --config PATH for another file. Relative paths resolve against --data-root (default: the repository root), not the current directory.
| Key | Default | Meaning |
|---|---|---|
camera.device_index |
0 |
OpenCV index of the capture device (see --list-devices; --device overrides it) |
camera.width |
1920 |
Requested capture width. The driver may negotiate another size, which the log records |
camera.height |
1080 |
Requested capture height |
camera.fps |
30 |
Requested frame rate |
detection.process_every_n_frames |
30 |
After warm-up, run the motion gate on the first frame and then every Nth (about 1 Hz at 30 fps) |
detection.motion_min_area |
200 |
Smallest motion contour, in pixels, that is sent to YOLO |
detection.motion_threshold |
16 |
MOG2 varThreshold: how different a pixel must be from the background to count as motion |
detection.motion_padding_px |
50 |
Context added around the motion box before the YOLO crop |
detection.motion_warmup_frames |
60 |
Frames MOG2 learns from before any detection (about 2 s) |
detection.yolo_model |
yolov8n.pt |
Ultralytics weights; downloaded on first use if missing |
detection.yolo_target_classes |
[bird] |
COCO class names to keep |
detection.yolo_confidence_threshold |
0.35 |
Minimum YOLO confidence |
detection.dedupe_within_seconds |
10 |
A visit closes after this long without the bird being confirmed |
detection.visit_iou_threshold |
0.3 |
A detection joins an open visit if its box overlaps the visit's last box by at least this IoU ... |
detection.visit_center_distance |
2.0 |
... or if its centre is within this many times max(width, height) of the last box's centre |
detection.max_visit_seconds |
600 |
A visit this long is written with "truncated": true, and the next confirmation opens a new one |
detection.recheck_window_frames |
30 |
When YOLO finds no bird in a gated frame's motion crop, it re-checks that crop on up to this many following frames (about 1 s), and the crop is kept out of the background model meanwhile. 0 turns the re-check off |
detection.recheck_every_n_frames |
3 |
Within that window, YOLO runs on every Nth frame. At 1 (every frame) a laptop CPU cannot keep up: 20 YOLO calls/s, one frame in three dropped. At 3 the loop keeps up, at 10 calls/s, and the visit opens about 0.1 s later |
detection.max_motion_regions |
4 |
Motion regions classified on a gated frame: every contour of at least motion_min_area, padded, merged with the contours whose padded boxes it overlaps; the largest first |
detection.max_yolo_calls_per_s |
5 |
YOLO calls that gated frames may start per second of capture time: the open visits' track crops first, then motion regions by size. The rest are logged and classified on the following frames as the budget refills, until the next gated frame. The re-check window's calls are not counted |
logging.events_file |
data/events.jsonl |
Where visits are appended |
logging.snapshots_dir |
data/snapshots |
Where snapshots are written |
logging.snapshot_format |
jpeg |
jpeg or png |
logging.snapshot_quality |
90 |
JPEG quality |
logging.save_full_frame |
true |
Save the full frame as well as the crop for each visit |
storage.max_events_per_day |
500 |
New visits beyond this many per day are dropped (disk-fill guard) |
storage.retention_days |
30 |
Snapshots older than this are deleted at startup; events.jsonl is never truncated |
Command-line flags (python main.py --help):
| Flag | Meaning |
|---|---|
--list-devices |
List capture devices and exit |
--device N |
Use capture device N |
--select-roi |
Redraw the region of interest |
--source PATH |
Read a video file instead of the camera (no device listing, no checklist; exit 0 at end of file) |
--headless |
No windows: no preview, no ROI dialog |
--no-pace |
With --source: process every frame as fast as possible instead of at the file's frame rate |
--yes |
Skip the camera checklist |
--config PATH |
Config file (default config.yaml) |
--data-root DIR |
Base for relative logging.* paths (default: repository root) |
--roi-file PATH |
ROI file (default data/roi.json) |
--log-dir DIR |
Where the run log goes (default logs/) |
--first-frame-timeout SECONDS |
Exit 1 if no frame arrives in time (default 5) |
--annotate-out PATH |
With --source: also write every processed frame, with the preview's overlays, to a video file (.mp4, or .avi for MJPG), and their frame numbers to PATH.frames.json. Works with --headless. Not available for the camera, which is never recorded |
Exit codes: 0 for a normal end, including Ctrl-C and SIGTERM; 1 for an error, with a one-line message; 2 if stdin closed at the checklist (use --yes).
data/events.jsonl has one JSON object per visit. A bird that stays in place is one line, however many frames it appears in. The full definition is in docs/events-schema.md.
| Field | Meaning |
|---|---|
ts |
Capture time of the visit's first detection, local ISO 8601 with milliseconds and offset |
run_id |
The run that saw it; names logs/run_<run_id>.jsonl |
frame_seq |
Frame number of that first detection within the run |
class |
Detected class (bird) |
confidence |
YOLO confidence of the first detection |
bbox_xywh |
Box in full-frame pixels: x, y, width, height |
snapshot_crop |
The crop YOLO saw, relative to the snapshots directory's parent (snapshots/<name>.jpg), or null |
snapshot_full |
The full frame, same convention, or null |
last_seen |
Capture time of the last matching detection |
visit_frames |
Frames in which the bird was detected: gated frames, plus the re-check frame that opened the visit, if one did |
truncated |
true if the visit was cut at detection.max_visit_seconds; the bird's next confirmation starts a new line |
recovered |
true if the line was written at startup from data/open_visits.json: the visit was still open when the previous run was killed |
visit_id |
<run_id>-<n>, the run's nth visit. Lines written before visit ids existed do not have it |
concurrent_max |
The most visits open at once at any moment of this visit, itself included: 2 or more when another bird was there too. Missing on older lines |
Lines are written when a visit closes: 10 s after the bird was last seen, or when the run ends. The run ends on a normal exit, an error, Ctrl-C, SIGTERM or Ctrl-Break. Open visits are also saved to data/open_visits.json: whenever a visit opens or closes, and every 60 s. After a hard kill (SIGKILL, taskkill /F, power loss), the next start writes them with "recovered": true. Only the last 60 s of an open visit (at most) are lost.
Visits per hour. scripts/plot_visits.py counts visits by hour of day (the local clock in each ts, all days summed) and writes a bar chart. It needs matplotlib, which requirements-dev.txt lists and Ultralytics already installs:
python scripts/plot_visits.py # data/events.jsonl -> docs/visits.png
python scripts/plot_visits.py path/to/events.jsonl --out visits.png --title "Week 1"
Visit report. scripts/visit_report.py prints visits per hour of day, the median visit length, the most birds there at once (visits overlapping in time, and concurrent_max where the lines have it) and the busiest hour:
python scripts/visit_report.py # data/events.jsonl
python scripts/visit_report.py path/to/events.jsonl
Snapshots are named <local time>_seq<frame, 6 digits>_d<index>_{crop,full}.jpg, for example 20260930T140506.789_seq000091_d0_crop.jpg.
Run logs. Every run writes logs/run_<run_id>.jsonl, one line per stage event (start, success, fail, skip, with an error_type). Per-frame stages are summarised every 300 frames. To see what failed or was skipped and how long each stage took:
python scripts/failure_report.py --latest # newest run
python scripts/failure_report.py # every run in logs/
python scripts/failure_report.py logs/run_<run_id>.jsonl
python -m pip install -r requirements-dev.txt
python -m pytest -m "not slow" # the fast suite: no camera, no YOLO
python -m pytest # adds the 14 tests that load the real yolov8n.pt
python -m pytest -m "not slow" --cov # with coverage, as CI runs it
python scripts/make_demo_gif.py # regenerate docs/demo.gif
make_demo_gif.py runs main.py --annotate-out on data/samples/demo.mp4 if that file exists, else on synth_bird.mp4, in a temporary directory (your data/events.jsonl is not touched). The run is paced at the clip's frame rate like a camera, so the HUD shows live numbers (about 30 fps); it takes as long as the clip. It cuts from 2 s before the first visit's ts to 2 s after its last_seen, and puts the input and the annotated frames side by side. If that is longer than 12 s, the perch plays faster, with a label such as 2x. ffmpeg comes from imageio-ffmpeg in requirements-dev.txt. If no size fits in 5 MB with the default encoding, the script retries with ordered dithering on lightly denoised frames, which real footage needs.
docs/demo-real.gif reuses a kept run instead (<dir> is any scratch directory):
python scripts/fetch_real_clip.py
python main.py --source data/samples/real/hummingbird_feeder.mp4 --roi-file data/samples/real/roi.json --headless --yes --data-root <dir> --log-dir <dir>/logs --annotate-out <dir>/annotated.mp4
python scripts/make_demo_gif.py --source data/samples/real/hummingbird_feeder.mp4 --annotated <dir>/annotated.mp4 --events <dir>/data/events.jsonl --out docs/demo-real.gif
docs/demo-multi.gif the same way, from the corpus's bird bath clip:
python scripts/fetch_clips.py bird_bath
python main.py --source data/samples/corpus/bird_bath/clip.mp4 --roi-file data/samples/corpus/bird_bath/roi.json --headless --yes --data-root <dir> --log-dir <dir>/logs --annotate-out <dir>/annotated.mp4
python scripts/make_demo_gif.py --source data/samples/corpus/bird_bath/clip.mp4 --annotated <dir>/annotated.mp4 --events <dir>/data/events.jsonl --out docs/demo-multi.gif
The corpus measurement (about 11 minutes, one paced run at a time): python scripts/fetch_clips.py, then python scripts/corpus_eval.py --runs 3 --keep-logs <dir> (docs/corpus-baseline.md).
CI (.github/workflows/ci.yml, badge at the top) runs the fast suite with coverage on Ubuntu and Windows, Python 3.12, on every push and pull request.
The slow marker covers 14 tests that run the real model in a subprocess: a smoke test on the blob clip, a check that the synthetic bird is detected where the script drew it, four on the real clip (skipped until python scripts/fetch_real_clip.py has downloaded it), and one paced run of each of the eight corpus clips, which must be no worse than docs/corpus-baseline.md (skipped until python scripts/fetch_clips.py has downloaded them). The real-clip tests must log a visit confirmed on at least 5 gated frames, and open it on the bird's arrival: within 0.5 s in three runs paced like a camera (about a minute), and, with the gated frames forced onto the frames where the bird is blurred, by 3.6 s with the default re-check and by 3.5 s with every frame re-checked. Everything else uses test doubles from tests/fakes.py:
-
FakePredictorstands in for YOLO. The detector reaches YOLO only through a smallPredictorprotocol (load(),predict(),names), so a test passes the fake in:fake = FakePredictor({91: [(300, 150, 30, 20)]}) # frame_seq -> boxes in full-frame pixels main.main(["--source", "clip.mp4", "--headless", "--no-pace"], predictor=fake) # or: Detector(config.detection, obs=obs, predictor=fake)
On a gated frame whose number is in the dict, it returns those boxes, converted to crop coordinates the way YOLO would report them. On every other frame it returns nothing.
fake.callsrecords every call. The test process never imports ultralytics, and a session check enforces this. -
FakeCapture/FakeDevicereplacecv2.VideoCapture. They cover endless or finite frames, failed reads and opens, a read that raises, and a read that never returns. -
FakeClockis injected asclock=/sleep=intoFrameGrabberandmain.main. Pacing, the stall timer and the reconnect backoff then run in fake time. -
tests/main_with_fakes.pyrunsmain.main()in a subprocess with the fakes installed from theCTB_FAKESenvironment variable. It is used for exit codes and signal handling.
The suite fails any test that takes more than 5 s, or that calls time.sleep() for more than 100 ms.
| Phase | Scope | State |
|---|---|---|
| 1 | Camera: device discovery, threaded capture, preview | done |
| 2 | Detection: MOG2 motion gate, YOLOv8n on the motion crop, ROI | done |
| 3 | Event log: events.jsonl, snapshots, dedupe, daily cap, retention |
done |
| 4 | Streamlit dashboard to review visits and snapshots | open |
| 5 | Reliability: camera reconnect with backoff, exit codes, structured run logs, clean shutdown | done |
| 6 | Visit-pattern analysis (times of day, durations) | open |
Known limitations:
- Capture backends are Windows-first. Device names come from DirectShow, and capture tries DirectShow, then Media Foundation. Video files work on any OS, but live capture on Linux and macOS is untested.
- At most 4 motion regions are classified on a gated frame (
detection.max_motion_regions), the largest first. Contours whose padded boxes overlap are one region. - CPU-only by default. A CUDA build of PyTorch speeds up only the YOLO step (see
requirements.txt). - A hard kill loses at most the last 60 s of an open visit. The visit itself is recovered on the next start from
data/open_visits.json, with"recovered": true, butlast_seenandvisit_framesare as of the last checkpoint. - Two birds close together can still share a visit, and one bird can be two. The birds on a frame are matched to visits one to one, but a bird that YOLO does not find on a frame lets its neighbour's box take its visit, and one blurred bird boxed twice, side by side, is two visits. On the corpus: 9 birds merged and 13 extra visits from split birds in 24 runs (docs/corpus-baseline.md).
- Open visits and motion regions cost YOLO time. Each gated frame runs YOLO once per open visit and once per motion region, up to
detection.max_yolo_calls_per_s(5) a second; the rest wait a few frames. Track crops continue for up todedupe_within_secondsafter the bird has left. Motion from a part of the bird outside its box (the hummingbird's bill on the real clip) is a region of its own, so a perched bird can cost 2 calls per gated frame. - Each YOLO call skips frames on 1080p footage. Detection runs on the main thread; a call (about 58 ms on the author's laptop) is longer than a frame interval at 30 fps, so a paced run skips about one frame per call, and about 8 for a gated frame with 5 calls. On the corpus, 6-36 % of frames were skipped.
- A re-check window costs YOLO time. After a gated frame with motion that YOLO rejects, YOLO runs on that crop on every third of the next 30 frames: about 10 calls/s for a second on a laptop CPU, instead of 1. Motion that goes on (a branch, a shadow) opens one window, not one per second.
detection.recheck_every_n_frames: 1finds the bird about 0.1 s sooner, at 20 calls/s and with one frame in three dropped during the window;recheck_window_frames: 0turns the re-check off. tsis the first frame on which YOLO found the bird. The motion gate looks once a second, so that can be up to a second after the bird came into view, and longer if YOLO cannot make it out during the re-check window.- Anything YOLO keeps calling a bird stays one long visit. A still object that YOLO scores at or above the threshold (a decoy, say) is written as a truncated visit every
max_visit_seconds(10 min by default), for as long as it stays.
MIT. See LICENSE.



