commit a54a070ca9df2e2b1325b58127c8930cbbf54888 Author: zakariasaputra Date: Fri Jul 17 14:58:29 2026 +0700 intial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..683d8d1 --- /dev/null +++ b/README.md @@ -0,0 +1,400 @@ +# Chicken Counter + +First-pass Python pipeline for Jetson-style chicken counting using Ultralytics YOLO +tracking with BoT-SORT, ROI/gate-based counting, backward-motion detection from +background optical flow, and an OpenCV overlay that matches the provided reference +visual. + +## What Is Included + +- Modular runtime under `src/chicken_counter/` +- Sample camera config in `configs/cameras/example_camera.yaml` +- BoT-SORT tracker settings in `configs/trackers/botsort_chicken.yaml` +- CLI entrypoint: `chicken-counter` + +## Pipeline Stages + +1. Capture frames from a video file or camera stream +2. Run `model.track(..., persist=True)` with class filtering for chickens only +3. Maintain per-track state, track live occupancy inside the counting box, and count unique box entries +4. Estimate backward motion from sparse optical flow on background features +5. Render an OpenCV overlay with ROI, white gates, IDs, trails, and both live/cumulative counts + +## Project Layout + +```text +configs/ + cameras/example_camera.yaml + cycle7_batch.yaml + trackers/botsort_chicken.yaml +src/chicken_counter/ + batch_discovery.py + batch_runner.py + capture.py + cli.py + compress.py + config.py + counting.py + motion.py + overlay.py + pipeline.py + report.py + tracking.py + types.py + video_writer.py +``` + +## Install + +```bash +python -m pip install -e . +``` + +For Jetson deployment you will usually want a Jetson-compatible OpenCV and PyTorch +stack already installed, then install the rest of the package around that environment. + +## Run + +Update `configs/cameras/example_camera.yaml` with: + +- `source`: your input video path, RTSP URL, or camera index +- `detection.model_path`: your TensorRT `.engine` or `.pt` checkpoint +- ROI coordinates and gate lines calibrated for the real camera + +Then run: + +```bash +chicken-counter --config configs/cameras/example_camera.yaml +``` + +Press `q` to quit the preview window. + +For headless Jetson MP4 runs, set `display.show_window: false` and keep +`display.output_path` enabled so the annotated video is written without opening a GUI. + +The video writer tries a Jetson GStreamer hardware encoder first when +`display.encoder: auto` or `gstreamer`, then falls back to OpenCV codecs in +`display.codec_preference` order (default: `avc1`, `mp4v`, `H264`). + +## Config Notes + +### Detection + +The sample config restricts inference to class `0` and keeps ignored classes explicit: + +- `classes: [0]` +- `ignored_classes: [1, 2]` +- `conf` and `iou` are exposed for real-footage tuning +- `min_box_area_px` can be used to reject very small partial detections from validation +- `device: "0"` should be set explicitly on Jetson CUDA +- `imgsz` must match the size used when a TensorRT `.engine` was exported + +For TensorRT deployments, point `detection.model_path` at your `.engine` file and keep +`performance.half: false` (precision is already baked into the engine build). + +### Tracking + +The supplied tracker config enables: + +- `tracker_type: botsort` +- `gmc_method: none` for fixed-camera MP4 runs (avoids duplicate optical flow) +- `with_reid: false` + +Re-enable `gmc_method: sparseOptFlow` in `configs/trackers/botsort_chicken.yaml` only if +the camera mount moves or footage is shaky enough that track IDs drift without GMC. + +Starting thresholds match the prompt defaults and can be tuned in +`configs/trackers/botsort_chicken.yaml`. + +### Periodic Runtime Feedback + +You can enable checkpoint-style progress feedback every `N` frames with the `feedback` +config block: + +```yaml +feedback: + enabled: true + every_n_frames: 300 + save_images: true + image_output_dir: output/checkpoints + log_to_terminal: true +``` + +When enabled, the pipeline will: + +- print a periodic progress line with frame number, elapsed time, processing FPS, ETA, and total count +- save the current annotated frame as a checkpoint image (when `save_images: true`) + +This is especially useful on Jetson when processing MP4 files headlessly, because you +can verify progress from the terminal and inspect saved snapshot images without needing +an on-device display. + +### Counting ROI And Gates + +The overlay is intended to resemble the reference image while staying easy to read: + +- no outer green ROI outline +- one visible counting rectangle that is slightly smaller and cleaner than the previous broad region +- orange chicken bounding boxes that are visually distinct from the counting guides +- per-bird numeric labels based on count sequence, not raw tracker ID, using a non-white color +- short centroid trails +- one bold `TOTAL ENTERED` caption as the main count display, using a non-white color + +The green ROI should be treated as the actual middle counting box. The current counting +semantics are: + +- `Inside Box`: how many currently tracked chickens have their centroids inside the ROI +- `Total Entered`: how many unique tracked chickens have entered the ROI at least once +- a chicken is only valid for `Total Entered` if its bounding-box area meets `min_box_area_px` +- if backward motion is confirmed, the current frame is finalized and then the pipeline stops +- validated chickens receive a stable visible sequence number `1, 2, 3, ...` in entry order +- unvalidated chickens are tracked internally but do not show a visible sequence number yet + +The implementation still assumes normal travel is `bottom_to_up`. + +## Calibration Workflow + +1. Start with a representative frame from the real camera. +2. Set `roi.points` so the counting rectangle spans the intended middle counting box only. +3. If the displayed rectangle feels too large or small, tighten or expand `roi.points` directly. +4. Run a short clip and compare `Inside Box` against the visible birds currently in that box. +5. Increase `min_box_area_px` if small partial chickens are being counted too early. +6. Verify `Total Entered` only increases when a new tracked bird enters the box during forward motion and is large enough to be valid. +6. Verify that once backward motion is confirmed, the output video ends at that point and the MP4 is finalized cleanly. +7. Verify that the highest displayed sequence number matches `Total Entered`. +8. Verify the final freeze frame stays on screen long enough to read the last total clearly. + +## Backward-Motion Tuning + +The stop trigger is separate from chicken tracks. It measures background motion while +masking detected chicken boxes. + +Tune these values against real footage: + +- `motion.forward_sign` +- `motion.ema_alpha` +- `motion.reverse_enter_threshold` +- `motion.reverse_exit_threshold` +- `motion.debounce_frames` +- `motion.min_features` +- `motion.stride_frames` (run flow every N frames; `2` is faster) +- `motion.flow_scale` (downscale ROI gray before flow; `0.5` is faster) +- `motion.max_corners` (fewer corners = faster; try `80`) + +Important: confirm the actual sign convention from real cart footage before treating +the configured forward direction as final. + +## Jetson Performance Speedups + +For long batch runs, enable inference and motion stride in config: + +```yaml +performance: + inference_stride: 2 # run YOLO+BoT-SORT every 2nd frame; reuse tracks in between +motion: + stride_frames: 2 # run optical flow every 2nd frame + flow_scale: 0.5 # half-resolution flow inside ROI crop + max_corners: 80 +detection: + imgsz: 640 # keep 640 while using existing TensorRT .engine +``` + +`configs/cycle7_batch.yaml` already uses these production defaults. + +**Validation:** run a short clip with stride enabled, then compare `total_entered` against +`inference_stride: 1` and `motion.stride_frames: 1`. Watch checkpoint `fps=` logs for +speedup. Box positions may lag by up to one frame on skipped inference frames. + +Set `inference_stride: 1` or `motion.stride_frames: 1` to restore full per-frame accuracy +for tuning. + +## Known Limits In This First Pass + +- No DeepStream integration yet +- No multi-process or multi-camera scheduler yet +- Counting currently assumes vertical motion and `bottom_to_up` travel +- The live box count depends on stable tracking centroids inside the ROI +- The optical-flow trigger is vision-first, though the config structure leaves room for + a future controller/encoder integration path + +## Headless Jetson MP4 Example + +For a headless run that saves both output video and periodic checkpoint images, use a +config shaped like this: + +```yaml +display: + show_window: false + output_path: output/coop_cam_03_overlay.mp4 + encoder: auto + output_bitrate_kbps: 4000 +feedback: + enabled: true + every_n_frames: 300 + save_images: true + image_output_dir: output/checkpoints + log_to_terminal: true +``` + +## 40-Minute Jetson Recipe + +For long headless runs (~72,000 frames at 30 FPS), use the production-oriented settings +in `configs/cameras/example_camera.yaml`: + +```yaml +detection: + device: "0" + imgsz: 640 + model_path: /path/to/your-model.engine +overlay: + show_track_trails: false + show_track_ring: false +motion: + max_corners: 80 + stride_frames: 2 + flow_scale: 0.5 +display: + show_window: false + output_path: /media/jetson/DATA/try-sukawarna-vis.mp4 + encoder: auto + output_bitrate_kbps: 4000 +performance: + half: false + overlay_buffer_reuse: true + inference_stride: 2 +feedback: + enabled: true + every_n_frames: 900 + log_to_terminal: true + save_images: false +``` + +Tracker YAML should use `gmc_method: none` for fixed-camera footage. + +Lower `display.output_bitrate_kbps` produces smaller MP4 files with more compression +artifacts. Start at `4000` and adjust after inspecting output quality. + +Checkpoint logs look like: + +```text +[checkpoint] frame=9000/72000 elapsed=18m12s fps=8.2 total_entered=142 eta=2h05m status=running +``` + +When backward motion is confirmed, the pipeline now: + +- finishes the current annotated frame +- writes that frame to the output video +- logs the backward-stop event +- appends a short freeze frame so the final total is readable +- exits immediately afterward, so the output MP4 ends there + +## Daily Cycle7 Multi-Camera Batch + +For everyday processing of 4 cameras, use `configs/cycle7_batch.yaml`. + +### Input folder layout + +Place today's videos under: + +```text +/media/jetson/DATA/chicken-sukawarna/cycle7/2026-07-09/ + kandang_1_camera_1_2026-07-09_120056.mp4 + kandang_1_camera_2_2026-07-09_120456.mp4 + kandang_1_camera_3_2026-07-09_121012.mp4 + kandang_1_camera_4_2026-07-09_121530.mp4 +``` + +Date folders use `YYYY-MM-DD`. Camera files are matched by `camera_num` using the +pattern `kandang_*_camera_{num}_*.mp4`. + +### Run commands + +```bash +# Process today's folder +chicken-counter batch --config configs/cycle7_batch.yaml + +# Process a specific date +chicken-counter batch --config configs/cycle7_batch.yaml --date 2026-07-09 +``` + +Single-camera mode still works: + +```bash +chicken-counter --config configs/cameras/example_camera.yaml +chicken-counter run --config configs/cameras/example_camera.yaml +``` + +### Output layout + +```text +/media/jetson/DATA/chicken-sukawarna/cycle7/2026-07-09/output/ + CC1_vis.mp4 + CC1_compressed.mp4 + CC2_vis.mp4 + CC2_compressed.mp4 + ... + checkpoints/CC1/frame_003000.jpg + checkpoints/CC2/frame_006000.jpg + counts_2026-07-09.json +``` + +After all 4 cameras finish counting, the batch runner compresses each annotated video +to under `batch.compress_max_mb` (default 200 MB) using `ffmpeg`. + +### Per-camera counting boxes + +| Camera | ROI points | +|--------|------------| +| CC1 | `[250,330], [1650,330], [1650,720], [250,720]` | +| CC2 | `[20,380], [1880,380], [1880,720], [20,720]` | +| CC3 | `[20,330], [1880,330], [1880,720], [20,720]` | +| CC4 | `[50,330], [1450,330], [1450,720], [50,720]` | + +Tune these in `configs/cycle7_batch.yaml` if a lane drifts after camera maintenance. + +### JSON report format + +`counts_{date}.json` contains per-camera totals and the sum across all 4 cameras: + +```json +{ + "date": "2026-07-09", + "generated_at": "2026-07-09T11:45:00+00:00", + "cameras": { + "CC1": { + "total_entered": 142, + "source_video": "kandang_1_camera_1_2026-07-09_120056.mp4", + "vis_video": "CC1_vis.mp4", + "compressed_video": "CC1_compressed.mp4", + "compressed_size_mb": 187.4, + "frames_processed": 68432, + "stopped_reason": "backward", + "elapsed_seconds": 8234.5 + } + }, + "total_entered_sum": 580 +} +``` + +### Checkpoint images + +Batch mode saves review images every `checkpoint_every_n_frames` (default 3000) per camera. +For a ~72k frame run that is about 24 images per camera. + +### Cron example + +```cron +0 7 * * * cd /media/jetson/DATA/chicken-sukawarna && /usr/bin/chicken-counter batch --config configs/cycle7_batch.yaml >> logs/cycle7-batch.log 2>&1 +``` + +Requires `ffmpeg` on the Jetson PATH for post-run compression. + +## Next Jetson-Focused Improvements + +1. Add a hardware-aware video ingest path for CSI/GStreamer. +2. Export richer event logs for per-bird count timestamps. +3. Add a controller-signal adapter so encoder direction can override vision when available. + +Recent work includes daily 4-camera batch processing, JSON count reports, post-run +compression under 200 MB, GStreamer hardware encoding, overlay buffer reuse, +duplicate optical-flow removal (`gmc_method: none`), and long-run ETA logging. diff --git a/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.pt b/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.pt new file mode 100644 index 0000000..1013fd6 Binary files /dev/null and b/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.pt differ diff --git a/chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt b/chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt new file mode 100644 index 0000000..98aa2c1 Binary files /dev/null and b/chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt differ diff --git a/configs/cameras/example_camera.yaml b/configs/cameras/example_camera.yaml new file mode 100644 index 0000000..adb2e19 --- /dev/null +++ b/configs/cameras/example_camera.yaml @@ -0,0 +1,79 @@ +camera_id: coop_cam_03 +source: /media/jetson/DATA/record/try-sukawarna.mp4 +detection: + model_path: /media/jetson/DATA/chicken-sukawarna/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine + classes: [0] + ignored_classes: [1, 2] + conf: 0.35 + iou: 0.55 + imgsz: 640 + device: "0" + min_box_area_px: 6000 + validate_while_inside: true +detection_zone: + enabled: true + buffer_above_px: 250 + buffer_below_px: 250 + show_in_overlay: true +tracker: + tracker_config_path: configs/trackers/botsort_chicken.yaml + persist: true + track_buffer: 75 +roi: + points: + - [80, 340] + - [1690, 340] + - [1690, 810] + - [80, 810] + inset_left_px: 60 + inset_right_px: 60 + inset_top_px: 0 + inset_bottom_px: 0 + min_overlap_ratio: 0.30 +gate: + mode: two_line + lines_y: [420, 730] + direction: bottom_to_up +motion: + enabled: true + axis: vertical + forward_sign: 1.0 + ema_alpha: 0.2 + reverse_enter_threshold: -1.5 + reverse_exit_threshold: -0.5 + debounce_frames: 12 + min_features: 60 + max_corners: 80 + stride_frames: 2 + flow_scale: 0.5 + quality_level: 0.01 + min_distance: 8 + block_radius: 6 +overlay: + show_boxes: true + show_track_trails: false + trail_length: 20 + show_center_marker: true + show_track_ring: false + count_anchor: [780, 120] + inside_box_only: true + pending_blink: true + pending_colors: + - [255, 255, 0] + - [0, 255, 255] +display: + show_window: false + output_path: /media/jetson/DATA/try-sukawarna-vis.mp4 + encoder: auto + output_bitrate_kbps: 4000 + codec_preference: [avc1, mp4v, H264] +performance: + half: false + overlay_buffer_reuse: true + inference_stride: 2 +feedback: + enabled: true + every_n_frames: 900 + save_images: false + image_output_dir: /media/jetson/DATA/chicken-sukawarna/output/checkpoints + log_to_terminal: true diff --git a/configs/cycle7_batch.yaml b/configs/cycle7_batch.yaml new file mode 100644 index 0000000..01aea8b --- /dev/null +++ b/configs/cycle7_batch.yaml @@ -0,0 +1,117 @@ +batch: + root_dir: /home/nvidia-admin/VIDEOS/cycle7/kandang-atas + camera_glob: "kandang_*_camera_{num}_*.mp4" + output_subdir: output + compress_max_mb: 200 + delete_intermediate: false + checkpoint_every_n_frames: 3000 + +defaults: + detection: + model_path: /home/nvidia-admin/LABS/try-weight-estimator/try-chicken-sukawarna/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.pt + classes: [0] + ignored_classes: [1, 2] + conf: 0.35 + iou: 0.55 + imgsz: 640 + device: "0" + min_box_area_px: 3000 + validate_while_inside: true + detection_zone: + enabled: true + buffer_above_px: 250 + buffer_below_px: 250 + show_in_overlay: true + tracker: + tracker_config_path: /home/nvidia-admin/LABS/try-weight-estimator/try-chicken-sukawarna/configs/trackers/botsort_chicken.yaml + persist: true + track_buffer: 75 + gate: + mode: two_line + lines_y: [420, 730] + direction: bottom_to_up + motion: + enabled: true + axis: vertical + forward_sign: 1.0 + ema_alpha: 0.2 + reverse_enter_threshold: -1.5 + reverse_exit_threshold: -0.5 + debounce_frames: 12 + min_features: 60 + max_corners: 80 + stride_frames: 2 + flow_scale: 0.5 + quality_level: 0.01 + min_distance: 8 + block_radius: 6 + overlay: + show_boxes: true + show_track_trails: false + trail_length: 20 + show_center_marker: true + show_track_ring: false + count_anchor: [780, 120] + inside_box_only: true + pending_blink: true + pending_colors: + - [255, 255, 0] + - [0, 255, 255] + display: + show_window: false + encoder: auto + output_bitrate_kbps: 4000 + codec_preference: [avc1, mp4v, H264] + performance: + half: false + overlay_buffer_reuse: true + inference_stride: 2 + feedback: + enabled: true + every_n_frames: 3000 + save_images: true + log_to_terminal: true + roi: + inset_left_px: 60 + inset_right_px: 60 + inset_top_px: 0 + inset_bottom_px: 0 + min_overlap_ratio: 0.30 + +cameras: + CC1: + camera_num: 1 + count_anchor: [780, 120] + roi: + points: + - [250, 330] + - [1650, 330] + - [1650, 720] + - [250, 720] + CC2: + camera_num: 2 + count_anchor: [900, 120] + roi: + points: + - [20, 380] + - [1880, 380] + - [1880, 720] + - [20, 720] + CC3: + camera_num: 3 + count_anchor: [900, 120] + roi: + points: + - [20, 330] + - [1880, 330] + - [1880, 720] + - [20, 720] + CC4: + camera_num: 4 + count_anchor: [700, 120] + roi: + points: + - [50, 330] + - [1450, 330] + - [1450, 720] + - [50, 720] diff --git a/configs/trackers/botsort_chicken.yaml b/configs/trackers/botsort_chicken.yaml new file mode 100644 index 0000000..d0beb51 --- /dev/null +++ b/configs/trackers/botsort_chicken.yaml @@ -0,0 +1,12 @@ +tracker_type: botsort +track_high_thresh: 0.5 +track_low_thresh: 0.1 +new_track_thresh: 0.6 +track_buffer: 75 +match_thresh: 0.8 +fuse_score: true +gmc_method: none +proximity_thresh: 0.5 +appearance_thresh: 0.25 +with_reid: false +model: auto \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e49f20d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "chicken-counter" +version = "0.1.0" +description = "First-pass Jetson chicken counting pipeline with YOLO and BoT-SORT." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "numpy>=1.26", + "opencv-python>=4.10", + "PyYAML>=6.0.2", + "ultralytics>=8.4.38", +] + +[project.scripts] +chicken-counter = "chicken_counter.cli:main" + +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/chicken_counter.egg-info/PKG-INFO b/src/chicken_counter.egg-info/PKG-INFO new file mode 100644 index 0000000..ed46aea --- /dev/null +++ b/src/chicken_counter.egg-info/PKG-INFO @@ -0,0 +1,411 @@ +Metadata-Version: 2.4 +Name: chicken-counter +Version: 0.1.0 +Summary: First-pass Jetson chicken counting pipeline with YOLO and BoT-SORT. +Requires-Python: >=3.10 +Description-Content-Type: text/markdown +Requires-Dist: numpy>=1.26 +Requires-Dist: opencv-python>=4.10 +Requires-Dist: PyYAML>=6.0.2 +Requires-Dist: ultralytics>=8.4.38 + +# Chicken Counter + +First-pass Python pipeline for Jetson-style chicken counting using Ultralytics YOLO +tracking with BoT-SORT, ROI/gate-based counting, backward-motion detection from +background optical flow, and an OpenCV overlay that matches the provided reference +visual. + +## What Is Included + +- Modular runtime under `src/chicken_counter/` +- Sample camera config in `configs/cameras/example_camera.yaml` +- BoT-SORT tracker settings in `configs/trackers/botsort_chicken.yaml` +- CLI entrypoint: `chicken-counter` + +## Pipeline Stages + +1. Capture frames from a video file or camera stream +2. Run `model.track(..., persist=True)` with class filtering for chickens only +3. Maintain per-track state, track live occupancy inside the counting box, and count unique box entries +4. Estimate backward motion from sparse optical flow on background features +5. Render an OpenCV overlay with ROI, white gates, IDs, trails, and both live/cumulative counts + +## Project Layout + +```text +configs/ + cameras/example_camera.yaml + cycle7_batch.yaml + trackers/botsort_chicken.yaml +src/chicken_counter/ + batch_discovery.py + batch_runner.py + capture.py + cli.py + compress.py + config.py + counting.py + motion.py + overlay.py + pipeline.py + report.py + tracking.py + types.py + video_writer.py +``` + +## Install + +```bash +python -m pip install -e . +``` + +For Jetson deployment you will usually want a Jetson-compatible OpenCV and PyTorch +stack already installed, then install the rest of the package around that environment. + +## Run + +Update `configs/cameras/example_camera.yaml` with: + +- `source`: your input video path, RTSP URL, or camera index +- `detection.model_path`: your TensorRT `.engine` or `.pt` checkpoint +- ROI coordinates and gate lines calibrated for the real camera + +Then run: + +```bash +chicken-counter --config configs/cameras/example_camera.yaml +``` + +Press `q` to quit the preview window. + +For headless Jetson MP4 runs, set `display.show_window: false` and keep +`display.output_path` enabled so the annotated video is written without opening a GUI. + +The video writer tries a Jetson GStreamer hardware encoder first when +`display.encoder: auto` or `gstreamer`, then falls back to OpenCV codecs in +`display.codec_preference` order (default: `avc1`, `mp4v`, `H264`). + +## Config Notes + +### Detection + +The sample config restricts inference to class `0` and keeps ignored classes explicit: + +- `classes: [0]` +- `ignored_classes: [1, 2]` +- `conf` and `iou` are exposed for real-footage tuning +- `min_box_area_px` can be used to reject very small partial detections from validation +- `device: "0"` should be set explicitly on Jetson CUDA +- `imgsz` must match the size used when a TensorRT `.engine` was exported + +For TensorRT deployments, point `detection.model_path` at your `.engine` file and keep +`performance.half: false` (precision is already baked into the engine build). + +### Tracking + +The supplied tracker config enables: + +- `tracker_type: botsort` +- `gmc_method: none` for fixed-camera MP4 runs (avoids duplicate optical flow) +- `with_reid: false` + +Re-enable `gmc_method: sparseOptFlow` in `configs/trackers/botsort_chicken.yaml` only if +the camera mount moves or footage is shaky enough that track IDs drift without GMC. + +Starting thresholds match the prompt defaults and can be tuned in +`configs/trackers/botsort_chicken.yaml`. + +### Periodic Runtime Feedback + +You can enable checkpoint-style progress feedback every `N` frames with the `feedback` +config block: + +```yaml +feedback: + enabled: true + every_n_frames: 300 + save_images: true + image_output_dir: output/checkpoints + log_to_terminal: true +``` + +When enabled, the pipeline will: + +- print a periodic progress line with frame number, elapsed time, processing FPS, ETA, and total count +- save the current annotated frame as a checkpoint image (when `save_images: true`) + +This is especially useful on Jetson when processing MP4 files headlessly, because you +can verify progress from the terminal and inspect saved snapshot images without needing +an on-device display. + +### Counting ROI And Gates + +The overlay is intended to resemble the reference image while staying easy to read: + +- no outer green ROI outline +- one visible counting rectangle that is slightly smaller and cleaner than the previous broad region +- orange chicken bounding boxes that are visually distinct from the counting guides +- per-bird numeric labels based on count sequence, not raw tracker ID, using a non-white color +- short centroid trails +- one bold `TOTAL ENTERED` caption as the main count display, using a non-white color + +The green ROI should be treated as the actual middle counting box. The current counting +semantics are: + +- `Inside Box`: how many currently tracked chickens have their centroids inside the ROI +- `Total Entered`: how many unique tracked chickens have entered the ROI at least once +- a chicken is only valid for `Total Entered` if its bounding-box area meets `min_box_area_px` +- if backward motion is confirmed, the current frame is finalized and then the pipeline stops +- validated chickens receive a stable visible sequence number `1, 2, 3, ...` in entry order +- unvalidated chickens are tracked internally but do not show a visible sequence number yet + +The implementation still assumes normal travel is `bottom_to_up`. + +## Calibration Workflow + +1. Start with a representative frame from the real camera. +2. Set `roi.points` so the counting rectangle spans the intended middle counting box only. +3. If the displayed rectangle feels too large or small, tighten or expand `roi.points` directly. +4. Run a short clip and compare `Inside Box` against the visible birds currently in that box. +5. Increase `min_box_area_px` if small partial chickens are being counted too early. +6. Verify `Total Entered` only increases when a new tracked bird enters the box during forward motion and is large enough to be valid. +6. Verify that once backward motion is confirmed, the output video ends at that point and the MP4 is finalized cleanly. +7. Verify that the highest displayed sequence number matches `Total Entered`. +8. Verify the final freeze frame stays on screen long enough to read the last total clearly. + +## Backward-Motion Tuning + +The stop trigger is separate from chicken tracks. It measures background motion while +masking detected chicken boxes. + +Tune these values against real footage: + +- `motion.forward_sign` +- `motion.ema_alpha` +- `motion.reverse_enter_threshold` +- `motion.reverse_exit_threshold` +- `motion.debounce_frames` +- `motion.min_features` +- `motion.stride_frames` (run flow every N frames; `2` is faster) +- `motion.flow_scale` (downscale ROI gray before flow; `0.5` is faster) +- `motion.max_corners` (fewer corners = faster; try `80`) + +Important: confirm the actual sign convention from real cart footage before treating +the configured forward direction as final. + +## Jetson Performance Speedups + +For long batch runs, enable inference and motion stride in config: + +```yaml +performance: + inference_stride: 2 # run YOLO+BoT-SORT every 2nd frame; reuse tracks in between +motion: + stride_frames: 2 # run optical flow every 2nd frame + flow_scale: 0.5 # half-resolution flow inside ROI crop + max_corners: 80 +detection: + imgsz: 640 # keep 640 while using existing TensorRT .engine +``` + +`configs/cycle7_batch.yaml` already uses these production defaults. + +**Validation:** run a short clip with stride enabled, then compare `total_entered` against +`inference_stride: 1` and `motion.stride_frames: 1`. Watch checkpoint `fps=` logs for +speedup. Box positions may lag by up to one frame on skipped inference frames. + +Set `inference_stride: 1` or `motion.stride_frames: 1` to restore full per-frame accuracy +for tuning. + +## Known Limits In This First Pass + +- No DeepStream integration yet +- No multi-process or multi-camera scheduler yet +- Counting currently assumes vertical motion and `bottom_to_up` travel +- The live box count depends on stable tracking centroids inside the ROI +- The optical-flow trigger is vision-first, though the config structure leaves room for + a future controller/encoder integration path + +## Headless Jetson MP4 Example + +For a headless run that saves both output video and periodic checkpoint images, use a +config shaped like this: + +```yaml +display: + show_window: false + output_path: output/coop_cam_03_overlay.mp4 + encoder: auto + output_bitrate_kbps: 4000 +feedback: + enabled: true + every_n_frames: 300 + save_images: true + image_output_dir: output/checkpoints + log_to_terminal: true +``` + +## 40-Minute Jetson Recipe + +For long headless runs (~72,000 frames at 30 FPS), use the production-oriented settings +in `configs/cameras/example_camera.yaml`: + +```yaml +detection: + device: "0" + imgsz: 640 + model_path: /path/to/your-model.engine +overlay: + show_track_trails: false + show_track_ring: false +motion: + max_corners: 80 + stride_frames: 2 + flow_scale: 0.5 +display: + show_window: false + output_path: /media/jetson/DATA/try-sukawarna-vis.mp4 + encoder: auto + output_bitrate_kbps: 4000 +performance: + half: false + overlay_buffer_reuse: true + inference_stride: 2 +feedback: + enabled: true + every_n_frames: 900 + log_to_terminal: true + save_images: false +``` + +Tracker YAML should use `gmc_method: none` for fixed-camera footage. + +Lower `display.output_bitrate_kbps` produces smaller MP4 files with more compression +artifacts. Start at `4000` and adjust after inspecting output quality. + +Checkpoint logs look like: + +```text +[checkpoint] frame=9000/72000 elapsed=18m12s fps=8.2 total_entered=142 eta=2h05m status=running +``` + +When backward motion is confirmed, the pipeline now: + +- finishes the current annotated frame +- writes that frame to the output video +- logs the backward-stop event +- appends a short freeze frame so the final total is readable +- exits immediately afterward, so the output MP4 ends there + +## Daily Cycle7 Multi-Camera Batch + +For everyday processing of 4 cameras, use `configs/cycle7_batch.yaml`. + +### Input folder layout + +Place today's videos under: + +```text +/media/jetson/DATA/chicken-sukawarna/cycle7/2026-07-09/ + kandang_1_camera_1_2026-07-09_120056.mp4 + kandang_1_camera_2_2026-07-09_120456.mp4 + kandang_1_camera_3_2026-07-09_121012.mp4 + kandang_1_camera_4_2026-07-09_121530.mp4 +``` + +Date folders use `YYYY-MM-DD`. Camera files are matched by `camera_num` using the +pattern `kandang_*_camera_{num}_*.mp4`. + +### Run commands + +```bash +# Process today's folder +chicken-counter batch --config configs/cycle7_batch.yaml + +# Process a specific date +chicken-counter batch --config configs/cycle7_batch.yaml --date 2026-07-09 +``` + +Single-camera mode still works: + +```bash +chicken-counter --config configs/cameras/example_camera.yaml +chicken-counter run --config configs/cameras/example_camera.yaml +``` + +### Output layout + +```text +/media/jetson/DATA/chicken-sukawarna/cycle7/2026-07-09/output/ + CC1_vis.mp4 + CC1_compressed.mp4 + CC2_vis.mp4 + CC2_compressed.mp4 + ... + checkpoints/CC1/frame_003000.jpg + checkpoints/CC2/frame_006000.jpg + counts_2026-07-09.json +``` + +After all 4 cameras finish counting, the batch runner compresses each annotated video +to under `batch.compress_max_mb` (default 200 MB) using `ffmpeg`. + +### Per-camera counting boxes + +| Camera | ROI points | +|--------|------------| +| CC1 | `[250,330], [1650,330], [1650,720], [250,720]` | +| CC2 | `[20,380], [1880,380], [1880,720], [20,720]` | +| CC3 | `[20,330], [1880,330], [1880,720], [20,720]` | +| CC4 | `[50,330], [1450,330], [1450,720], [50,720]` | + +Tune these in `configs/cycle7_batch.yaml` if a lane drifts after camera maintenance. + +### JSON report format + +`counts_{date}.json` contains per-camera totals and the sum across all 4 cameras: + +```json +{ + "date": "2026-07-09", + "generated_at": "2026-07-09T11:45:00+00:00", + "cameras": { + "CC1": { + "total_entered": 142, + "source_video": "kandang_1_camera_1_2026-07-09_120056.mp4", + "vis_video": "CC1_vis.mp4", + "compressed_video": "CC1_compressed.mp4", + "compressed_size_mb": 187.4, + "frames_processed": 68432, + "stopped_reason": "backward", + "elapsed_seconds": 8234.5 + } + }, + "total_entered_sum": 580 +} +``` + +### Checkpoint images + +Batch mode saves review images every `checkpoint_every_n_frames` (default 3000) per camera. +For a ~72k frame run that is about 24 images per camera. + +### Cron example + +```cron +0 7 * * * cd /media/jetson/DATA/chicken-sukawarna && /usr/bin/chicken-counter batch --config configs/cycle7_batch.yaml >> logs/cycle7-batch.log 2>&1 +``` + +Requires `ffmpeg` on the Jetson PATH for post-run compression. + +## Next Jetson-Focused Improvements + +1. Add a hardware-aware video ingest path for CSI/GStreamer. +2. Export richer event logs for per-bird count timestamps. +3. Add a controller-signal adapter so encoder direction can override vision when available. + +Recent work includes daily 4-camera batch processing, JSON count reports, post-run +compression under 200 MB, GStreamer hardware encoding, overlay buffer reuse, +duplicate optical-flow removal (`gmc_method: none`), and long-run ETA logging. diff --git a/src/chicken_counter.egg-info/SOURCES.txt b/src/chicken_counter.egg-info/SOURCES.txt new file mode 100644 index 0000000..d9983d5 --- /dev/null +++ b/src/chicken_counter.egg-info/SOURCES.txt @@ -0,0 +1,24 @@ +README.md +pyproject.toml +src/chicken_counter/__init__.py +src/chicken_counter/batch_discovery.py +src/chicken_counter/batch_runner.py +src/chicken_counter/capture.py +src/chicken_counter/cli.py +src/chicken_counter/compress.py +src/chicken_counter/config.py +src/chicken_counter/counting.py +src/chicken_counter/motion.py +src/chicken_counter/overlay.py +src/chicken_counter/pipeline.py +src/chicken_counter/report.py +src/chicken_counter/tracking.py +src/chicken_counter/types.py +src/chicken_counter/video_writer.py +src/chicken_counter.egg-info/PKG-INFO +src/chicken_counter.egg-info/SOURCES.txt +src/chicken_counter.egg-info/dependency_links.txt +src/chicken_counter.egg-info/entry_points.txt +src/chicken_counter.egg-info/requires.txt +src/chicken_counter.egg-info/top_level.txt +tests/test_tracking.py \ No newline at end of file diff --git a/src/chicken_counter.egg-info/dependency_links.txt b/src/chicken_counter.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/chicken_counter.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/chicken_counter.egg-info/entry_points.txt b/src/chicken_counter.egg-info/entry_points.txt new file mode 100644 index 0000000..7d5ab66 --- /dev/null +++ b/src/chicken_counter.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +chicken-counter = chicken_counter.cli:main diff --git a/src/chicken_counter.egg-info/requires.txt b/src/chicken_counter.egg-info/requires.txt new file mode 100644 index 0000000..9856261 --- /dev/null +++ b/src/chicken_counter.egg-info/requires.txt @@ -0,0 +1,4 @@ +numpy>=1.26 +opencv-python>=4.10 +PyYAML>=6.0.2 +ultralytics>=8.4.38 diff --git a/src/chicken_counter.egg-info/top_level.txt b/src/chicken_counter.egg-info/top_level.txt new file mode 100644 index 0000000..b7d2b60 --- /dev/null +++ b/src/chicken_counter.egg-info/top_level.txt @@ -0,0 +1 @@ +chicken_counter diff --git a/src/chicken_counter/__init__.py b/src/chicken_counter/__init__.py new file mode 100644 index 0000000..59ff72e --- /dev/null +++ b/src/chicken_counter/__init__.py @@ -0,0 +1 @@ +"""Chicken counting pipeline package for Jetson single-camera and daily batch runs.""" diff --git a/src/chicken_counter/__pycache__/__init__.cpython-312.pyc b/src/chicken_counter/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..8a08b18 Binary files /dev/null and b/src/chicken_counter/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/chicken_counter/__pycache__/__init__.cpython-313.pyc b/src/chicken_counter/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..c3e13d0 Binary files /dev/null and b/src/chicken_counter/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/chicken_counter/__pycache__/batch_discovery.cpython-312.pyc b/src/chicken_counter/__pycache__/batch_discovery.cpython-312.pyc new file mode 100644 index 0000000..0036e6d Binary files /dev/null and b/src/chicken_counter/__pycache__/batch_discovery.cpython-312.pyc differ diff --git a/src/chicken_counter/__pycache__/batch_runner.cpython-312.pyc b/src/chicken_counter/__pycache__/batch_runner.cpython-312.pyc new file mode 100644 index 0000000..9eff444 Binary files /dev/null and b/src/chicken_counter/__pycache__/batch_runner.cpython-312.pyc differ diff --git a/src/chicken_counter/__pycache__/capture.cpython-312.pyc b/src/chicken_counter/__pycache__/capture.cpython-312.pyc new file mode 100644 index 0000000..b0ecbd3 Binary files /dev/null and b/src/chicken_counter/__pycache__/capture.cpython-312.pyc differ diff --git a/src/chicken_counter/__pycache__/cli.cpython-312.pyc b/src/chicken_counter/__pycache__/cli.cpython-312.pyc new file mode 100644 index 0000000..bfc0a11 Binary files /dev/null and b/src/chicken_counter/__pycache__/cli.cpython-312.pyc differ diff --git a/src/chicken_counter/__pycache__/compress.cpython-312.pyc b/src/chicken_counter/__pycache__/compress.cpython-312.pyc new file mode 100644 index 0000000..d43f2ca Binary files /dev/null and b/src/chicken_counter/__pycache__/compress.cpython-312.pyc differ diff --git a/src/chicken_counter/__pycache__/config.cpython-312.pyc b/src/chicken_counter/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..c370467 Binary files /dev/null and b/src/chicken_counter/__pycache__/config.cpython-312.pyc differ diff --git a/src/chicken_counter/__pycache__/config.cpython-313.pyc b/src/chicken_counter/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..769c338 Binary files /dev/null and b/src/chicken_counter/__pycache__/config.cpython-313.pyc differ diff --git a/src/chicken_counter/__pycache__/counting.cpython-312.pyc b/src/chicken_counter/__pycache__/counting.cpython-312.pyc new file mode 100644 index 0000000..bc745d6 Binary files /dev/null and b/src/chicken_counter/__pycache__/counting.cpython-312.pyc differ diff --git a/src/chicken_counter/__pycache__/motion.cpython-312.pyc b/src/chicken_counter/__pycache__/motion.cpython-312.pyc new file mode 100644 index 0000000..56c8cb0 Binary files /dev/null and b/src/chicken_counter/__pycache__/motion.cpython-312.pyc differ diff --git a/src/chicken_counter/__pycache__/overlay.cpython-312.pyc b/src/chicken_counter/__pycache__/overlay.cpython-312.pyc new file mode 100644 index 0000000..8e3c476 Binary files /dev/null and b/src/chicken_counter/__pycache__/overlay.cpython-312.pyc differ diff --git a/src/chicken_counter/__pycache__/pipeline.cpython-312.pyc b/src/chicken_counter/__pycache__/pipeline.cpython-312.pyc new file mode 100644 index 0000000..3907bba Binary files /dev/null and b/src/chicken_counter/__pycache__/pipeline.cpython-312.pyc differ diff --git a/src/chicken_counter/__pycache__/report.cpython-312.pyc b/src/chicken_counter/__pycache__/report.cpython-312.pyc new file mode 100644 index 0000000..95969f2 Binary files /dev/null and b/src/chicken_counter/__pycache__/report.cpython-312.pyc differ diff --git a/src/chicken_counter/__pycache__/tracking.cpython-312.pyc b/src/chicken_counter/__pycache__/tracking.cpython-312.pyc new file mode 100644 index 0000000..744512f Binary files /dev/null and b/src/chicken_counter/__pycache__/tracking.cpython-312.pyc differ diff --git a/src/chicken_counter/__pycache__/tracking.cpython-313.pyc b/src/chicken_counter/__pycache__/tracking.cpython-313.pyc new file mode 100644 index 0000000..bd1485a Binary files /dev/null and b/src/chicken_counter/__pycache__/tracking.cpython-313.pyc differ diff --git a/src/chicken_counter/__pycache__/types.cpython-312.pyc b/src/chicken_counter/__pycache__/types.cpython-312.pyc new file mode 100644 index 0000000..c5e7e8e Binary files /dev/null and b/src/chicken_counter/__pycache__/types.cpython-312.pyc differ diff --git a/src/chicken_counter/__pycache__/types.cpython-313.pyc b/src/chicken_counter/__pycache__/types.cpython-313.pyc new file mode 100644 index 0000000..b96cb3c Binary files /dev/null and b/src/chicken_counter/__pycache__/types.cpython-313.pyc differ diff --git a/src/chicken_counter/__pycache__/video_writer.cpython-312.pyc b/src/chicken_counter/__pycache__/video_writer.cpython-312.pyc new file mode 100644 index 0000000..caa36d6 Binary files /dev/null and b/src/chicken_counter/__pycache__/video_writer.cpython-312.pyc differ diff --git a/src/chicken_counter/batch_discovery.py b/src/chicken_counter/batch_discovery.py new file mode 100644 index 0000000..e010a06 --- /dev/null +++ b/src/chicken_counter/batch_discovery.py @@ -0,0 +1,58 @@ +"""Locate daily input videos for each camera in a dated Cycle7 folder.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from chicken_counter.config import BatchSettings + + +@dataclass +class CameraDiscoveryResult: + found: dict[str, Path] = field(default_factory=dict) + skipped: dict[str, str] = field(default_factory=dict) + + +def discover_camera_videos(day_dir: Path, settings: BatchSettings) -> CameraDiscoveryResult: + if not day_dir.is_dir(): + raise FileNotFoundError(f"Daily input folder does not exist: {day_dir}") + + result = CameraDiscoveryResult() + total_configured = len(settings.cameras) + + for camera_id, preset in sorted( + settings.cameras.items(), + key=lambda item: item[1].camera_num, + ): + pattern = settings.batch.camera_glob.format(num=preset.camera_num) + matches = sorted(day_dir.glob(pattern)) + if not matches: + result.skipped[camera_id] = "video_not_found" + continue + if len(matches) > 1: + result.skipped[camera_id] = "multiple_matches" + continue + result.found[camera_id] = matches[0] + + if not result.found: + skipped_summary = ", ".join( + f"{camera_id} ({reason})" for camera_id, reason in result.skipped.items() + ) + raise FileNotFoundError( + f"No camera videos found in {day_dir}. Skipped: {skipped_summary}" + ) + + found_count = len(result.found) + if result.skipped: + skipped_summary = ", ".join( + f"{camera_id} ({reason})" for camera_id, reason in result.skipped.items() + ) + print( + f"[batch] discovered {found_count}/{total_configured} cameras; " + f"skipped: {skipped_summary}" + ) + else: + print(f"[batch] discovered {found_count}/{total_configured} cameras") + + return result diff --git a/src/chicken_counter/batch_runner.py b/src/chicken_counter/batch_runner.py new file mode 100644 index 0000000..8453c0e --- /dev/null +++ b/src/chicken_counter/batch_runner.py @@ -0,0 +1,111 @@ +"""Run CC1–CC4 sequentially, then compress videos and write the JSON report.""" + +from __future__ import annotations + +from datetime import date as date_type +from pathlib import Path + +from chicken_counter.batch_discovery import discover_camera_videos +from chicken_counter.compress import compress_video_to_target +from chicken_counter.config import BatchSettings, build_camera_config_from_batch +from chicken_counter.pipeline import run_pipeline +from chicken_counter.report import build_batch_report, persist_batch_reports +from chicken_counter.tracking import DetectionTracker +from chicken_counter.types import CameraBatchResult + + +def run_daily_batch(settings: BatchSettings, date: str | None = None) -> Path: + run_date = date or date_type.today().isoformat() + day_dir = Path(settings.batch.root_dir) / run_date + output_dir = day_dir / settings.batch.output_subdir + output_dir.mkdir(parents=True, exist_ok=True) + + print(f"[batch] starting daily run for {run_date}") + print(f"[batch] input folder: {day_dir}") + print(f"[batch] output folder: {output_dir}") + + discovery = discover_camera_videos(day_dir, settings) + camera_order = sorted(settings.cameras.items(), key=lambda item: item[1].camera_num) + + first_camera_id = next( + camera_id for camera_id, _preset in camera_order if camera_id in discovery.found + ) + first_source = discovery.found[first_camera_id] + init_config = build_camera_config_from_batch( + settings, + first_camera_id, + source=first_source, + output_path=output_dir / f"{first_camera_id}_vis.mp4", + checkpoint_dir=output_dir / "checkpoints" / first_camera_id, + ) + shared_tracker = DetectionTracker(init_config) + + camera_results: list[CameraBatchResult] = [] + report_path = output_dir / f"counts_{run_date}.json" + + for camera_id, _preset in camera_order: + if camera_id in discovery.skipped: + skip_reason = discovery.skipped[camera_id] + print(f"[batch] skipping {camera_id}: {skip_reason}") + camera_results.append( + CameraBatchResult( + camera_id=camera_id, + skipped=True, + skip_reason=skip_reason, + ) + ) + persist_batch_reports(run_date, camera_results, output_dir) + continue + + source_path = discovery.found[camera_id] + vis_path = output_dir / f"{camera_id}_vis.mp4" + checkpoint_dir = output_dir / "checkpoints" / camera_id + + print(f"[batch] processing {camera_id} from {source_path.name}") + camera_config = build_camera_config_from_batch( + settings, + camera_id, + source=source_path, + output_path=vis_path, + checkpoint_dir=checkpoint_dir, + ) + pipeline_result = run_pipeline(camera_config, tracker=shared_tracker) + camera_results.append( + CameraBatchResult( + camera_id=camera_id, + pipeline=pipeline_result, + ) + ) + print( + f"[batch] finished {camera_id}: total_entered={pipeline_result.total_entered_count} " + f"frames={pipeline_result.frames_processed} reason={pipeline_result.stopped_reason}" + ) + persist_batch_reports(run_date, camera_results, output_dir) + + print("[batch] all cameras complete; starting compression") + for item in camera_results: + if item.skipped or item.pipeline is None: + continue + vis_path = item.pipeline.vis_video_path + if not vis_path: + continue + compressed_path = output_dir / f"{item.camera_id}_compressed.mp4" + size_mb = compress_video_to_target( + vis_path, + compressed_path, + max_mb=settings.batch.compress_max_mb, + ) + item.compressed_video_path = str(compressed_path) + item.compressed_size_mb = size_mb + + if settings.batch.delete_intermediate: + Path(vis_path).unlink(missing_ok=True) + + persist_batch_reports(run_date, camera_results, output_dir) + + report = build_batch_report(run_date, camera_results, output_dir=output_dir) + print( + f"[batch] complete for {run_date}: total_entered_sum={report.total_entered_sum} " + f"report={report_path}" + ) + return report_path diff --git a/src/chicken_counter/capture.py b/src/chicken_counter/capture.py new file mode 100644 index 0000000..d20e047 --- /dev/null +++ b/src/chicken_counter/capture.py @@ -0,0 +1,12 @@ +"""Open video files or camera streams for the counting pipeline.""" + +from __future__ import annotations + +import cv2 + + +def open_capture(source: str | int) -> cv2.VideoCapture: + capture = cv2.VideoCapture(source) + if not capture.isOpened(): + raise RuntimeError(f"Unable to open video source: {source}") + return capture diff --git a/src/chicken_counter/cli.py b/src/chicken_counter/cli.py new file mode 100644 index 0000000..08c3911 --- /dev/null +++ b/src/chicken_counter/cli.py @@ -0,0 +1,63 @@ +"""Command-line entrypoint for single-camera runs and daily batch processing.""" + +from __future__ import annotations + +import argparse + +from chicken_counter.batch_runner import run_daily_batch +from chicken_counter.config import load_batch_config, load_camera_config +from chicken_counter.pipeline import run_pipeline + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run the chicken counting pipeline.") + subparsers = parser.add_subparsers(dest="command") + + run_parser = subparsers.add_parser("run", help="Run a single camera pipeline.") + run_parser.add_argument("--config", required=True, help="Path to camera config YAML/JSON.") + run_parser.add_argument("--camera-id", help="Camera ID when using a multi-camera config file.") + + batch_parser = subparsers.add_parser("batch", help="Run the daily Cycle7 multi-camera batch.") + batch_parser.add_argument("--config", required=True, help="Path to batch config YAML/JSON.") + batch_parser.add_argument( + "--date", + help="Processing date folder in YYYY-MM-DD format. Defaults to today.", + ) + + parser.add_argument("--config", help=argparse.SUPPRESS) + parser.add_argument("--camera-id", help=argparse.SUPPRESS) + return parser + + +def main() -> None: + parser = build_parser() + args = parser.parse_args() + + if args.command == "batch": + settings = load_batch_config(args.config) + run_daily_batch(settings, date=args.date) + return + + if args.command == "run": + config = load_camera_config(args.config, args.camera_id) + result = run_pipeline(config) + print( + f"[done] camera={result.camera_id} total_entered={result.total_entered_count} " + f"frames={result.frames_processed} reason={result.stopped_reason}" + ) + return + + if args.config: + config = load_camera_config(args.config, args.camera_id) + result = run_pipeline(config) + print( + f"[done] camera={result.camera_id} total_entered={result.total_entered_count} " + f"frames={result.frames_processed} reason={result.stopped_reason}" + ) + return + + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/src/chicken_counter/compress.py b/src/chicken_counter/compress.py new file mode 100644 index 0000000..59df721 --- /dev/null +++ b/src/chicken_counter/compress.py @@ -0,0 +1,103 @@ +"""Compress annotated videos with ffmpeg to stay under a target file size.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import cv2 + + +def _video_duration_seconds(path: Path) -> float: + capture = cv2.VideoCapture(str(path)) + if not capture.isOpened(): + raise RuntimeError(f"Unable to open video for duration probe: {path}") + + frame_count = capture.get(cv2.CAP_PROP_FRAME_COUNT) + fps = capture.get(cv2.CAP_PROP_FPS) + capture.release() + + if fps and fps > 0 and frame_count and frame_count > 0: + return float(frame_count / fps) + raise RuntimeError(f"Unable to determine duration for video: {path}") + + +def _file_size_mb(path: Path) -> float: + return path.stat().st_size / (1024 * 1024) + + +def _run_ffmpeg(command: list[str]) -> None: + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode != 0: + stderr = result.stderr.strip() or result.stdout.strip() + raise RuntimeError(f"ffmpeg failed: {stderr}") + + +def compress_video_to_target( + input_path: str | Path, + output_path: str | Path, + *, + max_mb: int = 200, + max_attempts: int = 3, +) -> float: + input_file = Path(input_path) + output_file = Path(output_path) + if not input_file.is_file(): + raise FileNotFoundError(f"Input video not found: {input_file}") + + output_file.parent.mkdir(parents=True, exist_ok=True) + duration = _video_duration_seconds(input_file) + if duration <= 0: + raise RuntimeError(f"Invalid video duration for {input_file}") + + target_kbps = int((max_mb * 8192) / duration * 0.92) + target_kbps = max(300, target_kbps) + + for attempt in range(max_attempts): + attempt_kbps = max(300, int(target_kbps * (0.85**attempt))) + if output_file.exists(): + output_file.unlink() + + codec_attempts = [ + ["-c:v", "h264_nvmpi", "-b:v", f"{attempt_kbps}k", "-maxrate", f"{attempt_kbps}k", "-bufsize", f"{attempt_kbps * 2}k"], + ["-c:v", "libx264", "-preset", "fast", "-b:v", f"{attempt_kbps}k", "-maxrate", f"{attempt_kbps}k", "-bufsize", f"{attempt_kbps * 2}k"], + ] + + last_error: Exception | None = None + for codec_args in codec_attempts: + command = [ + "ffmpeg", + "-y", + "-i", + str(input_file), + *codec_args, + "-c:a", + "copy", + str(output_file), + ] + try: + _run_ffmpeg(command) + break + except RuntimeError as exc: + last_error = exc + if output_file.exists(): + output_file.unlink() + else: + if last_error is not None: + raise last_error + raise RuntimeError(f"Unable to compress video: {input_file}") + + size_mb = _file_size_mb(output_file) + print( + f"[compress] {output_file.name}: {size_mb:.1f} MB " + f"(attempt {attempt + 1}, target {attempt_kbps} kbps)" + ) + if size_mb <= max_mb: + return size_mb + + final_size = _file_size_mb(output_file) + if final_size > max_mb: + raise RuntimeError( + f"Compressed video still exceeds {max_mb} MB: {output_file} ({final_size:.1f} MB)" + ) + return final_size diff --git a/src/chicken_counter/config.py b/src/chicken_counter/config.py new file mode 100644 index 0000000..286a841 --- /dev/null +++ b/src/chicken_counter/config.py @@ -0,0 +1,391 @@ +"""Load YAML/JSON settings for single-camera runs and daily batch jobs.""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass, field +import json +from pathlib import Path +from typing import Any + +import yaml + + +Point = tuple[int, int] + + +@dataclass +class DetectionConfig: + model_path: str + classes: list[int] = field(default_factory=lambda: [0]) + ignored_classes: list[int] = field(default_factory=lambda: [1, 2]) + conf: float = 0.35 + iou: float = 0.55 + imgsz: int = 640 + device: str | int | None = None + min_box_area_px: int = 0 + validate_while_inside: bool = True + + +@dataclass +class DetectionZoneConfig: + enabled: bool = False + buffer_above_px: int = 250 + buffer_below_px: int = 250 + show_in_overlay: bool = False + + def compute_rect( + self, + roi: RoiConfig, + frame_width: int, + frame_height: int, + ) -> tuple[int, int, int, int]: + x_values = [point[0] for point in roi.points] + y_values = [point[1] for point in roi.points] + x1 = max(0, min(x_values)) + x2 = min(frame_width, max(x_values)) + y1 = max(0, min(y_values) - self.buffer_above_px) + y2 = min(frame_height, max(y_values) + self.buffer_below_px) + return x1, y1, x2, y2 + + +@dataclass +class TrackerConfig: + tracker_config_path: str + persist: bool = True + track_buffer: int = 75 + + +@dataclass +class RoiConfig: + points: list[Point] + inset_left_px: int = 0 + inset_right_px: int = 0 + inset_top_px: int = 0 + inset_bottom_px: int = 0 + min_overlap_ratio: float = 0.0 + + @property + def is_polygon(self) -> bool: + return len(self.points) > 2 + + def bounding_rect(self) -> tuple[int, int, int, int]: + x_values = [point[0] for point in self.points] + y_values = [point[1] for point in self.points] + return min(x_values), min(y_values), max(x_values), max(y_values) + + def counting_polygon(self) -> list[Point]: + x_min, y_min, x_max, y_max = self.bounding_rect() + x_min += self.inset_left_px + x_max -= self.inset_right_px + y_min += self.inset_top_px + y_max -= self.inset_bottom_px + + min_width = 20 + min_height = 20 + if x_max - x_min < min_width: + center_x = (x_min + x_max) // 2 + half = min_width // 2 + x_min = center_x - half + x_max = center_x + half + if y_max - y_min < min_height: + center_y = (y_min + y_max) // 2 + half = min_height // 2 + y_min = center_y - half + y_max = center_y + half + + return [ + (x_min, y_min), + (x_max, y_min), + (x_max, y_max), + (x_min, y_max), + ] + + def counting_rect(self) -> tuple[int, int, int, int]: + polygon = self.counting_polygon() + x_values = [point[0] for point in polygon] + y_values = [point[1] for point in polygon] + return min(x_values), min(y_values), max(x_values), max(y_values) + + +@dataclass +class GateConfig: + mode: str = "two_line" + lines_y: list[int] = field(default_factory=lambda: [320, 600]) + direction: str = "bottom_to_up" + + +@dataclass +class MotionConfig: + enabled: bool = True + axis: str = "vertical" + forward_sign: float = 1.0 + ema_alpha: float = 0.2 + reverse_enter_threshold: float = -1.5 + reverse_exit_threshold: float = -0.5 + debounce_frames: int = 12 + min_features: int = 60 + max_corners: int = 300 + quality_level: float = 0.01 + min_distance: int = 8 + block_radius: int = 6 + stride_frames: int = 1 + flow_scale: float = 1.0 + + +Color = tuple[int, int, int] + + +@dataclass +class OverlayConfig: + show_boxes: bool = True + show_track_trails: bool = True + trail_length: int = 20 + show_center_marker: bool = True + show_track_ring: bool = False + count_anchor: Point = (900, 120) + inside_box_only: bool = True + pending_blink: bool = True + pending_colors: list[Color] = field( + default_factory=lambda: [(255, 255, 0), (0, 255, 255)] + ) + + +@dataclass +class DisplayConfig: + window_name: str = "Chicken Counter" + show_window: bool = True + output_path: str | None = None + write_fps: float | None = None + max_frames: int | None = None + encoder: str = "auto" + output_bitrate_kbps: int = 4000 + codec_preference: list[str] = field(default_factory=lambda: ["avc1", "mp4v", "H264"]) + + +@dataclass +class PerformanceConfig: + half: bool = False + overlay_buffer_reuse: bool = True + inference_stride: int = 1 + + +@dataclass +class FeedbackConfig: + enabled: bool = False + every_n_frames: int = 300 + save_images: bool = True + image_output_dir: str = "output/checkpoints" + log_to_terminal: bool = True + + +@dataclass +class CameraConfig: + camera_id: str + source: str | int + detection: DetectionConfig + tracker: TrackerConfig + roi: RoiConfig + gate: GateConfig + motion: MotionConfig + overlay: OverlayConfig + display: DisplayConfig + performance: PerformanceConfig + feedback: FeedbackConfig + detection_zone: DetectionZoneConfig = field(default_factory=DetectionZoneConfig) + + +@dataclass +class BatchConfig: + root_dir: str + camera_glob: str = "kandang_*_camera_{num}_*.mp4" + output_subdir: str = "output" + compress_max_mb: int = 200 + delete_intermediate: bool = False + checkpoint_every_n_frames: int = 3000 + + +@dataclass +class CameraPreset: + camera_id: str + camera_num: int + roi: RoiConfig + count_anchor: Point | None = None + gate: GateConfig | None = None + motion: MotionConfig | None = None + + +@dataclass +class BatchSettings: + batch: BatchConfig + defaults: dict[str, Any] + cameras: dict[str, CameraPreset] + + +def _load_data(path: Path) -> dict[str, Any]: + if path.suffix.lower() == ".json": + return json.loads(path.read_text(encoding="utf-8")) + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def _point_list(raw_points: list[list[int]] | list[tuple[int, int]]) -> list[Point]: + return [tuple(map(int, point)) for point in raw_points] + + +def _build_overlay_config(overlay_raw: dict[str, Any]) -> OverlayConfig: + overlay_kwargs = { + **overlay_raw, + "count_anchor": tuple(overlay_raw["count_anchor"]), + } + if "pending_colors" in overlay_raw: + overlay_kwargs["pending_colors"] = [ + tuple(map(int, color)) for color in overlay_raw["pending_colors"] + ] + return OverlayConfig(**overlay_kwargs) + + +def _build_roi_config(roi_raw: dict[str, Any]) -> RoiConfig: + return RoiConfig( + points=_point_list(roi_raw["points"]), + inset_left_px=int(roi_raw.get("inset_left_px", 0)), + inset_right_px=int(roi_raw.get("inset_right_px", 0)), + inset_top_px=int(roi_raw.get("inset_top_px", 0)), + inset_bottom_px=int(roi_raw.get("inset_bottom_px", 0)), + min_overlap_ratio=float(roi_raw.get("min_overlap_ratio", 0.0)), + ) + + +def _build_camera_config(raw: dict[str, Any]) -> CameraConfig: + return CameraConfig( + camera_id=raw["camera_id"], + source=raw["source"], + detection=DetectionConfig(**raw["detection"]), + tracker=TrackerConfig(**raw["tracker"]), + roi=_build_roi_config(raw["roi"]), + gate=GateConfig(**raw["gate"]), + motion=MotionConfig(**raw["motion"]), + overlay=_build_overlay_config(raw["overlay"]), + display=DisplayConfig(**raw["display"]), + performance=PerformanceConfig(**raw.get("performance", {})), + feedback=FeedbackConfig(**raw.get("feedback", {})), + detection_zone=DetectionZoneConfig(**raw.get("detection_zone", {})), + ) + + +def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + merged = copy.deepcopy(base) + for key, value in override.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = _deep_merge(merged[key], value) + else: + merged[key] = copy.deepcopy(value) + return merged + + +def load_camera_config(path: str | Path, camera_id: str | None = None) -> CameraConfig: + config_path = Path(path) + raw = _load_data(config_path) + + if "batch" in raw: + raise ValueError( + "This is a batch config file. Use 'chicken-counter batch --config ...' instead." + ) + + if "cameras" in raw and "defaults" not in raw: + if not camera_id: + raise ValueError("camera_id is required when config contains multiple cameras") + raw = raw["cameras"][camera_id] + + return _build_camera_config(raw) + + +def load_batch_config(path: str | Path) -> BatchSettings: + config_path = Path(path) + raw = _load_data(config_path) + + if "batch" not in raw: + raise ValueError("Batch config must contain a top-level 'batch' section") + + batch = BatchConfig(**raw["batch"]) + defaults = raw.get("defaults", {}) + cameras: dict[str, CameraPreset] = {} + + for camera_id, camera_raw in raw.get("cameras", {}).items(): + roi_points = _point_list(camera_raw["roi"]["points"]) + count_anchor = None + if "count_anchor" in camera_raw: + count_anchor = tuple(camera_raw["count_anchor"]) + elif "overlay" in camera_raw and "count_anchor" in camera_raw["overlay"]: + count_anchor = tuple(camera_raw["overlay"]["count_anchor"]) + + gate = GateConfig(**camera_raw["gate"]) if "gate" in camera_raw else None + motion = MotionConfig(**camera_raw["motion"]) if "motion" in camera_raw else None + + cameras[camera_id] = CameraPreset( + camera_id=camera_id, + camera_num=int(camera_raw["camera_num"]), + roi=RoiConfig(points=roi_points), + count_anchor=count_anchor, + gate=gate, + motion=motion, + ) + + return BatchSettings(batch=batch, defaults=defaults, cameras=cameras) + + +def build_camera_config_from_batch( + settings: BatchSettings, + camera_id: str, + *, + source: str | Path, + output_path: str | Path, + checkpoint_dir: str | Path, +) -> CameraConfig: + if camera_id not in settings.cameras: + raise KeyError(f"Unknown camera_id in batch config: {camera_id}") + + preset = settings.cameras[camera_id] + raw = _deep_merge(settings.defaults, {"camera_id": camera_id, "source": str(source)}) + + raw.setdefault("roi", {}) + raw["roi"]["points"] = [list(point) for point in preset.roi.points] + if preset.gate is not None: + raw["gate"] = { + "mode": preset.gate.mode, + "lines_y": preset.gate.lines_y, + "direction": preset.gate.direction, + } + if preset.motion is not None: + raw["motion"] = { + "enabled": preset.motion.enabled, + "axis": preset.motion.axis, + "forward_sign": preset.motion.forward_sign, + "ema_alpha": preset.motion.ema_alpha, + "reverse_enter_threshold": preset.motion.reverse_enter_threshold, + "reverse_exit_threshold": preset.motion.reverse_exit_threshold, + "debounce_frames": preset.motion.debounce_frames, + "min_features": preset.motion.min_features, + "max_corners": preset.motion.max_corners, + "quality_level": preset.motion.quality_level, + "min_distance": preset.motion.min_distance, + "block_radius": preset.motion.block_radius, + "stride_frames": preset.motion.stride_frames, + "flow_scale": preset.motion.flow_scale, + } + + if preset.count_anchor is not None: + raw.setdefault("overlay", {}) + raw["overlay"]["count_anchor"] = list(preset.count_anchor) + + raw.setdefault("display", {}) + raw["display"]["output_path"] = str(output_path) + raw["display"]["show_window"] = False + + raw.setdefault("feedback", {}) + raw["feedback"]["enabled"] = True + raw["feedback"]["every_n_frames"] = settings.batch.checkpoint_every_n_frames + raw["feedback"]["save_images"] = True + raw["feedback"]["image_output_dir"] = str(checkpoint_dir) + raw["feedback"]["log_to_terminal"] = True + + return _build_camera_config(raw) diff --git a/src/chicken_counter/counting.py b/src/chicken_counter/counting.py new file mode 100644 index 0000000..5a7f530 --- /dev/null +++ b/src/chicken_counter/counting.py @@ -0,0 +1,151 @@ +"""Count chickens entering the ROI and assign visible sequence IDs.""" + +from __future__ import annotations + +from collections import defaultdict, deque + +import cv2 +import numpy as np + +from chicken_counter.config import GateConfig, RoiConfig +from chicken_counter.types import CountEvent, TrackObservation + + +class CountingZone: + def __init__( + self, + roi: RoiConfig, + gate: GateConfig, + trail_length: int, + track_buffer: int, + min_box_area_px: int = 0, + validate_while_inside: bool = True, + ) -> None: + self.roi = roi + self.gate = gate + self.trail_length = trail_length + self.track_buffer = track_buffer + self.min_box_area_px = min_box_area_px + self.min_overlap_ratio = roi.min_overlap_ratio + self.validate_while_inside = validate_while_inside + self.inside_box_count = 0 + self.total_entered_count = 0 + self.histories: dict[int, deque[tuple[int, int]]] = defaultdict(lambda: deque(maxlen=trail_length)) + self.last_seen_frame: dict[int, int] = {} + self.counted_ids: set[int] = set() + self.prev_inside_ids: set[int] = set() + self.current_inside_ids: set[int] = set() + self.sequence_numbers_by_track_id: dict[int, int] = {} + self.latest_validated_track_id: int | None = None + self._counting_polygon = np.array(roi.counting_polygon(), dtype=np.int32) + self._counting_rect = roi.counting_rect() + + def update( + self, + tracks: list[TrackObservation], + frame_index: int, + *, + counting_paused: bool = False, + ) -> list[CountEvent]: + events: list[CountEvent] = [] + active_ids = set() + inside_ids = set() + for track in tracks: + active_ids.add(track.track_id) + self.last_seen_frame[track.track_id] = frame_index + self.histories[track.track_id].append(track.centroid) + + if self._inside_roi(track.centroid): + inside_ids.add(track.track_id) + + if counting_paused: + continue + + if track.track_id not in inside_ids or track.track_id in self.counted_ids: + continue + + should_validate = False + if self.validate_while_inside: + should_validate = self._meets_validation_thresholds(track) + else: + just_entered_box = ( + track.track_id in inside_ids and track.track_id not in self.prev_inside_ids + ) + should_validate = just_entered_box and self._meets_validation_thresholds(track) + + if should_validate: + self.counted_ids.add(track.track_id) + self.total_entered_count += 1 + self.sequence_numbers_by_track_id[track.track_id] = self.total_entered_count + self.latest_validated_track_id = track.track_id + events.append( + CountEvent( + track_id=track.track_id, + frame_index=frame_index, + total_entered_after_event=self.total_entered_count, + sequence_number=self.sequence_numbers_by_track_id[track.track_id], + ) + ) + + self.inside_box_count = len(inside_ids) + self.current_inside_ids = inside_ids + self.prev_inside_ids = inside_ids + self._purge_stale(frame_index, active_ids) + return events + + def trail_for(self, track_id: int) -> list[tuple[int, int]]: + return list(self.histories.get(track_id, ())) + + def sequence_number_for(self, track_id: int) -> int | None: + return self.sequence_numbers_by_track_id.get(track_id) + + def is_inside(self, track_id: int) -> bool: + return track_id in self.current_inside_ids + + def is_validated(self, track_id: int) -> bool: + return track_id in self.counted_ids + + def _inside_roi(self, point: tuple[int, int]) -> bool: + return cv2.pointPolygonTest(self._counting_polygon, point, False) > 0 + + def _meets_size_threshold(self, track: TrackObservation) -> bool: + x1, y1, x2, y2 = track.bbox_xyxy + area = max(0, x2 - x1) * max(0, y2 - y1) + return area >= self.min_box_area_px + + def _bbox_overlap_ratio(self, track: TrackObservation) -> float: + x1, y1, x2, y2 = track.bbox_xyxy + bbox_area = max(0, x2 - x1) * max(0, y2 - y1) + if bbox_area <= 0: + return 0.0 + + rx1, ry1, rx2, ry2 = self._counting_rect + ix1 = max(x1, rx1) + iy1 = max(y1, ry1) + ix2 = min(x2, rx2) + iy2 = min(y2, ry2) + if ix2 <= ix1 or iy2 <= iy1: + return 0.0 + + intersection_area = (ix2 - ix1) * (iy2 - iy1) + return intersection_area / bbox_area + + def _meets_overlap_threshold(self, track: TrackObservation) -> bool: + if self.min_overlap_ratio <= 0: + return True + return self._bbox_overlap_ratio(track) >= self.min_overlap_ratio + + def _meets_validation_thresholds(self, track: TrackObservation) -> bool: + return self._meets_size_threshold(track) and self._meets_overlap_threshold(track) + + def _purge_stale(self, frame_index: int, active_ids: set[int]) -> None: + stale_ids = [ + track_id + for track_id, last_seen in self.last_seen_frame.items() + if track_id not in active_ids and frame_index - last_seen > self.track_buffer + ] + for track_id in stale_ids: + self.last_seen_frame.pop(track_id, None) + self.histories.pop(track_id, None) + self.prev_inside_ids.discard(track_id) + self.current_inside_ids.discard(track_id) diff --git a/src/chicken_counter/motion.py b/src/chicken_counter/motion.py new file mode 100644 index 0000000..a4ee1a3 --- /dev/null +++ b/src/chicken_counter/motion.py @@ -0,0 +1,99 @@ +"""Detect backward cart motion using sparse optical flow on the background.""" + +from __future__ import annotations + +import cv2 +import numpy as np + +from chicken_counter.config import MotionConfig, RoiConfig +from chicken_counter.types import MotionState, TrackObservation + + +class BackwardMotionDetector: + def __init__(self, config: MotionConfig, roi: RoiConfig) -> None: + self.config = config + self.roi = roi + self.previous_gray: np.ndarray | None = None + self.state = MotionState() + self._roi_bounds = self._compute_roi_bounds() + + def _compute_roi_bounds(self) -> tuple[int, int, int, int]: + x_values = [point[0] for point in self.roi.points] + y_values = [point[1] for point in self.roi.points] + return min(x_values), min(y_values), max(x_values), max(y_values) + + def update( + self, + frame: np.ndarray, + tracks: list[TrackObservation], + frame_index: int, + ) -> MotionState: + if not self.config.enabled: + return self.state + + stride = max(1, self.config.stride_frames) + if frame_index % stride != 0: + return self.state + + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + x_min, y_min, x_max, y_max = self._roi_bounds + gray = gray[y_min:y_max, x_min:x_max] + + scale = self.config.flow_scale + if scale < 1.0: + target_width = max(1, int(gray.shape[1] * scale)) + target_height = max(1, int(gray.shape[0] * scale)) + gray = cv2.resize(gray, (target_width, target_height), interpolation=cv2.INTER_AREA) + else: + scale = 1.0 + + mask = np.full(gray.shape, 255, dtype=np.uint8) + for track in tracks: + x1, y1, x2, y2 = track.bbox_xyxy + r = self.config.block_radius + local_x1 = int((max(0, x1 - r) - x_min) * scale) + local_y1 = int((max(0, y1 - r) - y_min) * scale) + local_x2 = int((min(x_max, x2 + r) - x_min) * scale) + local_y2 = int((min(y_max, y2 + r) - y_min) * scale) + if local_x2 <= local_x1 or local_y2 <= local_y1: + continue + cv2.rectangle(mask, (local_x1, local_y1), (local_x2, local_y2), 0, -1) + + points = cv2.goodFeaturesToTrack( + gray, + maxCorners=self.config.max_corners, + qualityLevel=self.config.quality_level, + minDistance=self.config.min_distance, + mask=mask, + ) + + if self.previous_gray is None or points is None or len(points) < self.config.min_features: + self.previous_gray = gray + return self.state + + next_points, status, _ = cv2.calcOpticalFlowPyrLK(self.previous_gray, gray, points, None) + self.previous_gray = gray + if next_points is None or status is None: + return self.state + + valid_prev = points[status.flatten() == 1] + valid_next = next_points[status.flatten() == 1] + if len(valid_prev) < self.config.min_features: + return self.state + + flow = valid_next - valid_prev + axis_values = flow[:, 0, 1] if self.config.axis == "vertical" else flow[:, 0, 0] + median_axis_speed = float(np.median(axis_values)) * self.config.forward_sign + alpha = self.config.ema_alpha + self.state.smoothed_speed = alpha * median_axis_speed + (1.0 - alpha) * self.state.smoothed_speed + + if self.state.smoothed_speed <= self.config.reverse_enter_threshold: + self.state.consecutive_reverse_frames += 1 + elif self.state.smoothed_speed > self.config.reverse_exit_threshold: + self.state.consecutive_reverse_frames = 0 + self.state.backward_active = False + + if self.state.consecutive_reverse_frames >= self.config.debounce_frames: + self.state.backward_active = True + + return self.state diff --git a/src/chicken_counter/overlay.py b/src/chicken_counter/overlay.py new file mode 100644 index 0000000..f40bd2f --- /dev/null +++ b/src/chicken_counter/overlay.py @@ -0,0 +1,189 @@ +"""Render annotated frames with boxes, counting guides, and totals.""" + +from __future__ import annotations + +from collections.abc import Iterable + +import cv2 +import numpy as np + +from chicken_counter.config import CameraConfig +from chicken_counter.counting import CountingZone +from chicken_counter.types import MotionState, TrackObservation + + +WHITE = (255, 255, 255) +BLACK = (0, 0, 0) +CYAN = (255, 255, 0) +RED = (0, 0, 255) +YELLOW = (0, 255, 255) +ORANGE = (0, 165, 255) +BLUE = (255, 120, 0) +LIME = (80, 220, 80) +GRAY = (160, 160, 160) + + +def draw_overlay( + frame: np.ndarray, + config: CameraConfig, + counting_zone: CountingZone, + tracks: Iterable[TrackObservation], + motion_state: MotionState, + frame_index: int = 0, + buffer: np.ndarray | None = None, +) -> np.ndarray: + if buffer is not None: + np.copyto(buffer, frame) + annotated = buffer + else: + annotated = frame.copy() + + if config.detection_zone.enabled and config.detection_zone.show_in_overlay: + _draw_detection_zone(annotated, config) + + _draw_roi_and_gates(annotated, config) + + blink_on = (frame_index // 8) % 2 == 0 + pending_colors = config.overlay.pending_colors or [CYAN, YELLOW] + + for track in tracks: + inside_box = counting_zone.is_inside(track.track_id) + if config.overlay.inside_box_only and not inside_box: + continue + + validated = counting_zone.is_validated(track.track_id) + x1, y1, x2, y2 = track.bbox_xyxy + cx, cy = track.centroid + sequence_number = counting_zone.sequence_number_for(track.track_id) + + if config.overlay.show_boxes: + if validated: + box_color = ORANGE + elif config.overlay.pending_blink: + box_color = pending_colors[0 if blink_on else 1 % len(pending_colors)] + else: + box_color = pending_colors[0] + cv2.rectangle(annotated, (x1, y1), (x2, y2), box_color, 2) + + if validated and sequence_number is not None: + _draw_outlined_text( + annotated, + str(sequence_number), + (x1, max(24, y1 - 8)), + font_scale=0.8, + fill_color=LIME, + outline_color=BLACK, + thickness=2, + outline_thickness=4, + ) + + if config.overlay.show_center_marker: + marker_color = ORANGE if validated else (pending_colors[0 if blink_on else 1 % len(pending_colors)]) + cv2.circle(annotated, (cx, cy), 4, marker_color, -1) + if config.overlay.show_track_ring: + radius = max(20, int(max(x2 - x1, y2 - y1) * 0.6)) + cv2.circle(annotated, (cx, cy), radius, WHITE, 1) + + if config.overlay.show_track_trails: + trail = counting_zone.trail_for(track.track_id) + _draw_trail(annotated, trail) + + count_x, count_y = config.overlay.count_anchor + _draw_outlined_text( + annotated, + f"TOTAL ENTERED: {counting_zone.total_entered_count}", + (count_x, count_y), + font_scale=1.35, + fill_color=BLUE, + outline_color=BLACK, + thickness=4, + outline_thickness=6, + ) + + motion_label = "BACKWARD STOP" if motion_state.backward_active else "FORWARD" + motion_color = RED if motion_state.backward_active else YELLOW + cv2.putText( + annotated, + motion_label, + (count_x, count_y + 42), + cv2.FONT_HERSHEY_SIMPLEX, + 0.8, + motion_color, + 2, + cv2.LINE_AA, + ) + + return annotated + + +def _draw_outlined_text( + frame: np.ndarray, + text: str, + origin: tuple[int, int], + *, + font_scale: float, + fill_color: tuple[int, int, int], + outline_color: tuple[int, int, int], + thickness: int, + outline_thickness: int, +) -> None: + font = cv2.FONT_HERSHEY_SIMPLEX + cv2.putText( + frame, + text, + origin, + font, + font_scale, + outline_color, + outline_thickness, + cv2.LINE_AA, + ) + cv2.putText( + frame, + text, + origin, + font, + font_scale, + fill_color, + thickness, + cv2.LINE_AA, + ) + + +def _draw_roi_and_gates(frame: np.ndarray, config: CameraConfig) -> None: + points = np.array(config.roi.counting_polygon(), dtype=np.int32) + cv2.polylines(frame, [points], isClosed=True, color=BLUE, thickness=3) + + +def _draw_detection_zone(frame: np.ndarray, config: CameraConfig) -> None: + height, width = frame.shape[:2] + x1, y1, x2, y2 = config.detection_zone.compute_rect(config.roi, width, height) + _draw_dashed_rectangle(frame, (x1, y1), (x2, y2), GRAY, thickness=1) + + +def _draw_dashed_rectangle( + frame: np.ndarray, + pt1: tuple[int, int], + pt2: tuple[int, int], + color: tuple[int, int, int], + *, + thickness: int = 1, + dash_length: int = 12, +) -> None: + x1, y1 = pt1 + x2, y2 = pt2 + for x_start in range(x1, x2, dash_length * 2): + cv2.line(frame, (x_start, y1), (min(x_start + dash_length, x2), y1), color, thickness) + for x_start in range(x1, x2, dash_length * 2): + cv2.line(frame, (x_start, y2), (min(x_start + dash_length, x2), y2), color, thickness) + for y_start in range(y1, y2, dash_length * 2): + cv2.line(frame, (x1, y_start), (x1, min(y_start + dash_length, y2)), color, thickness) + for y_start in range(y1, y2, dash_length * 2): + cv2.line(frame, (x2, y_start), (x2, min(y_start + dash_length, y2)), color, thickness) + + +def _draw_trail(frame: np.ndarray, trail: list[tuple[int, int]]) -> None: + if len(trail) < 2: + return + for start, end in zip(trail[:-1], trail[1:]): + cv2.line(frame, start, end, YELLOW, 2) diff --git a/src/chicken_counter/pipeline.py b/src/chicken_counter/pipeline.py new file mode 100644 index 0000000..6633cfe --- /dev/null +++ b/src/chicken_counter/pipeline.py @@ -0,0 +1,269 @@ +"""Run the main per-frame counting loop and connect all pipeline stages.""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from pathlib import Path + +import cv2 +import numpy as np + +from chicken_counter.capture import open_capture +from chicken_counter.config import CameraConfig +from chicken_counter.counting import CountingZone +from chicken_counter.motion import BackwardMotionDetector +from chicken_counter.overlay import draw_overlay +from chicken_counter.tracking import DetectionTracker +from chicken_counter.types import FrameResult, PipelineResult, TrackObservation +from chicken_counter.video_writer import make_video_writer + + +@dataclass +class PipelineArtifacts: + capture: cv2.VideoCapture + tracker: DetectionTracker + counting_zone: CountingZone + motion_detector: BackwardMotionDetector + writer: cv2.VideoWriter | None + overlay_buffer: np.ndarray | None + run_start_time: float + total_source_frames: int | None + owns_tracker: bool + detection_zone_rect: tuple[int, int, int, int] | None = None + + +def build_pipeline( + config: CameraConfig, + tracker: DetectionTracker | None = None, +) -> PipelineArtifacts: + capture = open_capture(config.source) + owns_tracker = tracker is None + if tracker is None: + tracker = DetectionTracker(config) + counting_zone = CountingZone( + roi=config.roi, + gate=config.gate, + trail_length=config.overlay.trail_length, + track_buffer=config.tracker.track_buffer, + min_box_area_px=config.detection.min_box_area_px, + validate_while_inside=config.detection.validate_while_inside, + ) + motion_detector = BackwardMotionDetector(config.motion, config.roi) + + width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT)) + frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT)) + total_source_frames = frame_count if frame_count > 0 else None + + detection_zone_rect = None + if config.detection_zone.enabled and width > 0 and height > 0: + detection_zone_rect = config.detection_zone.compute_rect(config.roi, width, height) + print( + f"[detection_zone] enabled crop=({detection_zone_rect[0]}, {detection_zone_rect[1]})" + f"-({detection_zone_rect[2]}, {detection_zone_rect[3]})" + ) + + overlay_buffer = None + if config.performance.overlay_buffer_reuse and width > 0 and height > 0: + overlay_buffer = np.empty((height, width, 3), dtype=np.uint8) + + writer = None + if config.display.output_path: + fps = config.display.write_fps or capture.get(cv2.CAP_PROP_FPS) or 30.0 + writer = make_video_writer( + config.display.output_path, + (width, height), + fps, + encoder=config.display.encoder, + output_bitrate_kbps=config.display.output_bitrate_kbps, + codec_preference=config.display.codec_preference, + ) + + return PipelineArtifacts( + capture=capture, + tracker=tracker, + counting_zone=counting_zone, + motion_detector=motion_detector, + writer=writer, + overlay_buffer=overlay_buffer, + run_start_time=time.monotonic(), + total_source_frames=total_source_frames, + owns_tracker=owns_tracker, + detection_zone_rect=detection_zone_rect, + ) + + +def run_pipeline( + config: CameraConfig, + tracker: DetectionTracker | None = None, +) -> PipelineResult: + if tracker is not None: + tracker.config = config + tracker.reset_tracking() + + artifacts = build_pipeline(config, tracker=tracker) + inference_stride = max(1, config.performance.inference_stride) + print( + f"[perf] inference_stride={inference_stride} " + f"motion.stride_frames={max(1, config.motion.stride_frames)} " + f"motion.flow_scale={config.motion.flow_scale}" + ) + frame_index = 0 + last_annotated = None + last_tracks: list[TrackObservation] = [] + stopped_reason = "eof" + user_quit = False + + try: + while True: + ok, frame = artifacts.capture.read() + if not ok: + break + + frame_index += 1 + if frame_index % inference_stride == 0 or not last_tracks: + last_tracks = artifacts.tracker.infer( + frame, + crop_rect=artifacts.detection_zone_rect, + ) + tracks = last_tracks + motion_state = artifacts.motion_detector.update(frame, tracks, frame_index) + count_events = artifacts.counting_zone.update( + tracks, + frame_index, + counting_paused=motion_state.backward_active, + ) + annotated = draw_overlay( + frame, + config, + artifacts.counting_zone, + tracks, + motion_state, + frame_index=frame_index, + buffer=artifacts.overlay_buffer, + ) + + result = FrameResult( + frame_index=frame_index, + tracks=tracks, + inside_box_count=artifacts.counting_zone.inside_box_count, + total_entered_count=artifacts.counting_zone.total_entered_count, + motion_state=motion_state, + count_events=count_events, + ) + _consume_result(config, artifacts, annotated, result) + last_annotated = annotated + + if motion_state.backward_active: + stopped_reason = "backward" + print(f"[stop] backward detection confirmed at frame={frame_index}; ending pipeline") + break + + if config.display.max_frames and frame_index >= config.display.max_frames: + stopped_reason = "max_frames" + break + if config.display.show_window and cv2.waitKey(1) & 0xFF == ord("q"): + stopped_reason = "user_quit" + user_quit = True + break + + if artifacts.writer is not None and last_annotated is not None: + freeze_frame_count = int((config.display.write_fps or 30.0) * 2) + for _ in range(max(1, freeze_frame_count)): + artifacts.writer.write(last_annotated) + finally: + artifacts.capture.release() + if artifacts.writer is not None: + artifacts.writer.release() + if config.display.show_window: + cv2.destroyAllWindows() + + elapsed_seconds = time.monotonic() - artifacts.run_start_time + if user_quit: + stopped_reason = "user_quit" + + return PipelineResult( + camera_id=config.camera_id, + total_entered_count=artifacts.counting_zone.total_entered_count, + frames_processed=frame_index, + stopped_reason=stopped_reason, + vis_video_path=config.display.output_path, + source_video=str(config.source), + elapsed_seconds=elapsed_seconds, + ) + + +def _consume_result( + config: CameraConfig, + artifacts: PipelineArtifacts, + annotated, + result: FrameResult, +) -> None: + if config.display.show_window: + cv2.imshow(config.display.window_name, annotated) + if artifacts.writer is not None: + artifacts.writer.write(annotated) + + for event in result.count_events: + print( + f"[frame {event.frame_index}] counted track={event.track_id} " + f"inside_box={result.inside_box_count} total_entered={event.total_entered_after_event}" + ) + + if _should_emit_feedback(config, result.frame_index): + _emit_periodic_feedback(config, artifacts, annotated, result) + + +def _should_emit_feedback(config: CameraConfig, frame_index: int) -> bool: + if not config.feedback.enabled: + return False + if config.feedback.every_n_frames <= 0: + return False + return frame_index % config.feedback.every_n_frames == 0 + + +def _format_duration(seconds: float) -> str: + if seconds < 60: + return f"{seconds:.0f}s" + minutes, secs = divmod(int(seconds), 60) + if minutes < 60: + return f"{minutes}m{secs:02d}s" + hours, minutes = divmod(minutes, 60) + return f"{hours}h{minutes:02d}m" + + +def _emit_periodic_feedback( + config: CameraConfig, + artifacts: PipelineArtifacts, + annotated, + result: FrameResult, +) -> None: + if config.feedback.log_to_terminal: + elapsed = time.monotonic() - artifacts.run_start_time + fps = result.frame_index / elapsed if elapsed > 0 else 0.0 + status = "backward_stop" if result.motion_state.backward_active else "running" + + progress = f"frame={result.frame_index}" + if artifacts.total_source_frames: + progress = f"frame={result.frame_index}/{artifacts.total_source_frames}" + + eta_text = "" + if artifacts.total_source_frames and fps > 0: + remaining_frames = max(0, artifacts.total_source_frames - result.frame_index) + eta_seconds = remaining_frames / fps + eta_text = f" eta={_format_duration(eta_seconds)}" + + print( + f"[checkpoint] {progress} elapsed={_format_duration(elapsed)} " + f"fps={fps:.1f} inside_box={result.inside_box_count} " + f"total_entered={result.total_entered_count} " + f"backward_active={result.motion_state.backward_active} " + f"status={status}{eta_text}" + ) + + if config.feedback.save_images: + output_dir = Path(config.feedback.image_output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + image_path = output_dir / f"frame_{result.frame_index:06d}.jpg" + cv2.imwrite(str(image_path), annotated) diff --git a/src/chicken_counter/report.py b/src/chicken_counter/report.py new file mode 100644 index 0000000..eda39ca --- /dev/null +++ b/src/chicken_counter/report.py @@ -0,0 +1,117 @@ +"""Build and write the per-camera count JSON including the 4-camera total.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + +from chicken_counter.types import BatchReport, CameraBatchResult + + +def build_camera_report_entry( + item: CameraBatchResult, + *, + output_dir: Path | None = None, +) -> dict: + if item.skipped: + return { + "skipped": True, + "skip_reason": item.skip_reason, + "total_entered": 0, + } + + pipeline = item.pipeline + if pipeline is None: + return {} + + source_name = Path(pipeline.source_video).name + return { + "total_entered": pipeline.total_entered_count, + "source_video": source_name, + "vis_video": _relative_output_path(pipeline.vis_video_path, output_dir), + "compressed_video": _relative_output_path(item.compressed_video_path, output_dir), + "compressed_size_mb": item.compressed_size_mb, + "frames_processed": pipeline.frames_processed, + "stopped_reason": pipeline.stopped_reason, + "elapsed_seconds": round(pipeline.elapsed_seconds, 1), + } + + +def build_batch_report( + date: str, + results: list[CameraBatchResult], + *, + output_dir: Path | None = None, +) -> BatchReport: + cameras: dict[str, dict] = {} + total_sum = 0 + + for item in results: + camera_entry = build_camera_report_entry(item, output_dir=output_dir) + if not camera_entry: + continue + cameras[item.camera_id] = camera_entry + if not item.skipped: + total_sum += camera_entry["total_entered"] + + return BatchReport( + date=date, + generated_at=datetime.now(timezone.utc).isoformat(), + cameras=cameras, + total_entered_sum=total_sum, + ) + + +def write_batch_report(report: BatchReport, output_path: str | Path) -> Path: + path = Path(output_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(report.__dict__, indent=2), encoding="utf-8") + print(f"[report] wrote {path}") + return path + + +def write_camera_report( + date: str, + item: CameraBatchResult, + output_dir: str | Path, +) -> Path: + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + camera_entry = build_camera_report_entry(item, output_dir=output_path) + payload = { + "date": date, + "camera_id": item.camera_id, + "generated_at": datetime.now(timezone.utc).isoformat(), + **camera_entry, + } + path = output_path / f"{item.camera_id}_counts_{date}.json" + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(f"[report] wrote {path}") + return path + + +def persist_batch_reports( + date: str, + results: list[CameraBatchResult], + output_dir: str | Path, +) -> Path: + output_path = Path(output_dir) + latest = results[-1] + write_camera_report(date, latest, output_path) + aggregate_path = output_path / f"counts_{date}.json" + report = build_batch_report(date, results, output_dir=output_path) + write_batch_report(report, aggregate_path) + return aggregate_path + + +def _relative_output_path(path: str | Path | None, base_dir: Path | None = None) -> str | None: + if path is None: + return None + resolved = Path(path) + if base_dir is not None: + try: + return resolved.relative_to(base_dir).as_posix() + except ValueError: + pass + return resolved.name diff --git a/src/chicken_counter/tracking.py b/src/chicken_counter/tracking.py new file mode 100644 index 0000000..ad1d057 --- /dev/null +++ b/src/chicken_counter/tracking.py @@ -0,0 +1,127 @@ +"""Run YOLO detection and BoT-SORT tracking on each frame.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +from ultralytics import YOLO + +from chicken_counter.config import CameraConfig +from chicken_counter.types import TrackObservation + + +class DetectionTracker: + def __init__(self, config: CameraConfig) -> None: + self.config = config + model_path = Path(config.detection.model_path) + self.model_kind = model_path.suffix.lower().lstrip(".") or "unknown" + self.model = YOLO(config.detection.model_path) + self.tracker_config_path = str(Path(config.tracker.tracker_config_path)) + print( + f"[model] loaded {self.model_kind} from {model_path} " + f"(imgsz={config.detection.imgsz}, device={config.detection.device})" + ) + if self.model_kind == "engine": + print("[model] TensorRT engine active; runtime half flag is ignored") + + def reset_tracking(self) -> None: + if hasattr(self.model, "predictor"): + self.model.predictor = None + + def infer( + self, + frame: np.ndarray, + *, + crop_rect: tuple[int, int, int, int] | None = None, + ) -> list[TrackObservation]: + offset_x = 0 + offset_y = 0 + source = frame + if crop_rect is not None: + x1, y1, x2, y2 = crop_rect + source = frame[y1:y2, x1:x2] + offset_x, offset_y = x1, y1 + + track_kwargs: dict = { + "source": source, + "persist": self.config.tracker.persist, + "tracker": self.tracker_config_path, + "conf": self.config.detection.conf, + "iou": self.config.detection.iou, + "classes": self.config.detection.classes, + "imgsz": self.config.detection.imgsz, + "verbose": False, + "device": self.config.detection.device, + } + if self.model_kind != "engine" and self.config.performance.half: + track_kwargs["half"] = True + + results = self.model.track(**track_kwargs) + + if not results: + return [] + + result = results[0] + boxes = result.boxes + if boxes is None or boxes.id is None: + return [] + + ids = boxes.id.int().cpu().numpy() + classes = boxes.cls.int().cpu().numpy() + confidences = boxes.conf.cpu().numpy() + xyxy = boxes.xyxy.int().cpu().numpy() + + mask_polygons = None + if result.masks is not None and result.masks.xy is not None: + mask_polygons = result.masks.xy + if len(mask_polygons) != len(boxes): + raise RuntimeError( + f"Ultralytics box/mask count mismatch: {len(boxes)} boxes, " + f"{len(mask_polygons)} masks" + ) + + tracks: list[TrackObservation] = [] + for index in range(len(boxes)): + track_id = int(ids[index]) + class_id = int(classes[index]) + confidence = float(confidences[index]) + bbox = xyxy[index] + x1 = int(bbox[0]) + offset_x + y1 = int(bbox[1]) + offset_y + x2 = int(bbox[2]) + offset_x + y2 = int(bbox[3]) + offset_y + centroid = ((x1 + x2) // 2, (y1 + y2) // 2) + + if crop_rect is not None and not self._centroid_in_rect(centroid, crop_rect): + continue + + polygon = None + if mask_polygons is not None: + poly = np.asarray(mask_polygons[index], dtype=np.float64).copy() + if poly.ndim == 2 and poly.shape[0] >= 3: + poly[:, 0] += offset_x + poly[:, 1] += offset_y + polygon = poly + + tracks.append( + TrackObservation( + track_id=track_id, + class_id=class_id, + confidence=confidence, + bbox_xyxy=(x1, y1, x2, y2), + centroid=centroid, + mask_polygon_xy=polygon, + ) + ) + + return tracks + + @staticmethod + def _centroid_in_rect( + centroid: tuple[int, int], + rect: tuple[int, int, int, int], + ) -> bool: + x1, y1, x2, y2 = rect + cx, cy = centroid + return x1 <= cx <= x2 and y1 <= cy <= y2 diff --git a/src/chicken_counter/types.py b/src/chicken_counter/types.py new file mode 100644 index 0000000..60ec21d --- /dev/null +++ b/src/chicken_counter/types.py @@ -0,0 +1,72 @@ +"""Shared dataclasses for tracks, frame results, and batch reports.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class TrackObservation: + track_id: int + class_id: int + confidence: float + bbox_xyxy: tuple[int, int, int, int] + centroid: tuple[int, int] + # Full-frame polygon (N, 2) float/int array; None for detect-only models. + mask_polygon_xy: Any | None = None + + +@dataclass +class CountEvent: + track_id: int + frame_index: int + total_entered_after_event: int + sequence_number: int + + +@dataclass +class MotionState: + smoothed_speed: float = 0.0 + consecutive_reverse_frames: int = 0 + backward_active: bool = False + + +@dataclass +class FrameResult: + frame_index: int + tracks: list[TrackObservation] = field(default_factory=list) + inside_box_count: int = 0 + total_entered_count: int = 0 + latest_validated_track_id: int | None = None + motion_state: MotionState = field(default_factory=MotionState) + count_events: list[CountEvent] = field(default_factory=list) + + +@dataclass +class PipelineResult: + camera_id: str + total_entered_count: int + frames_processed: int + stopped_reason: str + vis_video_path: str | None + source_video: str + elapsed_seconds: float + + +@dataclass +class CameraBatchResult: + camera_id: str + pipeline: PipelineResult | None = None + skipped: bool = False + skip_reason: str | None = None + compressed_video_path: str | None = None + compressed_size_mb: float | None = None + + +@dataclass +class BatchReport: + date: str + generated_at: str + cameras: dict[str, dict] + total_entered_sum: int diff --git a/src/chicken_counter/video_writer.py b/src/chicken_counter/video_writer.py new file mode 100644 index 0000000..6b593b1 --- /dev/null +++ b/src/chicken_counter/video_writer.py @@ -0,0 +1,81 @@ +"""Write annotated output videos via GStreamer or OpenCV codecs.""" + +from __future__ import annotations + +from pathlib import Path + +import cv2 + + +def make_video_writer( + path: str, + frame_size: tuple[int, int], + fps: float, + *, + encoder: str = "auto", + output_bitrate_kbps: int = 4000, + codec_preference: list[str] | None = None, +) -> cv2.VideoWriter: + Path(path).parent.mkdir(parents=True, exist_ok=True) + width, height = frame_size + bitrate_bps = max(1, output_bitrate_kbps) * 1000 + codecs = codec_preference or ["avc1", "mp4v", "H264"] + + if encoder in ("auto", "gstreamer"): + writer = _try_gstreamer_writer(path, width, height, fps, bitrate_bps) + if writer is not None: + print(f"[video] opened GStreamer hardware encoder (bitrate={output_bitrate_kbps} kbps)") + return writer + if encoder == "gstreamer": + raise RuntimeError( + f"GStreamer video writer failed for path: {path}. " + "Ensure OpenCV was built with GStreamer and Jetson encoder plugins are available." + ) + + writer = _try_opencv_writer(path, frame_size, fps, codecs) + if writer is not None: + print(f"[video] opened OpenCV encoder (codecs tried: {codecs})") + return writer + + raise RuntimeError( + "Unable to open video writer for path: " + f"{path}. Tried encoder={encoder}, codecs={codecs}" + ) + + +def _try_gstreamer_writer( + path: str, + width: int, + height: int, + fps: float, + bitrate_bps: int, +) -> cv2.VideoWriter | None: + fps_int = max(1, int(round(fps))) + pipeline = ( + f"appsrc ! video/x-raw, format=BGR ! " + f"video/x-raw,width={width},height={height},framerate={fps_int}/1 ! " + f"videoconvert ! nvvidconv ! " + f"video/x-raw(memory:NVMM),format=NV12 ! " + f"nvv4l2h264enc bitrate={bitrate_bps} insert-sps-pps=true ! " + f"h264parse ! mp4mux ! filesink location={path}" + ) + writer = cv2.VideoWriter(pipeline, cv2.CAP_GSTREAMER, 0, fps, (width, height), True) + if writer.isOpened(): + return writer + writer.release() + return None + + +def _try_opencv_writer( + path: str, + frame_size: tuple[int, int], + fps: float, + codec_candidates: list[str], +) -> cv2.VideoWriter | None: + for codec in codec_candidates: + fourcc = cv2.VideoWriter_fourcc(*codec) + writer = cv2.VideoWriter(path, fourcc, fps, frame_size) + if writer.isOpened(): + return writer + writer.release() + return None diff --git a/tests/__pycache__/test_tracking.cpython-312.pyc b/tests/__pycache__/test_tracking.cpython-312.pyc new file mode 100644 index 0000000..b5177ff Binary files /dev/null and b/tests/__pycache__/test_tracking.cpython-312.pyc differ diff --git a/tests/test_tracking.py b/tests/test_tracking.py new file mode 100644 index 0000000..34a7c07 --- /dev/null +++ b/tests/test_tracking.py @@ -0,0 +1,40 @@ +"""Tests for mask polygon handling in DetectionTracker.""" + +from __future__ import annotations + +import unittest + +import numpy as np + +from chicken_counter.types import TrackObservation + + +class TrackObservationMaskTests(unittest.TestCase): + def test_track_observation_accepts_mask_polygon(self) -> None: + poly = np.array([[10.0, 20.0], [30.0, 20.0], [30.0, 40.0]], dtype=np.float64) + track = TrackObservation( + track_id=1, + class_id=0, + confidence=0.9, + bbox_xyxy=(10, 20, 30, 40), + centroid=(20, 30), + mask_polygon_xy=poly, + ) + self.assertIsNotNone(track.mask_polygon_xy) + self.assertEqual(track.mask_polygon_xy.shape, (3, 2)) + + def test_crop_offset_translation_pattern(self) -> None: + """Mirrors tracking.infer crop offset applied to mask polygons.""" + offset_x, offset_y = 100, 50 + local = np.array([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], dtype=np.float64) + full = local.copy() + full[:, 0] += offset_x + full[:, 1] += offset_y + self.assertAlmostEqual(float(full[0, 0]), 100.0) + self.assertAlmostEqual(float(full[0, 1]), 50.0) + self.assertAlmostEqual(float(full[2, 0]), 110.0) + self.assertAlmostEqual(float(full[2, 1]), 60.0) + + +if __name__ == "__main__": + unittest.main()