intial commit

This commit is contained in:
zakariasaputra committed 2026-07-17 14:58:29 +07:00
commit a54a070ca9
49 files changed
+2960

No files matched your search

+400
View File
@@ -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.
+79
View File
@@ -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
+117
View File
@@ -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]
+12
View File
@@ -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
+25
View File
@@ -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"]
+411
View File
@@ -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.
+24
View File
@@ -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
@@ -0,0 +1 @@
@@ -0,0 +1,2 @@
[console_scripts]
chicken-counter = chicken_counter.cli:main
@@ -0,0 +1,4 @@
numpy>=1.26
opencv-python>=4.10
PyYAML>=6.0.2
ultralytics>=8.4.38
@@ -0,0 +1 @@
chicken_counter
+1
View File
@@ -0,0 +1 @@
"""Chicken counting pipeline package for Jetson single-camera and daily batch runs."""
Binary file not shown.
+58
View File
@@ -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
+111
View File
@@ -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
+12
View File
@@ -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
+63
View File
@@ -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()
+103
View File
@@ -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
+391
View File
@@ -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)
+151
View File
@@ -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)
+99
View File
@@ -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
+189
View File
@@ -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)
+269
View File
@@ -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)
+117
View File
@@ -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
+127
View File
@@ -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
+72
View File
@@ -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
+81
View File
@@ -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
Binary file not shown.
+40
View File
@@ -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()