intial commit
This commit is contained in:
commit
a54a070ca9
49 files changed
+2960
No files matched your search
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Chicken counting pipeline package for Jetson single-camera and daily batch runs."""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
@@ -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()
|
||||
Reference in new issue
Block a user