Skip to content

About

Passive bird-visit logger for a parked car: MOG2 motion gate + YOLOv8n on CPU

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

CatchThatBird

CI

The input clip and the pipeline's annotated preview side by side: a hummingbird flies in to a feeder, is boxed as a bird while it perches, and flies off

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.

Quickstart

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): run Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass, then .\.venv\Scripts\Activate.ps1 again. 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.

The input clip and the pipeline's annotated preview side by side: a house sparrow lands in the watched region, is boxed as a bird while it perches, and flies off

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:

Crop snapshot from the quickstart run: the composited house sparrow on the synthetic grey background

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.pt in 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), and last_seen is 13 s after ts. 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, ts and last_seen are the time the run started reading plus the position in the clip. --no-pace therefore 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_invalid line 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).
  • persist is the visit being written when the run ended.
  • docs/architecture.md explains 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.

Running with a real camera

The pipeline was built for a DJI Osmo Pocket 3 connected over USB-C in webcam (UVC) mode, pointed at the car.

  1. 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
    
  2. Start with that device. Or set camera.device_index in config.yaml instead of passing --device.

    python main.py --device 1
    

    Before 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

    --yes skips the checklist for unattended runs.

  3. 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. C means "use the whole frame". The rectangle is saved to data/roi.json (not tracked by git) and reused. If only the resolution changed, it is rescaled. To redraw it:

    python main.py --select-roi
    

    A headless run with no data/roi.json uses data/roi.example.json and prints a warning. That example was drawn for the author's camera.

  4. Watch the preview. It shows the ROI, detections and a three-line status display. q or Esc quits, and s saves the current frame to data/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.

How it works

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_frames 11), and last_seen was 10.7 s after ts (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. Since 2cfe133, 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_id 83ae1c57), where it opened at 5.97 s (run_id 49d65294). 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_id abbcda9b).
  • 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".

The bird bath clip and the pipeline's annotated preview side by side: a pale bird and a chickadee each get a box and a visit of their own, and a goldfinch that lands later gets a third

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.

Configuration

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).

Output

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

Development

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:

  • FakePredictor stands in for YOLO. The detector reaches YOLO only through a small Predictor protocol (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.calls records every call. The test process never imports ultralytics, and a session check enforces this.

  • FakeCapture / FakeDevice replace cv2.VideoCapture. They cover endless or finite frames, failed reads and opens, a read that raises, and a read that never returns.

  • FakeClock is injected as clock= / sleep= into FrameGrabber and main.main. Pacing, the stall timer and the reconnect backoff then run in fake time.

  • tests/main_with_fakes.py runs main.main() in a subprocess with the fakes installed from the CTB_FAKES environment 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.

Status and roadmap

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, but last_seen and visit_frames are 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 to dedupe_within_seconds after 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: 1 finds the bird about 0.1 s sooner, at 20 calls/s and with one frame in three dropped during the window; recheck_window_frames: 0 turns the re-check off.
  • ts is 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.

License

MIT. See LICENSE.

About

Passive bird-visit logger for a parked car: MOG2 motion gate + YOLOv8n on CPU

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages