forked from zakaria/chicken-counting-sukawarna-det
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
129d4def09 | ||
|
|
d97cc616ef | ||
|
|
d9daf0ab01 | ||
|
|
2061fbf08c | ||
|
|
9f4fc138ba | ||
|
|
acd9c9265b | ||
|
|
127f3c59b2 | ||
|
|
f9c38e5126 | ||
|
|
5ad4a5f796 | ||
|
|
8fe2f88f12 | ||
|
|
b00923dc2b | ||
|
|
6723701424 | ||
|
|
ec7226d172 | ||
|
|
a2f44167a2 | ||
|
|
010f6e1493 | ||
|
|
c05f3d98d0 | ||
|
|
3b524d4a65 | ||
|
|
6cddf2c9d3 | ||
|
|
b9f4ba2ef8 | ||
|
|
94c4dd81a7 | ||
|
|
8cb5b01c00 | ||
|
|
3eeb20ca56 | ||
|
|
f9fc6c0571 | ||
|
|
e7c26d3f86 | ||
|
|
dc447264b7 | ||
|
|
9512fc7f4d | ||
|
|
ae86821165 | ||
|
|
efd4726bc6 | ||
|
|
6dbaa517dc | ||
|
|
a54d0e6b2c | ||
|
|
d31ad05f0a | ||
|
|
af4e514357 |
No files matched your search
@@ -218,3 +218,11 @@ __marimo__/
|
||||
|
||||
# Streamlit
|
||||
.streamlit/secrets.toml
|
||||
|
||||
# Project local / runtime outputs
|
||||
runs/
|
||||
output/
|
||||
*.log
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.2026*
|
||||
@@ -0,0 +1,306 @@
|
||||
# Chicken Counter API
|
||||
|
||||
Base URL: `http://<jetson-ip>:8080`
|
||||
|
||||
## System Status & Live Monitoring
|
||||
|
||||
### `GET /api/status`
|
||||
Returns real-time pipeline activity status, active streaming cameras, latest processed date, and all-time total counts.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "running",
|
||||
"is_counting_active": true,
|
||||
"active_cameras": ["CC1", "CC2", "CC3", "CC4"],
|
||||
"latest_counted_date": "2026-06-18",
|
||||
"total_chickens_all_time": 146418,
|
||||
"cycle_start_date": "2026-05-22",
|
||||
"timestamp": "2026-08-14T08:30:00.000000+00:00"
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /`
|
||||
Returns the dashboard HTML page.
|
||||
|
||||
### `GET /api/cameras`
|
||||
List cameras currently writing to `/dev/shm`.
|
||||
|
||||
```json
|
||||
{"cameras": ["CC1", "CC2", "CC3"]}
|
||||
```
|
||||
|
||||
### `GET /shm/<camera_id>/stats.json`
|
||||
Live stats for the active pipeline.
|
||||
|
||||
```json
|
||||
{
|
||||
"frame_index": 5120,
|
||||
"inside_box_count": 42,
|
||||
"total_entered_count": 1858,
|
||||
"track_count": 99,
|
||||
"backward_active": false,
|
||||
"smoothed_speed": 4.3,
|
||||
"count_events": 0,
|
||||
"run_date": "2026-06-10"
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /shm/<camera_id>/frame.jpg`
|
||||
Live JPEG frame from the active pipeline.
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
All endpoints require the dashboard to be started with `--db <path>`. If no DB exists, endpoints return `[]` or `{}`.
|
||||
|
||||
### `GET /api/config/cycle_start_date`
|
||||
Returns or updates the active Day 0 (`cycle_start_date`).
|
||||
|
||||
```bash
|
||||
# Query active Day 0
|
||||
GET /api/config/cycle_start_date
|
||||
→ {"cycle_start_date": "2026-05-22"}
|
||||
|
||||
# Override Day 0 dynamically via query param or POST payload
|
||||
GET /api/config/cycle_start_date?set=2026-05-22
|
||||
POST /api/config/cycle_start_date {"cycle_start_date": "2026-05-22"}
|
||||
→ {"status": "ok", "cycle_start_date": "2026-05-22"}
|
||||
```
|
||||
|
||||
### `GET /api/db/summary`
|
||||
Overall totals across all dates and locations.
|
||||
|
||||
```json
|
||||
{
|
||||
"days": 12,
|
||||
"locations": 2,
|
||||
"total_runs": 48,
|
||||
"total_chickens": 125000,
|
||||
"total_hours": 8.5
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/db/history`
|
||||
Per-date summary, newest first (max 50 rows).
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"date": "2026-06-10",
|
||||
"location": "kandang-atas",
|
||||
"cams": 4,
|
||||
"total": 5570,
|
||||
"minutes": 40.2
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `GET /api/db/date/<date>`
|
||||
Detail for a specific date. Format: `YYYY-MM-DD`.
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2026-06-10",
|
||||
"total": {"total": 5570, "minutes": 40.2},
|
||||
"cameras": [
|
||||
{
|
||||
"camera_id": "CC1",
|
||||
"total_entered": 1500,
|
||||
"frames_processed": 24800,
|
||||
"elapsed_seconds": 600.5,
|
||||
"stopped_reason": "backward",
|
||||
"source_video": "kandang_1_camera_1_2026-06-10_120056.mp4",
|
||||
"location": "kandang-atas"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/db/camera/<camera_id>`
|
||||
History for a specific camera across all dates (max 50 rows).
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"date": "2026-06-10",
|
||||
"location": "kandang-atas",
|
||||
"total_entered": 1500,
|
||||
"frames_processed": 24800,
|
||||
"elapsed_seconds": 600.5,
|
||||
"stopped_reason": "backward"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `GET /api/db/location/<location>`
|
||||
Summary and history for a specific location.
|
||||
|
||||
```json
|
||||
{
|
||||
"location": "kandang-atas",
|
||||
"summary": {"days": 5, "total": 25000, "hours": 3.2},
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-06-10",
|
||||
"cameras": "CC1, CC2, CC3, CC4",
|
||||
"total": 5570,
|
||||
"minutes": 40.2
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE batch_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
location TEXT NOT NULL,
|
||||
camera_id TEXT NOT NULL,
|
||||
total_entered INTEGER NOT NULL DEFAULT 0,
|
||||
frames_processed INTEGER NOT NULL DEFAULT 0,
|
||||
elapsed_seconds REAL NOT NULL DEFAULT 0.0,
|
||||
stopped_reason TEXT NOT NULL DEFAULT '',
|
||||
source_video TEXT NOT NULL DEFAULT '',
|
||||
generated_at TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE(date, location, camera_id)
|
||||
);
|
||||
```
|
||||
|
||||
Data is inserted automatically by the batch runner when `location` and `db_path` are configured in the batch YAML, or manually via:
|
||||
|
||||
```bash
|
||||
python3 store_results.py output/counts_2026-06-10.json --location kandang-atas --db chicken_counts.db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mortality Detection
|
||||
|
||||
Mortality endpoints require the dashboard to be started with `--mortality-dir <path>`. The path must contain a `mortality_report.json` file generated by `./test_run_mortality.sh`. Multiple directories can be registered with repeated `--mortality-dir` flags.
|
||||
|
||||
If multiple images are captured in a single day (e.g. morning and afternoon scans), the pipeline processes all images in the input directory, generates marked output JPEGs (`output_<name>.jpg`), and aggregates the daily total carcass count into `total_mortality_count`.
|
||||
|
||||
### `GET /api/mortality/latest`
|
||||
Returns the most recently modified `mortality_report.json` across all registered mortality directories.
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2026-07-09",
|
||||
"mode": "similarity_two_pass",
|
||||
"model_path": "...",
|
||||
"conf_threshold": 0.7,
|
||||
"iou_threshold": 0.8,
|
||||
"total_images": 2,
|
||||
"total_mortality_count": 35,
|
||||
"_dir": "/path/to/mortality",
|
||||
"results": [
|
||||
{
|
||||
"input_image": "scan_01.jpg",
|
||||
"output_image": "output_scan_01.jpg",
|
||||
"count": 19,
|
||||
"detections": [
|
||||
{
|
||||
"id": 1,
|
||||
"box": [177, 161, 288, 347],
|
||||
"confidence": 0.9597,
|
||||
"area": 20646
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"input_image": "scan_02.jpg",
|
||||
"output_image": "output_scan_02.jpg",
|
||||
"count": 16,
|
||||
"detections": [...]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/mortality/history`
|
||||
Returns all `mortality_report.json` files from all registered directories, sorted newest first. Each report contains `total_mortality_count` (grand total across all images in that run) and `total_images`.
|
||||
|
||||
### `GET /api/mortality/date/<YYYY-MM-DD>`
|
||||
Returns all mortality scans and total carcass counts recorded on a specific date:
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2026-07-09",
|
||||
"total_mortality_count": 35,
|
||||
"total_images": 2,
|
||||
"reports": [...],
|
||||
"results": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/mortality/image/<filename>`
|
||||
Serves an annotated output JPEG by filename securely. Only files beginning with `output_` are accessible for security.
|
||||
|
||||
```
|
||||
GET /api/mortality/image/output_scan_01.jpg
|
||||
→ Content-Type: image/jpeg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Starting the Dashboard
|
||||
|
||||
Use the portable launcher script which auto-discovers the mortality directory:
|
||||
|
||||
```bash
|
||||
./start_dashboard.sh
|
||||
```
|
||||
|
||||
Optional environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `PORT` | `8080` | Port to listen on |
|
||||
| `DB_PATH` | `db/chicken_counts.db` | Path to SQLite database |
|
||||
| `MORTALITY_DIRS` | auto-detected | Comma-separated mortality dirs |
|
||||
|
||||
Or start manually with full control:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src venv/bin/python dashboard.py \
|
||||
--port 8080 \
|
||||
--db db/chicken_counts.db \
|
||||
--mortality-dir /path/to/mortality \
|
||||
--mortality-dir /path/to/another/mortality
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Outbound System Notifications & Webhooks
|
||||
|
||||
When integrating with external management systems or cloud backends, you can query status or send automated event notifications (e.g. `STARTED`, `COMPLETED`, `MORTALITY_DETECTED`).
|
||||
|
||||
### 1. Polling Pipeline State
|
||||
External systems can poll `GET http://<jetson-ip>:8080/api/status` every 5–10 seconds to detect if counting or mortality runs are currently in progress or finished.
|
||||
|
||||
### 2. Sending Outbound Webhook from Shell / Batch Scripts
|
||||
To notify an external endpoint (e.g. `https://your-server.com/api/notify`) upon run lifecycle events:
|
||||
|
||||
```bash
|
||||
# Example: Notify external server when counting starts
|
||||
curl -X POST https://your-server.com/api/notify \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "COUNTING_STARTED", "date": "2026-06-18", "device": "jetson-sukawarna"}'
|
||||
|
||||
# Example: Notify external server when counting completes with JSON payload
|
||||
curl -X POST https://your-server.com/api/notify \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @/home/asus/.Codes/VIDEOS/cycle7/kandang-atas/2026-06-18/output/counts_2026-06-18.json
|
||||
|
||||
# Example: Notify external server when mortality scan finishes
|
||||
curl -X POST https://your-server.com/api/notify \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @/home/asus/.Codes/VIDEOS/cycle7/kandang-atas/mortality/mortality_report.json
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the `chicken-counting-sukawarna-det` project are documented in this file.
|
||||
|
||||
## [Unreleased] - 2026-08-19
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- **Multi-Floor Script Syntax (`run_all_coops.sh`)**:
|
||||
- Resolved fatal `syntax error: unexpected end of file` caused by missing `done` in floor configuration discovery loop.
|
||||
- Expanded config discovery pattern from `K*-L*.yaml` to `*.yaml` to support custom named coops (e.g. `kandang-atas.yaml`).
|
||||
- **Excel Report Empty Result Crash (`export_excel_report.py`)**:
|
||||
- Added empty DataFrame pre-check to prevent `IndexError` when exporting reports for dates with no batch runs or newly initialized databases.
|
||||
- **Multi-Threaded SQLite Concurrency (`dashboard.py`)**:
|
||||
- Replaced global single `sqlite3` connection with thread-local connections (`threading.local()`) to prevent cursor collision and race conditions across concurrent HTTP worker threads.
|
||||
- Enabled `PRAGMA busy_timeout = 5000` and `PRAGMA journal_mode = WAL` to avoid database lock conflicts during batch processing writes.
|
||||
- **Editable Package Installation**:
|
||||
- Configured `pip install -e .` in environment setup to resolve `chicken_counter` imports and enable automatic unit test discovery without manual `PYTHONPATH` prefixes.
|
||||
|
||||
### ⚡ Performance & Optimization
|
||||
- **Disk Traversal Caching in Live Dashboard (`dashboard.py`)**:
|
||||
- Replaced repetitive `Path.rglob()` scans on every `/api/mortality/*` endpoint with a thread-safe 15-second in-memory TTL cache (`_MORTALITY_CACHE_TTL = 15.0s`).
|
||||
- Added $O(1)$ memory index lookup for serving annotated images (`/api/mortality/image/<filename>`), eliminating full filesystem traversals across 30GB+ video datasets.
|
||||
|
||||
### 📁 Configuration & Directory Structure
|
||||
- **Floor Configuration & Skeleton Paths**:
|
||||
- Created `configs/floor_config/kandang-atas.yaml` extending `cycle7_batch_optimized.yaml`.
|
||||
- Initialized missing directory skeletons for coops `K1-L1` through `K5-L2` under `../VIDEOS/cycle7/`.
|
||||
- **Documentation**:
|
||||
- Updated `README.md` and `RUN.md` with editable install steps and updated project layout.
|
||||
@@ -24,8 +24,13 @@ visual.
|
||||
|
||||
```text
|
||||
configs/
|
||||
floor_config/ ← Lightweight floor configs (K1-L1 to K5-L2, kandang-atas)
|
||||
K1-L1.yaml .. K5-L2.yaml ← Extends cycle7_batch_optimized.yaml
|
||||
kandang-atas.yaml ← Extends cycle7_batch_optimized.yaml
|
||||
cameras/example_camera.yaml
|
||||
cycle7_batch.yaml
|
||||
cycle7_batch_optimized.yaml ← Base batch processing config
|
||||
mortality_config.yaml
|
||||
trackers/botsort_chicken.yaml
|
||||
src/chicken_counter/
|
||||
batch_discovery.py
|
||||
@@ -35,6 +40,8 @@ src/chicken_counter/
|
||||
compress.py
|
||||
config.py
|
||||
counting.py
|
||||
engine_utils.py
|
||||
mortality.py
|
||||
motion.py
|
||||
overlay.py
|
||||
pipeline.py
|
||||
@@ -42,17 +49,28 @@ src/chicken_counter/
|
||||
tracking.py
|
||||
types.py
|
||||
video_writer.py
|
||||
dashboard.py
|
||||
export_engine.py
|
||||
export_excel_report.py
|
||||
run_all_coops.sh ← Master multi-coop batch runner
|
||||
start_dashboard.sh ← Live dashboard launcher
|
||||
test_run_folder/ ← Archive of test run scripts and logs
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python -m pip install -e .
|
||||
python3 -m venv venv
|
||||
venv/bin/pip install --upgrade pip
|
||||
venv/bin/pip install -r requirements.txt
|
||||
venv/bin/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.
|
||||
|
||||
> **Tip:** See `RUN.md` for a full step-by-step quickstart guide for new developers.
|
||||
|
||||
## Run
|
||||
|
||||
Update `configs/cameras/example_camera.yaml` with:
|
||||
@@ -199,7 +217,9 @@ detection:
|
||||
imgsz: 640 # keep 640 while using existing TensorRT .engine
|
||||
```
|
||||
|
||||
`configs/cycle7_batch.yaml` already uses these production defaults.
|
||||
`configs/cycle7_batch.yaml` and `configs/cycle7_batch_optimized.yaml` already use these production defaults.
|
||||
|
||||
`configs/cycle7_batch_optimized.yaml` adds per-camera parallelism, trimmed inference settings, and optimized YAML structure for the Sukawarna enclosure.
|
||||
|
||||
**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
|
||||
@@ -217,6 +237,53 @@ for tuning.
|
||||
- The optical-flow trigger is vision-first, though the config structure leaves room for
|
||||
a future controller/encoder integration path
|
||||
|
||||
## Cross-Machine Portability & Self-Healing Engine Auto-Recompilation
|
||||
|
||||
TensorRT `.engine` files are compiled specifically for the host GPU architecture and TensorRT version. When copying the project to a different machine (e.g. from Jetson to NUC or across different RTX GPUs):
|
||||
|
||||
- **Automatic Compatibility Check**: `src/chicken_counter/engine_utils.py` runs a fast health check on the specified `.engine` before counting starts.
|
||||
- **Self-Healing Recompilation**: If an incompatibility (e.g. platform tag mismatch or different compute capability) is detected:
|
||||
1. The system automatically searches `models/` for the matching base `.pt` model weights (stripping hardware prefixes like `NUC5070_` or `jetson_`).
|
||||
2. Automatically compiles a new optimized `.engine` on the host machine using FP16 precision.
|
||||
3. Updates `defaults.detection.model_path` in `configs/cycle7_batch_optimized.yaml` automatically.
|
||||
- **Manual Export Tool**: You can also compile engines manually anytime using `export_engine.py`:
|
||||
```bash
|
||||
./venv/bin/python export_engine.py models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.pt --half --workspace 4
|
||||
```
|
||||
|
||||
## Multi-Stage Growth Cycles & Day 0 Configuration
|
||||
|
||||
The pipeline dynamically adjusts detection and ROI entry thresholds based on flock age (Days Old Chick / DOC vs Mid-Cycle):
|
||||
|
||||
- **Day 0 (`cycle_start_date`)**: Configured in `configs/cycle7_batch_optimized.yaml` (default: `"2026-05-22"`). Can be overridden via CLI (`--cycle-start-date YYYY-MM-DD`) or REST API (`/api/config/cycle_start_date`).
|
||||
- **`early_cycle` (Days 0–15)**: Automatically applies high-sensitivity detection thresholds (`conf: 0.12`, `min_box_area_px: 200`, `min_overlap_ratio: 0.25`) for small fast-moving DOC chicks.
|
||||
- **`mid_cycle` (Days 16+)**: Preserves standard tuned per-camera defaults (`conf: 0.35–0.50`, `min_box_area_px: 2500–3000`).
|
||||
|
||||
## Mortality Detection
|
||||
|
||||
A separate pipeline detects carcasses (dead birds) from still photos. Supports multi-image daily runs and date subfolders (`mortality/YYYY-MM-DD/`).
|
||||
|
||||
```bash
|
||||
# Run on default mortality directory
|
||||
./test_run_mortality.sh
|
||||
|
||||
# Run on a specific date (auto-creates/routes to mortality/2026-05-23/)
|
||||
chicken-counter mortality --date 2026-05-23
|
||||
```
|
||||
|
||||
Key features:
|
||||
- **Multi-Image & Multi-Day Support**: Processes multiple images per day (e.g. morning/afternoon scans), aggregates the grand total carcass count (`total_mortality_count`), and saves outputs into date-isolated directories.
|
||||
- **Direct High-Precision Segmentation (Default)**: Uses the **segmentation model** (`models/chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt`) directly. 2-pass Detect & Refine (`two_pass: false`) is disabled by default because direct segmentation achieves higher accuracy and avoids false rejection on real farm photos.
|
||||
- **Optional 2-Pass Refine (`--two-pass`)**: An optional mode combining initial segmentation candidate proposals with `cv2.matchTemplate` similarity refinement.
|
||||
- **Containment filtering**: boxes where `IoA > 0.50` against a larger box are suppressed.
|
||||
- **Centroid deduplication**: detections whose centroids are within `dedupe_radius_px` of each other are merged to prevent counting the same carcass twice.
|
||||
|
||||
Outputs for each daily run:
|
||||
- `output_<name>.jpg` — annotated images with bounding boxes and carcass IDs
|
||||
- `mortality_report.json` — full summary report with `total_mortality_count`, per-image breakdown, and detection coordinates
|
||||
|
||||
Config: `configs/mortality_config.yaml`
|
||||
|
||||
## Headless Jetson MP4 Example
|
||||
|
||||
For a headless run that saves both output video and periodic checkpoint images, use a
|
||||
@@ -255,7 +322,7 @@ motion:
|
||||
flow_scale: 0.5
|
||||
display:
|
||||
show_window: false
|
||||
output_path: /media/jetson/DATA/try-sukawarna-vis.mp4
|
||||
output_path: /home/asus/.Codes/try-sukawarna-vis.mp4
|
||||
encoder: auto
|
||||
output_bitrate_kbps: 4000
|
||||
performance:
|
||||
@@ -294,14 +361,15 @@ For everyday processing of 4 cameras, use `configs/cycle7_batch.yaml`.
|
||||
|
||||
### Input folder layout
|
||||
|
||||
Place today's videos under:
|
||||
Place today's videos in a `VIDEOS` folder **adjacent to the project directory** (i.e. `../VIDEOS` relative to the project root):
|
||||
|
||||
```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
|
||||
../VIDEOS/cycle7/kandang-atas/
|
||||
2026-06-18/
|
||||
kandang_1_camera_1_2026-06-18_120056.mp4
|
||||
kandang_1_camera_2_2026-06-18_120456.mp4
|
||||
kandang_1_camera_3_2026-06-18_121012.mp4
|
||||
kandang_1_camera_4_2026-06-18_121530.mp4
|
||||
```
|
||||
|
||||
Date folders use `YYYY-MM-DD`. Camera files are matched by `camera_num` using the
|
||||
@@ -310,13 +378,32 @@ pattern `kandang_*_camera_{num}_*.mp4`.
|
||||
### Run commands
|
||||
|
||||
```bash
|
||||
# Process today's folder
|
||||
chicken-counter batch --config configs/cycle7_batch.yaml
|
||||
# Process today's folder using default parallel process mode
|
||||
chicken-counter batch --config configs/cycle7_batch_optimized.yaml
|
||||
|
||||
# Process a specific date
|
||||
chicken-counter batch --config configs/cycle7_batch.yaml --date 2026-07-09
|
||||
# Process a specific date using Model-Level Tensor Batching mode
|
||||
chicken-counter batch --config configs/cycle7_batch_optimized.yaml --date 2026-06-18 --mode tensor_batching
|
||||
|
||||
# Process a specific date using Hybrid mode (Threaded CPU + Batched GPU)
|
||||
chicken-counter batch --config configs/cycle7_batch_optimized.yaml --date 2026-06-18 --mode hybrid
|
||||
|
||||
# Run automated batch script for Tensor Batching
|
||||
./test_run_tensor_batch.sh # Runs all dates (2026-06-10 to 2026-06-19)
|
||||
./test_run_tensor_batch.sh 2026-06-18 # Runs a specific date
|
||||
|
||||
# Run automated batch script for Hybrid execution mode
|
||||
./test_run_hybrid.sh # Runs all dates (2026-06-10 to 2026-06-19)
|
||||
./test_run_hybrid.sh 2026-06-18 # Runs a specific date
|
||||
```
|
||||
|
||||
### Execution Modes (`execution_mode`)
|
||||
|
||||
Set `batch.execution_mode` in `configs/cycle7_batch_optimized.yaml` or override via `--mode`:
|
||||
|
||||
- `parallel_processes` (Default): Runs cameras in separate OS processes (e.g. via `test_run.sh`).
|
||||
- `tensor_batching`: Synchronizes camera frame streams and executes a single batched GPU model inference pass across all cameras (`batch_size=N`).
|
||||
- `hybrid`: Combines multi-threaded CPU frame capture, optical flow, and rendering across CPU cores with a single synchronized batched GPU forward pass (`batch_size=N`).
|
||||
|
||||
Single-camera mode still works:
|
||||
|
||||
```bash
|
||||
@@ -327,7 +414,7 @@ chicken-counter run --config configs/cameras/example_camera.yaml
|
||||
### Output layout
|
||||
|
||||
```text
|
||||
/media/jetson/DATA/chicken-sukawarna/cycle7/2026-07-09/output/
|
||||
../VIDEOS/cycle7/kandang-atas/2026-06-18/output/
|
||||
CC1_vis.mp4
|
||||
CC1_compressed.mp4
|
||||
CC2_vis.mp4
|
||||
@@ -335,7 +422,7 @@ chicken-counter run --config configs/cameras/example_camera.yaml
|
||||
...
|
||||
checkpoints/CC1/frame_003000.jpg
|
||||
checkpoints/CC2/frame_006000.jpg
|
||||
counts_2026-07-09.json
|
||||
counts_2026-06-18.json
|
||||
```
|
||||
|
||||
After all 4 cameras finish counting, the batch runner compresses each annotated video
|
||||
@@ -384,11 +471,37 @@ 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
|
||||
0 7 * * * cd /path/to/chicken-counting-sukawarna-det && ./test_run.sh >> logs/cycle7-batch.log 2>&1
|
||||
```
|
||||
|
||||
Requires `ffmpeg` on the Jetson PATH for post-run compression.
|
||||
|
||||
## Dashboard & API
|
||||
|
||||
Start the live dashboard and API server:
|
||||
|
||||
```bash
|
||||
# Portable launcher (recommended) — auto-discovers mortality dir
|
||||
./start_dashboard.sh
|
||||
|
||||
# Or start manually
|
||||
PYTHONPATH=src venv/bin/python dashboard.py \
|
||||
--port 8080 \
|
||||
--db db/chicken_counts.db \
|
||||
--mortality-dir ../VIDEOS/cycle7/kandang-atas/mortality
|
||||
```
|
||||
|
||||
Key API endpoints (see `API.md` for full schema):
|
||||
|
||||
| Endpoint | Description |
|
||||
| :--- | :--- |
|
||||
| `GET /api/db/summary` | Lifetime totals across all dates |
|
||||
| `GET /api/db/history` | Per-date summary, newest first |
|
||||
| `GET /api/db/date/<YYYY-MM-DD>` | Per-camera breakdown for a date |
|
||||
| `GET /api/mortality/latest` | Latest carcass detection report |
|
||||
| `GET /api/mortality/history` | All mortality reports, newest first |
|
||||
| `GET /api/mortality/image/<name>` | Serve annotated output JPEG |
|
||||
|
||||
## Next Jetson-Focused Improvements
|
||||
|
||||
1. Add a hardware-aware video ingest path for CSI/GStreamer.
|
||||
@@ -397,4 +510,5 @@ Requires `ffmpeg` on the Jetson PATH for post-run compression.
|
||||
|
||||
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.
|
||||
duplicate optical-flow removal (`gmc_method: none`), long-run ETA logging, mortality
|
||||
2-pass detection with feature similarity search, and a REST API via `dashboard.py`.
|
||||
@@ -1,11 +1,234 @@
|
||||
# Counter
|
||||
## Pakai virtual env di /media/jetson/DATA/karung-sukawarna/venv
|
||||
# Quick Start Guide
|
||||
|
||||
alias chicken-counter='PYTHONPATH={fullpath git clone folder} /media/jetson/DATA/karung-sukawarna/venv/bin/python -m chicken_counter.cli'
|
||||
This guide gets a new developer up and running from scratch.
|
||||
|
||||
chicken-counter batch --config configs/cycle7_batch.yaml --date 2026-06-10 --no-video --progress-bar
|
||||
---
|
||||
|
||||
# Dashboard
|
||||
## Pakai virtual env di /media/jetson/DATA/karung-sukawarna/venv
|
||||
source /media/jetson/DATA/karung-sukawarna/venv/bin/source
|
||||
python dashboard.py --port 8080
|
||||
## 1. Prerequisites
|
||||
|
||||
- Python 3.10+
|
||||
- `ffmpeg` on PATH (for video compression)
|
||||
- NVIDIA GPU + CUDA drivers (optional but recommended for inference speed)
|
||||
|
||||
---
|
||||
|
||||
## 2. First-Time Setup
|
||||
|
||||
```bash
|
||||
# Clone / copy the project folder, then enter it
|
||||
cd chicken-counting-sukawarna-det
|
||||
|
||||
# Create a virtual environment and install all dependencies
|
||||
python3 -m venv venv
|
||||
venv/bin/pip install --upgrade pip
|
||||
venv/bin/pip install -r requirements.txt
|
||||
venv/bin/pip install -e .
|
||||
```
|
||||
|
||||
> **Note**: If moving the project from another machine, always recreate the venv.
|
||||
> Do NOT copy the `venv/` folder — it contains absolute paths baked in from the source machine.
|
||||
|
||||
---
|
||||
|
||||
## 3. Project Layout
|
||||
|
||||
```text
|
||||
chicken-counting-sukawarna-det/
|
||||
├── configs/
|
||||
│ ├── floor_config/ ← Lightweight floor configs (K1-L1 to K5-L2, kandang-atas)
|
||||
│ │ ├── K1-L1.yaml .. K5-L2.yaml ← Extends cycle7_batch_optimized.yaml
|
||||
│ │ └── kandang-atas.yaml ← Extends cycle7_batch_optimized.yaml
|
||||
│ ├── cycle7_batch_optimized.yaml ← Main base batch processing config
|
||||
│ ├── cycle7_batch.yaml ← Alternate batch config
|
||||
│ ├── mortality_config.yaml ← Mortality (carcass) detection config
|
||||
│ ├── cameras/example_camera.yaml ← Single-camera run config template
|
||||
│ └── trackers/botsort_chicken.yaml ← BoT-SORT tracker settings
|
||||
├── db/
|
||||
│ └── chicken_counts.db ← SQLite database (auto-created)
|
||||
├── models/ ← Place your .pt / .onnx / .engine files here
|
||||
│ ├── chicken-detection-model-v26n-300e-best-2026-05-02-NEW.pt ← Source PyTorch model
|
||||
│ ├── chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine ← Hardware-tuned TensorRT
|
||||
│ └── chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt ← Used by mortality
|
||||
├── src/chicken_counter/ ← Main Python package (counting, tracking, motion, engine_utils)
|
||||
├── templates/ ← Dashboard HTML
|
||||
├── dashboard.py ← Live API + Dashboard server
|
||||
├── export_engine.py ← Manual TensorRT export utility
|
||||
├── export_excel_report.py ← Excel reporting & analytics generator
|
||||
├── run_all_coops.sh ← Master multi-coop batch runner (K1-L1 to K5-L2)
|
||||
├── start_dashboard.sh ← Portable dashboard launcher ← USE THIS
|
||||
├── test_run_folder/ ← Archive of test run scripts and logs
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Model Setup & Auto-Recompilation
|
||||
|
||||
Put your model files in the `models/` directory.
|
||||
|
||||
| Purpose | File | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| Batch video counting (TensorRT) | `models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine` | Maximum GPU throughput |
|
||||
| Base PyTorch weights | `models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.pt` | Used for portable runs and auto-recompiling engines |
|
||||
| Mortality detection | `models/chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt` | Direct segmentation model (2-pass disabled by default for accuracy) |
|
||||
|
||||
> **Self-Healing Recompilation on New Machines**: If you move the project to a new machine with a different GPU or OS, the pipeline will detect any incompatible `.engine`, automatically locate the matching `.pt` model, recompile a new `.engine` for the host machine, and update `configs/cycle7_batch_optimized.yaml` automatically.
|
||||
|
||||
---
|
||||
|
||||
## 5. Running the Systems
|
||||
|
||||
### A — Batch Video Processing (Daily Chicken Count)
|
||||
|
||||
Place input videos under the `VIDEOS` folder adjacent to the project following the `K{coop}-L{floor}` format:
|
||||
|
||||
```text
|
||||
../VIDEOS/cycle7/
|
||||
K1-L1/
|
||||
2026-06-18/
|
||||
K1-L1_cam1_2026-06-18_120056.mp4
|
||||
K1-L1_cam2_2026-06-18_120456.mp4
|
||||
K1-L1_cam3_2026-06-18_121012.mp4
|
||||
K1-L1_cam4_2026-06-18_121530.mp4
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
# Run all coops (10 floors: K1-L1 to K5-L2) for a specific date
|
||||
./run_all_coops.sh 2026-06-18
|
||||
|
||||
# Run a specific floor (e.g. Kandang 1 Lantai 1)
|
||||
PYTHONPATH=src venv/bin/python -m chicken_counter.cli batch \
|
||||
--config configs/floor_config/K1-L1.yaml \
|
||||
--date 2026-06-18
|
||||
|
||||
# Run archive scripts in test_run_folder
|
||||
./test_run_folder/test_run.sh
|
||||
./test_run_folder/test_run_tensor_batch.sh
|
||||
./test_run_folder/test_run_hybrid.sh
|
||||
```
|
||||
|
||||
Output is saved in `../VIDEOS/cycle7/kandang-atas/2026-06-18/output/`.
|
||||
|
||||
---
|
||||
|
||||
### B — Mortality Detection (Carcass Photo Scanning)
|
||||
|
||||
Place input photos in `../VIDEOS/cycle7/kandang-atas/mortality/`.
|
||||
|
||||
```bash
|
||||
# Run with default config
|
||||
./test_run_mortality.sh
|
||||
|
||||
# Override confidence threshold
|
||||
CONF=0.65 ./test_run_mortality.sh
|
||||
|
||||
# Run on a specific image
|
||||
./test_run_mortality.sh /path/to/photo.jpg
|
||||
```
|
||||
|
||||
Output annotated images are saved as `output_<original_name>.jpg` in the same directory.
|
||||
A `mortality_report.json` is also saved there with full detection data.
|
||||
|
||||
#### Key config options in `configs/mortality_config.yaml`
|
||||
|
||||
| Setting | Description |
|
||||
| :--- | :--- |
|
||||
| `conf` | Detection confidence threshold (0.0–1.0) |
|
||||
| `iou` | IoU NMS threshold |
|
||||
| `min_box_area_px` | Minimum bounding box area in pixels |
|
||||
| `dedupe_radius_px` | Centroid deduplication radius in pixels |
|
||||
| `two_pass` | 2x Detect & Refine pipeline (`false` by default; single-pass direct achieves higher accuracy on farm footage) |
|
||||
| `classes` | `[0]` = chicken only; ignores background/text/equipment |
|
||||
|
||||
---
|
||||
|
||||
### C — Dashboard & API Server
|
||||
|
||||
```bash
|
||||
# Start the dashboard (auto-discovers mortality directory)
|
||||
./start_dashboard.sh
|
||||
|
||||
# Custom port and directories
|
||||
PORT=9090 ./start_dashboard.sh
|
||||
|
||||
# Multiple mortality directories
|
||||
MORTALITY_DIRS="/path/to/mortality1,/path/to/mortality2" ./start_dashboard.sh
|
||||
```
|
||||
|
||||
Open in browser: **http://localhost:8080**
|
||||
|
||||
Available API endpoints:
|
||||
|
||||
| Endpoint | Description |
|
||||
| :--- | :--- |
|
||||
| `GET /api/status` | Live system status, active counting cameras & latest date |
|
||||
| `GET /api/cameras` | Live camera list from `/dev/shm` |
|
||||
| `GET /api/db/summary` | Total chickens, days, hours across all dates |
|
||||
| `GET /api/db/history` | Per-date summary, newest first |
|
||||
| `GET /api/db/date/<YYYY-MM-DD>` | Per-camera breakdown for a specific date |
|
||||
| `GET /api/db/camera/<id>` | History for a specific camera (CC1, CC2...) |
|
||||
| `GET /api/db/location/<name>` | Summary and history for a location |
|
||||
| `GET /api/mortality/latest` | Latest mortality detection report (JSON) |
|
||||
| `GET /api/mortality/history` | All mortality reports, newest first |
|
||||
| `GET /api/mortality/image/<filename>` | Serve annotated output JPEG by filename |
|
||||
| `GET /shm/<cam>/stats.json` | Live pipeline stats for a running camera |
|
||||
| `GET /shm/<cam>/frame.jpg` | Live frame snapshot from a running camera |
|
||||
|
||||
See `API.md` for full response schemas.
|
||||
|
||||
---
|
||||
|
||||
### D — Install as a System Service (Auto-Start)
|
||||
|
||||
```bash
|
||||
# Copy the service file and adjust WorkingDirectory / User if needed
|
||||
sudo cp chicken-dashboard.service /etc/systemd/system/
|
||||
|
||||
# Enable and start
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable chicken-dashboard
|
||||
sudo systemctl start chicken-dashboard
|
||||
|
||||
# Check status
|
||||
sudo systemctl status chicken-dashboard
|
||||
```
|
||||
|
||||
The service reads `start_dashboard.sh`, so it also auto-discovers the mortality directory.
|
||||
|
||||
---
|
||||
|
||||
## 6. Database
|
||||
|
||||
Results are automatically written to `db/chicken_counts.db` when `db_path` is set in the batch YAML. To store results manually from a JSON report:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src venv/bin/python store_results.py \
|
||||
../VIDEOS/cycle7/kandang-atas/2026-06-18/output/counts_2026-06-18.json \
|
||||
--location kandang-atas \
|
||||
--db db/chicken_counts.db
|
||||
```
|
||||
|
||||
Export to Excel:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src venv/bin/python export_excel_report.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Moving the Project to Another Machine
|
||||
|
||||
1. Delete the `venv/` folder before copying:
|
||||
```bash
|
||||
rm -rf venv/
|
||||
```
|
||||
2. Copy the entire project folder to the new machine.
|
||||
3. Update the `WorkingDirectory` and `ExecStart` in `chicken-dashboard.service` to the new path.
|
||||
4. Recreate the venv on the new machine:
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
venv/bin/pip install -r requirements.txt
|
||||
```
|
||||
5. All configs and Python code use relative paths and will work without any other changes.
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Chicken Counter Live Dashboard
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Uses dynamic %h (user home directory) so it works on any user account (asus, jetson, ubuntu, etc.)
|
||||
WorkingDirectory=%h/.Codes/chicken-counting-sukawarna-det
|
||||
ExecStart=%h/.Codes/chicken-counting-sukawarna-det/start_dashboard.sh
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PORT=8080
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
Executable → Regular
+8
-5
@@ -1,7 +1,7 @@
|
||||
camera_id: coop_cam_03
|
||||
source: /media/jetson/DATA/record/try-sukawarna.mp4
|
||||
source: ../record/try-sukawarna.mp4
|
||||
detection:
|
||||
model_path: /media/jetson/DATA/chicken-sukawarna/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine
|
||||
model_path: models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.onnx
|
||||
classes: [0]
|
||||
ignored_classes: [1, 2]
|
||||
conf: 0.35
|
||||
@@ -10,6 +10,9 @@ detection:
|
||||
device: "0"
|
||||
min_box_area_px: 6000
|
||||
validate_while_inside: true
|
||||
# Example is coop_cam_03 — same ID-flip guard as batch CC3.
|
||||
dedupe_radius_px: 24
|
||||
dedupe_frames: 12
|
||||
detection_zone:
|
||||
enabled: true
|
||||
buffer_above_px: 250
|
||||
@@ -18,7 +21,7 @@ detection_zone:
|
||||
tracker:
|
||||
tracker_config_path: configs/trackers/botsort_chicken.yaml
|
||||
persist: true
|
||||
track_buffer: 75
|
||||
track_buffer: 90
|
||||
roi:
|
||||
points:
|
||||
- [80, 340]
|
||||
@@ -63,7 +66,7 @@ overlay:
|
||||
- [0, 255, 255]
|
||||
display:
|
||||
show_window: false
|
||||
output_path: /media/jetson/DATA/try-sukawarna-vis.mp4
|
||||
output_path: ../try-sukawarna-vis.mp4
|
||||
encoder: auto
|
||||
output_bitrate_kbps: 4000
|
||||
codec_preference: [avc1, mp4v, H264]
|
||||
@@ -75,5 +78,5 @@ feedback:
|
||||
enabled: true
|
||||
every_n_frames: 900
|
||||
save_images: false
|
||||
image_output_dir: /media/jetson/DATA/chicken-sukawarna/output/checkpoints
|
||||
image_output_dir: output/checkpoints
|
||||
log_to_terminal: true
|
||||
Executable → Regular
+21
-12
@@ -1,31 +1,36 @@
|
||||
batch:
|
||||
root_dir: /media/jetson/DATA/.Codes/VIDEOS/cycle7/kandang-atas
|
||||
root_dir: ../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
|
||||
location: kandang-atas
|
||||
db_path: db/chicken_counts.db
|
||||
|
||||
defaults:
|
||||
detection:
|
||||
model_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine
|
||||
model_path: models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.onnx #pt = accuracy, onnx = speed
|
||||
classes: [0]
|
||||
ignored_classes: [1, 2]
|
||||
conf: 0.55
|
||||
conf: 0.35
|
||||
iou: 0.55
|
||||
imgsz: 640
|
||||
device: "0"
|
||||
min_box_area_px: 5000
|
||||
min_box_area_px: 3000
|
||||
validate_while_inside: true
|
||||
# Dedupe off by default; enabled only on CC2/CC3 (see cameras below).
|
||||
dedupe_radius_px: 0
|
||||
dedupe_frames: 12
|
||||
detection_zone:
|
||||
enabled: true
|
||||
buffer_above_px: 250
|
||||
buffer_below_px: 250
|
||||
show_in_overlay: true
|
||||
tracker:
|
||||
tracker_config_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/configs/trackers/botsort_chicken.yaml
|
||||
tracker_config_path: configs/trackers/botsort_chicken.yaml
|
||||
persist: true
|
||||
track_buffer: 45
|
||||
track_buffer: 90
|
||||
gate:
|
||||
mode: two_line
|
||||
lines_y: [420, 730]
|
||||
@@ -40,7 +45,7 @@ defaults:
|
||||
debounce_frames: 12
|
||||
min_features: 60
|
||||
max_corners: 80
|
||||
stride_frames: 3
|
||||
stride_frames: 2
|
||||
flow_scale: 0.5
|
||||
quality_level: 0.01
|
||||
min_distance: 8
|
||||
@@ -49,11 +54,10 @@ defaults:
|
||||
show_boxes: true
|
||||
show_track_trails: false
|
||||
trail_length: 20
|
||||
show_center_marker: false
|
||||
show_center_marker: true
|
||||
show_track_ring: false
|
||||
count_anchor: [780, 120]
|
||||
inside_box_only: true
|
||||
validated_only: true
|
||||
pending_blink: true
|
||||
pending_colors:
|
||||
- [255, 255, 0]
|
||||
@@ -61,7 +65,7 @@ defaults:
|
||||
display:
|
||||
show_window: false
|
||||
encoder: auto
|
||||
output_bitrate_kbps: 2000
|
||||
output_bitrate_kbps: 4000
|
||||
codec_preference: [avc1, mp4v, H264]
|
||||
performance:
|
||||
half: false
|
||||
@@ -81,7 +85,7 @@ defaults:
|
||||
inset_right_px: 60
|
||||
inset_top_px: 0
|
||||
inset_bottom_px: 0
|
||||
min_overlap_ratio: 0.35
|
||||
min_overlap_ratio: 0.30
|
||||
|
||||
cameras:
|
||||
CC1:
|
||||
@@ -97,7 +101,9 @@ cameras:
|
||||
camera_num: 2
|
||||
count_anchor: [900, 120]
|
||||
detection:
|
||||
min_box_area_px: 3000
|
||||
# ~5% double-count from ID flips; tight radius only.
|
||||
dedupe_radius_px: 24
|
||||
dedupe_frames: 12
|
||||
roi:
|
||||
points:
|
||||
- [20, 380]
|
||||
@@ -107,6 +113,9 @@ cameras:
|
||||
CC3:
|
||||
camera_num: 3
|
||||
count_anchor: [900, 120]
|
||||
detection:
|
||||
dedupe_radius_px: 24
|
||||
dedupe_frames: 12
|
||||
roi:
|
||||
points:
|
||||
- [20, 330]
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
batch:
|
||||
root_dir: /home/asus/.Codes/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/asus/.Codes/chicken-counting-sukawarna-det/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: 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/asus/.Codes/chicken-counting-sukawarna-det/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
|
||||
stream:
|
||||
enabled: true
|
||||
shm_dir: /dev/shm
|
||||
interval_frames: 5
|
||||
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:
|
||||
CC2:
|
||||
camera_num: 2
|
||||
count_anchor: [900, 120]
|
||||
roi:
|
||||
points:
|
||||
- [20, 380]
|
||||
- [1880, 380]
|
||||
- [1880, 720]
|
||||
- [20, 720]
|
||||
@@ -1,5 +1,5 @@
|
||||
batch:
|
||||
root_dir: /media/jetson/DATA/.Codes/VIDEOS/cycle7/kandang-atas
|
||||
root_dir: /home/asus/.Codes/VIDEOS/cycle7/kandang-atas
|
||||
camera_glob: "kandang_*_camera_{num}_*.mp4"
|
||||
output_subdir: output
|
||||
compress_max_mb: 200
|
||||
@@ -8,7 +8,7 @@ batch:
|
||||
|
||||
defaults:
|
||||
detection:
|
||||
model_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine
|
||||
model_path: /home/asus/.Codes/chicken-counting-sukawarna-det/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine
|
||||
classes: [0]
|
||||
ignored_classes: [1, 2]
|
||||
conf: 0.35
|
||||
@@ -23,7 +23,7 @@ defaults:
|
||||
buffer_below_px: 250
|
||||
show_in_overlay: true
|
||||
tracker:
|
||||
tracker_config_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/configs/trackers/botsort_chicken.yaml
|
||||
tracker_config_path: /home/asus/.Codes/chicken-counting-sukawarna-det/configs/trackers/botsort_chicken.yaml
|
||||
persist: true
|
||||
track_buffer: 75
|
||||
gate:
|
||||
@@ -0,0 +1,121 @@
|
||||
batch:
|
||||
root_dir: /home/asus/.Codes/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/asus/.Codes/chicken-counting-sukawarna-det/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: 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/asus/.Codes/chicken-counting-sukawarna-det/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
|
||||
stream:
|
||||
enabled: true
|
||||
shm_dir: /dev/shm
|
||||
interval_frames: 5
|
||||
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,151 @@
|
||||
batch:
|
||||
root_dir: "../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
|
||||
location: kandang-atas
|
||||
db_path: db/chicken_counts.db
|
||||
cycle_start_date: "2026-05-22" #day 0
|
||||
execution_mode: parallel_processes # parallel_processes (default) | tensor_batching | hybrid
|
||||
|
||||
stages:
|
||||
early_cycle:
|
||||
day_min: 0
|
||||
day_max: 15
|
||||
detection:
|
||||
conf: 0.1
|
||||
min_box_area_px: 100
|
||||
roi:
|
||||
min_overlap_ratio: 0.1 # 0.25
|
||||
mid_cycle:
|
||||
day_min: 16
|
||||
day_max: 999
|
||||
|
||||
defaults:
|
||||
detection:
|
||||
model_path: models/NUC5070_chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine # TensorRT engine (RTX 5070)
|
||||
classes: [0]
|
||||
ignored_classes: [1, 2]
|
||||
conf: 0.40 # mild lift vs 0.35; 0.55 was undercounting
|
||||
iou: 0.55
|
||||
imgsz: 640
|
||||
device: "0"
|
||||
min_box_area_px: 3000
|
||||
validate_while_inside: true
|
||||
dedupe_radius_px: 0 # off for CC1/CC4; CC2/CC3 enable below
|
||||
dedupe_frames: 12
|
||||
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: 90
|
||||
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: 40 # ↓ 60 → less optical flow computation
|
||||
max_corners: 50 # ↓ 80 → fewer corner featuresis
|
||||
stride_frames: 3 # ↑ 2 → motion detection every 3rd frame
|
||||
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: false # ✗ → save 1 circle per chicken
|
||||
show_track_ring: false
|
||||
count_anchor: [780, 120]
|
||||
inside_box_only: true
|
||||
validated_only: true # NEW → only draw counted chickens
|
||||
pending_blink: true
|
||||
pending_colors:
|
||||
- [255, 255, 0]
|
||||
- [0, 255, 255]
|
||||
display:
|
||||
show_window: false
|
||||
encoder: auto
|
||||
output_bitrate_kbps: 2000 # ↓ 4000 → faster encoding
|
||||
codec_preference: [avc1, mp4v, H264]
|
||||
performance:
|
||||
half: false
|
||||
overlay_buffer_reuse: true
|
||||
inference_stride: 2
|
||||
stream:
|
||||
enabled: true # required for dashboard live feed
|
||||
shm_dir: /dev/shm
|
||||
interval_frames: 5
|
||||
feedback:
|
||||
enabled: true
|
||||
every_n_frames: 3000
|
||||
save_images: false # ✗ → disable checkpoint JPEG I/O spikes
|
||||
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]
|
||||
detection:
|
||||
min_box_area_px: 2500
|
||||
conf: 0.35
|
||||
roi:
|
||||
points:
|
||||
- [250, 330]
|
||||
- [1650, 330]
|
||||
- [1650, 720]
|
||||
- [250, 720]
|
||||
CC2:
|
||||
camera_num: 2
|
||||
count_anchor: [900, 120]
|
||||
detection:
|
||||
conf: 0.5 # From 0.45
|
||||
dedupe_radius_px: 32
|
||||
dedupe_frames: 24 # From 18
|
||||
roi:
|
||||
points:
|
||||
- [20, 380]
|
||||
- [1880, 380]
|
||||
- [1880, 720]
|
||||
- [20, 720]
|
||||
CC3:
|
||||
camera_num: 3
|
||||
count_anchor: [900, 120]
|
||||
detection:
|
||||
dedupe_radius_px: 24
|
||||
dedupe_frames: 16 # From 12
|
||||
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,17 @@
|
||||
# Floor Configuration for K1-L1
|
||||
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
|
||||
extends: ../cycle7_batch_optimized.yaml
|
||||
|
||||
batch:
|
||||
location: "K1-L1"
|
||||
root_dir: "../VIDEOS/cycle7/K1-L1"
|
||||
|
||||
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
|
||||
# cameras:
|
||||
# CC1:
|
||||
# roi:
|
||||
# points:
|
||||
# - [250, 330]
|
||||
# - [1650, 330]
|
||||
# - [1650, 720]
|
||||
# - [250, 720]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Floor Configuration for K1-L2
|
||||
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
|
||||
extends: ../cycle7_batch_optimized.yaml
|
||||
|
||||
batch:
|
||||
location: "K1-L2"
|
||||
root_dir: "../VIDEOS/cycle7/K1-L2"
|
||||
|
||||
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
|
||||
# cameras:
|
||||
# CC1:
|
||||
# roi:
|
||||
# points:
|
||||
# - [250, 330]
|
||||
# - [1650, 330]
|
||||
# - [1650, 720]
|
||||
# - [250, 720]
|
||||
@@ -0,0 +1,7 @@
|
||||
# Floor Configuration for K1-L3
|
||||
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
|
||||
extends: ../cycle7_batch_optimized.yaml
|
||||
|
||||
batch:
|
||||
location: "K1-L3"
|
||||
root_dir: "../VIDEOS/cycle7/K1-L3"
|
||||
@@ -0,0 +1,17 @@
|
||||
# Floor Configuration for K2-L1
|
||||
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
|
||||
extends: ../cycle7_batch_optimized.yaml
|
||||
|
||||
batch:
|
||||
location: "K2-L1"
|
||||
root_dir: "../VIDEOS/cycle7/K2-L1"
|
||||
|
||||
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
|
||||
# cameras:
|
||||
# CC1:
|
||||
# roi:
|
||||
# points:
|
||||
# - [250, 330]
|
||||
# - [1650, 330]
|
||||
# - [1650, 720]
|
||||
# - [250, 720]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Floor Configuration for K2-L2
|
||||
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
|
||||
extends: ../cycle7_batch_optimized.yaml
|
||||
|
||||
batch:
|
||||
location: "K2-L2"
|
||||
root_dir: "../VIDEOS/cycle7/K2-L2"
|
||||
|
||||
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
|
||||
# cameras:
|
||||
# CC1:
|
||||
# roi:
|
||||
# points:
|
||||
# - [250, 330]
|
||||
# - [1650, 330]
|
||||
# - [1650, 720]
|
||||
# - [250, 720]
|
||||
@@ -0,0 +1,7 @@
|
||||
# Floor Configuration for K2-L3
|
||||
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
|
||||
extends: ../cycle7_batch_optimized.yaml
|
||||
|
||||
batch:
|
||||
location: "K2-L3"
|
||||
root_dir: "../VIDEOS/cycle7/K2-L3"
|
||||
@@ -0,0 +1,17 @@
|
||||
# Floor Configuration for K3-L1
|
||||
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
|
||||
extends: ../cycle7_batch_optimized.yaml
|
||||
|
||||
batch:
|
||||
location: "K3-L1"
|
||||
root_dir: "../VIDEOS/cycle7/K3-L1"
|
||||
|
||||
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
|
||||
# cameras:
|
||||
# CC1:
|
||||
# roi:
|
||||
# points:
|
||||
# - [250, 330]
|
||||
# - [1650, 330]
|
||||
# - [1650, 720]
|
||||
# - [250, 720]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Floor Configuration for K3-L2
|
||||
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
|
||||
extends: ../cycle7_batch_optimized.yaml
|
||||
|
||||
batch:
|
||||
location: "K3-L2"
|
||||
root_dir: "../VIDEOS/cycle7/K3-L2"
|
||||
|
||||
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
|
||||
# cameras:
|
||||
# CC1:
|
||||
# roi:
|
||||
# points:
|
||||
# - [250, 330]
|
||||
# - [1650, 330]
|
||||
# - [1650, 720]
|
||||
# - [250, 720]
|
||||
@@ -0,0 +1,7 @@
|
||||
# Floor Configuration for K3-L3
|
||||
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
|
||||
extends: ../cycle7_batch_optimized.yaml
|
||||
|
||||
batch:
|
||||
location: "K3-L3"
|
||||
root_dir: "../VIDEOS/cycle7/K3-L3"
|
||||
@@ -0,0 +1,17 @@
|
||||
# Floor Configuration for K4-L1
|
||||
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
|
||||
extends: ../cycle7_batch_optimized.yaml
|
||||
|
||||
batch:
|
||||
location: "K4-L1"
|
||||
root_dir: "../VIDEOS/cycle7/K4-L1"
|
||||
|
||||
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
|
||||
# cameras:
|
||||
# CC1:
|
||||
# roi:
|
||||
# points:
|
||||
# - [250, 330]
|
||||
# - [1650, 330]
|
||||
# - [1650, 720]
|
||||
# - [250, 720]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Floor Configuration for K4-L2
|
||||
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
|
||||
extends: ../cycle7_batch_optimized.yaml
|
||||
|
||||
batch:
|
||||
location: "K4-L2"
|
||||
root_dir: "../VIDEOS/cycle7/K4-L2"
|
||||
|
||||
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
|
||||
# cameras:
|
||||
# CC1:
|
||||
# roi:
|
||||
# points:
|
||||
# - [250, 330]
|
||||
# - [1650, 330]
|
||||
# - [1650, 720]
|
||||
# - [250, 720]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Floor Configuration for K5-L1
|
||||
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
|
||||
extends: ../cycle7_batch_optimized.yaml
|
||||
|
||||
batch:
|
||||
location: "K5-L1"
|
||||
root_dir: "../VIDEOS/cycle7/K5-L1"
|
||||
|
||||
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
|
||||
# cameras:
|
||||
# CC1:
|
||||
# roi:
|
||||
# points:
|
||||
# - [250, 330]
|
||||
# - [1650, 330]
|
||||
# - [1650, 720]
|
||||
# - [250, 720]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Floor Configuration for K5-L2
|
||||
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
|
||||
extends: ../cycle7_batch_optimized.yaml
|
||||
|
||||
batch:
|
||||
location: "K5-L2"
|
||||
root_dir: "../VIDEOS/cycle7/K5-L2"
|
||||
|
||||
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
|
||||
# cameras:
|
||||
# CC1:
|
||||
# roi:
|
||||
# points:
|
||||
# - [250, 330]
|
||||
# - [1650, 330]
|
||||
# - [1650, 720]
|
||||
# - [250, 720]
|
||||
@@ -0,0 +1,7 @@
|
||||
# Floor Configuration for kandang-atas
|
||||
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
|
||||
extends: ../cycle7_batch_optimized.yaml
|
||||
|
||||
batch:
|
||||
location: "kandang-atas"
|
||||
root_dir: "../VIDEOS/cycle7/kandang-atas"
|
||||
@@ -0,0 +1,13 @@
|
||||
# Configuration for Mortality Chicken Carcass Detection & Counting
|
||||
mortality:
|
||||
input_dir: "../VIDEOS/cycle7/kandang-atas/mortality"
|
||||
output_dir: null # null = save output_<File Name> in input directory
|
||||
model_path: "models/chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt"
|
||||
device: "0" # "0" for GPU acceleration, or "cpu"
|
||||
conf: 0.7
|
||||
iou: 0.8
|
||||
min_box_area_px: 1500
|
||||
dedupe_radius_px: 30.0
|
||||
two_pass: false # Enable Union 2x Detect & Refine crop pipeline
|
||||
classes: [0] # Class 0 = chicken ONLY; ignores background/text/equipment (classes 1 not-chicken, 2 half-chicken)
|
||||
imgsz: 640
|
||||
Executable → Regular
+6
-6
@@ -1,12 +1,12 @@
|
||||
tracker_type: botsort
|
||||
track_high_thresh: 0.65
|
||||
track_low_thresh: 0.3
|
||||
new_track_thresh: 0.7
|
||||
track_buffer: 75
|
||||
match_thresh: 0.9
|
||||
track_high_thresh: 0.5
|
||||
track_low_thresh: 0.1
|
||||
new_track_thresh: 0.65
|
||||
track_buffer: 90
|
||||
match_thresh: 0.8
|
||||
fuse_score: true
|
||||
gmc_method: none
|
||||
proximity_thresh: 0.5
|
||||
appearance_thresh: 0.25
|
||||
with_reid: false
|
||||
model: auto
|
||||
model: auto
|
||||
@@ -1,64 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(chicken_counter VERSION 0.1.0 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
if(NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE Release)
|
||||
endif()
|
||||
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE)
|
||||
|
||||
find_package(OpenCV 4.0 REQUIRED COMPONENTS core imgproc video videoio highgui imgcodecs dnn)
|
||||
find_package(nlohmann_json 3.0 REQUIRED)
|
||||
find_package(yaml-cpp REQUIRED)
|
||||
find_package(CUDAToolkit REQUIRED)
|
||||
|
||||
find_library(NVINFER_LIB nvinfer PATHS /usr/lib/aarch64-linux-gnu REQUIRED)
|
||||
find_library(NVONNX_LIB nvonnxparser PATHS /usr/lib/aarch64-linux-gnu REQUIRED)
|
||||
|
||||
set(COMMON_LIBS
|
||||
opencv_core opencv_imgproc opencv_video opencv_videoio opencv_highgui opencv_imgcodecs opencv_dnn
|
||||
nlohmann_json::nlohmann_json
|
||||
yaml-cpp
|
||||
CUDA::cudart
|
||||
${NVINFER_LIB}
|
||||
${NVONNX_LIB}
|
||||
)
|
||||
|
||||
add_library(chicken_counter_lib STATIC
|
||||
src/pipeline.cpp
|
||||
src/batch_runner.cpp
|
||||
)
|
||||
target_include_directories(chicken_counter_lib PUBLIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||
/usr/include/aarch64-linux-gnu
|
||||
)
|
||||
target_link_libraries(chicken_counter_lib PUBLIC ${COMMON_LIBS})
|
||||
target_compile_options(chicken_counter_lib PRIVATE -O3 -march=armv8.2-a+fp16+dotprod -flto -DNDEBUG)
|
||||
|
||||
add_executable(chicken_counter_cli
|
||||
src/main.cpp
|
||||
)
|
||||
target_link_libraries(chicken_counter_cli PRIVATE chicken_counter_lib)
|
||||
target_link_options(chicken_counter_cli PRIVATE -Wl,--strip-all)
|
||||
|
||||
add_executable(test_config
|
||||
tests/test_config.cpp
|
||||
)
|
||||
target_link_libraries(test_config PRIVATE chicken_counter_lib)
|
||||
|
||||
add_executable(test_modules
|
||||
tests/test_modules.cpp
|
||||
)
|
||||
target_link_libraries(test_modules PRIVATE chicken_counter_lib)
|
||||
|
||||
add_executable(chicken_counter_dashboard
|
||||
src/dashboard.cpp
|
||||
)
|
||||
target_link_libraries(chicken_counter_dashboard PRIVATE pthread)
|
||||
|
||||
enable_testing()
|
||||
add_test(NAME config COMMAND test_config)
|
||||
add_test(NAME modules COMMAND test_modules)
|
||||
@@ -1,102 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <regex>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "chicken_counter/config.hpp"
|
||||
|
||||
namespace cc {
|
||||
|
||||
struct CameraDiscoveryResult {
|
||||
std::unordered_map<std::string, std::string> found;
|
||||
std::unordered_map<std::string, std::string> skipped;
|
||||
};
|
||||
|
||||
inline std::string replace_glob_placeholder(const std::string& pattern, int num) {
|
||||
std::string s = pattern;
|
||||
std::string token = "{num}";
|
||||
size_t pos = s.find(token);
|
||||
if (pos != std::string::npos) {
|
||||
s.replace(pos, token.size(), std::to_string(num));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
inline std::vector<std::string> glob_filenames(const std::string& dir, const std::string& pattern) {
|
||||
namespace fs = std::filesystem;
|
||||
std::vector<std::string> matches;
|
||||
std::string regex_str = "^";
|
||||
for (char c : pattern) {
|
||||
if (c == '*') regex_str += ".*";
|
||||
else if (c == '?') regex_str += ".";
|
||||
else if (c == '.' || c == '[' || c == ']' || c == '(' || c == ')' || c == '{' || c == '}')
|
||||
regex_str += std::string("\\") + c;
|
||||
else regex_str += c;
|
||||
}
|
||||
regex_str += "$";
|
||||
std::regex re(regex_str);
|
||||
|
||||
for (auto& entry : fs::directory_iterator(dir)) {
|
||||
if (!entry.is_regular_file()) continue;
|
||||
std::string fname = entry.path().filename().string();
|
||||
if (std::regex_match(fname, re))
|
||||
matches.push_back(entry.path().string());
|
||||
}
|
||||
std::sort(matches.begin(), matches.end());
|
||||
return matches;
|
||||
}
|
||||
|
||||
inline CameraDiscoveryResult discover_camera_videos(
|
||||
const std::string& day_dir,
|
||||
const BatchSettings& settings)
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
if (!fs::is_directory(day_dir))
|
||||
throw std::runtime_error("Daily input folder does not exist: " + day_dir);
|
||||
|
||||
CameraDiscoveryResult result;
|
||||
int total = static_cast<int>(settings.cameras.size());
|
||||
|
||||
using pair_t = std::pair<std::string, CameraPreset>;
|
||||
std::vector<pair_t> sorted_cams(settings.cameras.begin(), settings.cameras.end());
|
||||
std::sort(sorted_cams.begin(), sorted_cams.end(),
|
||||
[](const pair_t& a, const pair_t& b) { return a.second.camera_num < b.second.camera_num; });
|
||||
|
||||
for (const auto& [camera_id, preset] : sorted_cams) {
|
||||
std::string pattern = replace_glob_placeholder(settings.batch.camera_glob, preset.camera_num);
|
||||
auto matches = glob_filenames(day_dir, pattern);
|
||||
if (matches.empty()) {
|
||||
result.skipped[camera_id] = "video_not_found";
|
||||
continue;
|
||||
}
|
||||
if (matches.size() > 1) {
|
||||
result.skipped[camera_id] = "multiple_matches";
|
||||
continue;
|
||||
}
|
||||
result.found[camera_id] = matches[0];
|
||||
}
|
||||
|
||||
if (result.found.empty()) {
|
||||
std::string summary;
|
||||
for (const auto& [id, reason] : result.skipped) summary += id + " (" + reason + "), ";
|
||||
throw std::runtime_error("No camera videos found in " + day_dir + ". Skipped: " + summary);
|
||||
}
|
||||
|
||||
int found_count = static_cast<int>(result.found.size());
|
||||
if (!result.skipped.empty()) {
|
||||
std::string summary;
|
||||
for (const auto& [id, reason] : result.skipped) summary += id + " (" + reason + "), ";
|
||||
std::cerr << "[batch] discovered " << found_count << "/" << total
|
||||
<< " cameras; skipped: " << summary << "\n";
|
||||
} else {
|
||||
std::cerr << "[batch] discovered " << found_count << "/" << total << " cameras\n";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace cc
|
||||
@@ -1,15 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "chicken_counter/config.hpp"
|
||||
|
||||
namespace cc {
|
||||
|
||||
std::string run_daily_batch(const BatchSettings& settings,
|
||||
const std::string& date = "",
|
||||
bool verbose = false,
|
||||
bool no_video = false,
|
||||
bool show_progress = false);
|
||||
|
||||
} // namespace cc
|
||||
@@ -1,22 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
#include <opencv2/videoio.hpp>
|
||||
|
||||
namespace cc {
|
||||
|
||||
inline cv::VideoCapture open_capture(const std::string& source) {
|
||||
cv::VideoCapture cap;
|
||||
if (source.size() == 1 && std::isdigit(source[0])) {
|
||||
cap.open(std::stoi(source));
|
||||
} else {
|
||||
cap.open(source);
|
||||
}
|
||||
if (!cap.isOpened())
|
||||
throw std::runtime_error("Unable to open video source: " + source);
|
||||
return cap;
|
||||
}
|
||||
|
||||
} // namespace cc
|
||||
@@ -1,106 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
#include <opencv2/videoio.hpp>
|
||||
|
||||
namespace cc {
|
||||
|
||||
inline double video_duration_seconds(const std::string& path) {
|
||||
cv::VideoCapture cap(path);
|
||||
if (!cap.isOpened())
|
||||
throw std::runtime_error("Unable to open video for duration probe: " + path);
|
||||
double fc = cap.get(cv::CAP_PROP_FRAME_COUNT);
|
||||
double fps = cap.get(cv::CAP_PROP_FPS);
|
||||
cap.release();
|
||||
if (fps > 0 && fc > 0) return fc / fps;
|
||||
throw std::runtime_error("Unable to determine duration for video: " + path);
|
||||
}
|
||||
|
||||
inline double file_size_mb(const std::string& path) {
|
||||
return static_cast<double>(std::filesystem::file_size(path)) / (1024.0 * 1024.0);
|
||||
}
|
||||
|
||||
inline void run_ffmpeg(const std::vector<std::string>& command) {
|
||||
std::string cmd;
|
||||
for (const auto& arg : command) cmd += arg + " ";
|
||||
cmd = cmd.substr(0, cmd.size() - 1) + " 2>&1";
|
||||
int ret = std::system(cmd.c_str());
|
||||
if (ret != 0)
|
||||
throw std::runtime_error("ffmpeg failed with code " + std::to_string(ret));
|
||||
}
|
||||
|
||||
inline double compress_video_to_target(
|
||||
const std::string& input_path,
|
||||
const std::string& output_path,
|
||||
int max_mb = 200,
|
||||
int max_attempts = 3)
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
if (!fs::is_regular_file(input_path))
|
||||
throw std::runtime_error("Input video not found: " + input_path);
|
||||
|
||||
fs::create_directories(fs::path(output_path).parent_path());
|
||||
|
||||
double duration = video_duration_seconds(input_path);
|
||||
if (duration <= 0)
|
||||
throw std::runtime_error("Invalid video duration for " + input_path);
|
||||
|
||||
int target_kbps = std::max(300, static_cast<int>((max_mb * 8192) / duration * 0.92));
|
||||
|
||||
for (int attempt = 0; attempt < max_attempts; ++attempt) {
|
||||
int attempt_kbps = std::max(300,
|
||||
static_cast<int>(target_kbps * std::pow(0.85, attempt)));
|
||||
|
||||
if (fs::exists(output_path)) fs::remove(output_path);
|
||||
|
||||
std::vector<std::vector<std::string>> codec_attempts = {
|
||||
{"-c:v", "h264_nvmpi", "-b:v", std::to_string(attempt_kbps) + "k",
|
||||
"-maxrate", std::to_string(attempt_kbps) + "k",
|
||||
"-bufsize", std::to_string(attempt_kbps * 2) + "k"},
|
||||
{"-c:v", "libx264", "-preset", "fast",
|
||||
"-b:v", std::to_string(attempt_kbps) + "k",
|
||||
"-maxrate", std::to_string(attempt_kbps) + "k",
|
||||
"-bufsize", std::to_string(attempt_kbps * 2) + "k"}
|
||||
};
|
||||
|
||||
bool succeeded = false;
|
||||
for (const auto& cargs : codec_attempts) {
|
||||
std::vector<std::string> cmd = {"ffmpeg", "-y", "-i", input_path};
|
||||
cmd.insert(cmd.end(), cargs.begin(), cargs.end());
|
||||
cmd.push_back("-c:a");
|
||||
cmd.push_back("copy");
|
||||
cmd.push_back(output_path);
|
||||
try {
|
||||
run_ffmpeg(cmd);
|
||||
succeeded = true;
|
||||
break;
|
||||
} catch (const std::runtime_error&) {
|
||||
if (fs::exists(output_path)) fs::remove(output_path);
|
||||
}
|
||||
}
|
||||
if (!succeeded)
|
||||
throw std::runtime_error("Unable to compress video: " + input_path);
|
||||
|
||||
double size_mb = file_size_mb(output_path);
|
||||
std::cerr << "[compress] " << fs::path(output_path).filename().string()
|
||||
<< ": " << size_mb << " MB (attempt " << (attempt + 1)
|
||||
<< ", target " << attempt_kbps << " kbps)\n";
|
||||
|
||||
if (size_mb <= max_mb) return size_mb;
|
||||
}
|
||||
|
||||
double final_size = file_size_mb(output_path);
|
||||
if (final_size > max_mb)
|
||||
throw std::runtime_error("Compressed video exceeds " + std::to_string(max_mb)
|
||||
+ " MB: " + output_path);
|
||||
return final_size;
|
||||
}
|
||||
|
||||
} // namespace cc
|
||||
@@ -1,617 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <opencv2/core/types.hpp>
|
||||
#include <yaml-cpp/yaml.h>
|
||||
|
||||
#include "chicken_counter/types.hpp"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cv::Point2i ↔ nlohmann::json (serialised as [x, y])
|
||||
// ---------------------------------------------------------------------------
|
||||
namespace cv {
|
||||
inline void to_json(nlohmann::json& j, const Point2i& p) { j = {p.x, p.y}; }
|
||||
inline void from_json(const nlohmann::json& j, Point2i& p) {
|
||||
p.x = j.at(0).get<int>();
|
||||
p.y = j.at(1).get<int>();
|
||||
}
|
||||
inline void to_json(nlohmann::json& j, const Scalar& s) { j = {s[0], s[1], s[2]}; }
|
||||
inline void from_json(const nlohmann::json& j, Scalar& s) {
|
||||
s = Scalar(j.at(0).get<double>(), j.at(1).get<double>(), j.at(2).get<double>());
|
||||
}
|
||||
} // namespace cv
|
||||
|
||||
namespace cc {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// YAML::Node → nlohmann::json
|
||||
// ---------------------------------------------------------------------------
|
||||
inline nlohmann::json yaml_to_json(const YAML::Node& node) {
|
||||
if (node.IsNull()) return nullptr;
|
||||
if (node.IsScalar()) {
|
||||
std::string tag = node.Tag();
|
||||
if (tag == "!") return node.as<std::string>();
|
||||
try {
|
||||
double dval = node.as<double>();
|
||||
int ival = static_cast<int>(dval);
|
||||
if (dval == static_cast<double>(ival)) return ival;
|
||||
return dval;
|
||||
} catch (const YAML::BadConversion&) {
|
||||
std::string val = node.as<std::string>();
|
||||
if (val == "true" || val == "True" || val == "yes" || val == "Yes")
|
||||
return true;
|
||||
if (val == "false" || val == "False" || val == "no" || val == "No")
|
||||
return false;
|
||||
if (val == "null" || val == "Null" || val == "NULL" || val == "~")
|
||||
return nullptr;
|
||||
return val;
|
||||
}
|
||||
}
|
||||
if (node.IsSequence()) {
|
||||
nlohmann::json arr = nlohmann::json::array();
|
||||
for (const auto& item : node) arr.push_back(yaml_to_json(item));
|
||||
return arr;
|
||||
}
|
||||
if (node.IsMap()) {
|
||||
nlohmann::json obj = nlohmann::json::object();
|
||||
for (const auto& kv : node) obj[kv.first.as<std::string>()] = yaml_to_json(kv.second);
|
||||
return obj;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config structs (no std::optional – nlohmann 3.10 compatibility)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct DetectionConfig {
|
||||
std::string model_path;
|
||||
std::vector<int> classes = {0};
|
||||
std::vector<int> ignored_classes = {1, 2};
|
||||
float conf = 0.35f;
|
||||
float iou = 0.55f;
|
||||
int imgsz = 640;
|
||||
std::string device; // empty = auto
|
||||
int min_box_area_px = 0;
|
||||
bool validate_while_inside = true;
|
||||
};
|
||||
|
||||
inline void to_json(nlohmann::json& j, const DetectionConfig& c) {
|
||||
j = {
|
||||
{"model_path", c.model_path},
|
||||
{"classes", c.classes},
|
||||
{"ignored_classes", c.ignored_classes},
|
||||
{"conf", c.conf}, {"iou", c.iou},
|
||||
{"imgsz", c.imgsz},
|
||||
{"min_box_area_px", c.min_box_area_px},
|
||||
{"validate_while_inside", c.validate_while_inside}
|
||||
};
|
||||
if (!c.device.empty()) j["device"] = c.device;
|
||||
}
|
||||
inline void from_json(const nlohmann::json& j, DetectionConfig& c) {
|
||||
j.at("model_path").get_to(c.model_path);
|
||||
c.classes = j.value("classes", std::vector<int>{0});
|
||||
c.ignored_classes = j.value("ignored_classes", std::vector<int>{1, 2});
|
||||
c.conf = j.value("conf", 0.35f);
|
||||
c.iou = j.value("iou", 0.55f);
|
||||
c.imgsz = j.value("imgsz", 640);
|
||||
if (j.contains("device")) {
|
||||
if (j["device"].is_string()) c.device = j["device"];
|
||||
else c.device = j["device"].dump();
|
||||
}
|
||||
c.min_box_area_px = j.value("min_box_area_px", 0);
|
||||
c.validate_while_inside = j.value("validate_while_inside", true);
|
||||
}
|
||||
|
||||
struct RoiConfig {
|
||||
std::vector<cv::Point2i> points;
|
||||
int inset_left_px = 0;
|
||||
int inset_right_px = 0;
|
||||
int inset_top_px = 0;
|
||||
int inset_bottom_px = 0;
|
||||
float min_overlap_ratio = 0.0f;
|
||||
|
||||
bool is_polygon() const { return points.size() > 2; }
|
||||
|
||||
cv::Rect bounding_rect() const {
|
||||
if (points.empty()) return {};
|
||||
int x1 = points[0].x, y1 = points[0].y, x2 = x1, y2 = y1;
|
||||
for (const auto& p : points) {
|
||||
if (p.x < x1) x1 = p.x; if (p.y < y1) y1 = p.y;
|
||||
if (p.x > x2) x2 = p.x; if (p.y > y2) y2 = p.y;
|
||||
}
|
||||
return cv::Rect(x1, y1, x2 - x1, y2 - y1);
|
||||
}
|
||||
|
||||
std::vector<cv::Point2i> counting_polygon() const {
|
||||
auto br = bounding_rect();
|
||||
int x_min = br.x + inset_left_px;
|
||||
int x_max = br.x + br.width - inset_right_px;
|
||||
int y_min = br.y + inset_top_px;
|
||||
int y_max = br.y + br.height - inset_bottom_px;
|
||||
|
||||
const int min_w = 20, min_h = 20;
|
||||
if (x_max - x_min < min_w) {
|
||||
int cx = (x_min + x_max) / 2;
|
||||
x_min = cx - min_w / 2; x_max = cx + min_w / 2;
|
||||
}
|
||||
if (y_max - y_min < min_h) {
|
||||
int cy = (y_min + y_max) / 2;
|
||||
y_min = cy - min_h / 2; y_max = cy + min_h / 2;
|
||||
}
|
||||
return {{x_min, y_min}, {x_max, y_min}, {x_max, y_max}, {x_min, y_max}};
|
||||
}
|
||||
|
||||
cv::Rect counting_rect() const {
|
||||
auto poly = counting_polygon();
|
||||
if (poly.empty()) return {};
|
||||
int x1 = poly[0].x, y1 = poly[0].y, x2 = x1, y2 = y1;
|
||||
for (const auto& p : poly) {
|
||||
if (p.x < x1) x1 = p.x; if (p.y < y1) y1 = p.y;
|
||||
if (p.x > x2) x2 = p.x; if (p.y > y2) y2 = p.y;
|
||||
}
|
||||
return cv::Rect(x1, y1, x2 - x1, y2 - y1);
|
||||
}
|
||||
};
|
||||
inline void to_json(nlohmann::json& j, const RoiConfig& c) {
|
||||
j = {
|
||||
{"points", c.points},
|
||||
{"inset_left_px", c.inset_left_px},
|
||||
{"inset_right_px", c.inset_right_px},
|
||||
{"inset_top_px", c.inset_top_px},
|
||||
{"inset_bottom_px", c.inset_bottom_px},
|
||||
{"min_overlap_ratio", c.min_overlap_ratio}
|
||||
};
|
||||
}
|
||||
inline void from_json(const nlohmann::json& j, RoiConfig& c) {
|
||||
c.points = j.at("points").get<std::vector<cv::Point2i>>();
|
||||
c.inset_left_px = j.value("inset_left_px", 0);
|
||||
c.inset_right_px = j.value("inset_right_px", 0);
|
||||
c.inset_top_px = j.value("inset_top_px", 0);
|
||||
c.inset_bottom_px = j.value("inset_bottom_px", 0);
|
||||
c.min_overlap_ratio = j.value("min_overlap_ratio", 0.0f);
|
||||
}
|
||||
|
||||
struct DetectionZoneConfig {
|
||||
bool enabled = false;
|
||||
int buffer_above_px = 250;
|
||||
int buffer_below_px = 250;
|
||||
bool show_in_overlay = false;
|
||||
|
||||
cv::Rect compute_rect(const RoiConfig& roi, int frame_w, int frame_h) const {
|
||||
auto br = roi.bounding_rect();
|
||||
int x1 = std::max(0, br.x);
|
||||
int x2 = std::min(frame_w, br.x + br.width);
|
||||
int y1 = std::max(0, br.y - buffer_above_px);
|
||||
int y2 = std::min(frame_h, br.y + br.height + buffer_below_px);
|
||||
return cv::Rect(x1, y1, x2 - x1, y2 - y1);
|
||||
}
|
||||
};
|
||||
inline void to_json(nlohmann::json& j, const DetectionZoneConfig& c) {
|
||||
j = {{"enabled", c.enabled}, {"buffer_above_px", c.buffer_above_px},
|
||||
{"buffer_below_px", c.buffer_below_px}, {"show_in_overlay", c.show_in_overlay}};
|
||||
}
|
||||
inline void from_json(const nlohmann::json& j, DetectionZoneConfig& c) {
|
||||
c.enabled = j.value("enabled", false);
|
||||
c.buffer_above_px = j.value("buffer_above_px", 250);
|
||||
c.buffer_below_px = j.value("buffer_below_px", 250);
|
||||
c.show_in_overlay = j.value("show_in_overlay", false);
|
||||
}
|
||||
|
||||
struct TrackerConfig {
|
||||
std::string tracker_config_path;
|
||||
bool persist = true;
|
||||
int track_buffer = 75;
|
||||
};
|
||||
inline void to_json(nlohmann::json& j, const TrackerConfig& c) {
|
||||
j = {{"tracker_config_path", c.tracker_config_path},
|
||||
{"persist", c.persist}, {"track_buffer", c.track_buffer}};
|
||||
}
|
||||
inline void from_json(const nlohmann::json& j, TrackerConfig& c) {
|
||||
j.at("tracker_config_path").get_to(c.tracker_config_path);
|
||||
c.persist = j.value("persist", true);
|
||||
c.track_buffer = j.value("track_buffer", 75);
|
||||
}
|
||||
|
||||
struct GateConfig {
|
||||
std::string mode = "two_line";
|
||||
std::vector<int> lines_y = {320, 600};
|
||||
std::string direction = "bottom_to_up";
|
||||
};
|
||||
inline void to_json(nlohmann::json& j, const GateConfig& c) {
|
||||
j = {{"mode", c.mode}, {"lines_y", c.lines_y}, {"direction", c.direction}};
|
||||
}
|
||||
inline void from_json(const nlohmann::json& j, GateConfig& c) {
|
||||
c.mode = j.value("mode", "two_line");
|
||||
c.lines_y = j.value("lines_y", std::vector<int>{320, 600});
|
||||
c.direction = j.value("direction", "bottom_to_up");
|
||||
}
|
||||
|
||||
struct MotionConfig {
|
||||
bool enabled = true;
|
||||
std::string axis = "vertical";
|
||||
float forward_sign = 1.0f;
|
||||
float ema_alpha = 0.2f;
|
||||
float reverse_enter_threshold = -1.5f;
|
||||
float reverse_exit_threshold = -0.5f;
|
||||
int debounce_frames = 12;
|
||||
int min_features = 60;
|
||||
int max_corners = 300;
|
||||
float quality_level = 0.01f;
|
||||
int min_distance = 8;
|
||||
int block_radius = 6;
|
||||
int stride_frames = 1;
|
||||
float flow_scale = 1.0f;
|
||||
};
|
||||
inline void to_json(nlohmann::json& j, const MotionConfig& c) {
|
||||
j = {{"enabled", c.enabled}, {"axis", c.axis}, {"forward_sign", c.forward_sign},
|
||||
{"ema_alpha", c.ema_alpha}, {"reverse_enter_threshold", c.reverse_enter_threshold},
|
||||
{"reverse_exit_threshold", c.reverse_exit_threshold}, {"debounce_frames", c.debounce_frames},
|
||||
{"min_features", c.min_features}, {"max_corners", c.max_corners},
|
||||
{"quality_level", c.quality_level}, {"min_distance", c.min_distance},
|
||||
{"block_radius", c.block_radius}, {"stride_frames", c.stride_frames},
|
||||
{"flow_scale", c.flow_scale}};
|
||||
}
|
||||
inline void from_json(const nlohmann::json& j, MotionConfig& c) {
|
||||
c.enabled = j.value("enabled", true);
|
||||
c.axis = j.value("axis", "vertical");
|
||||
c.forward_sign = j.value("forward_sign", 1.0f);
|
||||
c.ema_alpha = j.value("ema_alpha", 0.2f);
|
||||
c.reverse_enter_threshold = j.value("reverse_enter_threshold", -1.5f);
|
||||
c.reverse_exit_threshold = j.value("reverse_exit_threshold", -0.5f);
|
||||
c.debounce_frames = j.value("debounce_frames", 12);
|
||||
c.min_features = j.value("min_features", 60);
|
||||
c.max_corners = j.value("max_corners", 300);
|
||||
c.quality_level = j.value("quality_level", 0.01f);
|
||||
c.min_distance = j.value("min_distance", 8);
|
||||
c.block_radius = j.value("block_radius", 6);
|
||||
c.stride_frames = j.value("stride_frames", 1);
|
||||
c.flow_scale = j.value("flow_scale", 1.0f);
|
||||
}
|
||||
|
||||
struct OverlayConfig {
|
||||
bool show_boxes = true;
|
||||
bool show_track_trails = true;
|
||||
int trail_length = 20;
|
||||
bool show_center_marker = true;
|
||||
bool show_track_ring = false;
|
||||
cv::Point2i count_anchor = {900, 120};
|
||||
bool inside_box_only = true;
|
||||
bool validated_only = false;
|
||||
bool pending_blink = true;
|
||||
std::vector<cv::Scalar> pending_colors = {cv::Scalar(255, 255, 0), cv::Scalar(0, 255, 255)};
|
||||
};
|
||||
inline void to_json(nlohmann::json& j, const OverlayConfig& c) {
|
||||
j = {{"show_boxes", c.show_boxes}, {"show_track_trails", c.show_track_trails},
|
||||
{"trail_length", c.trail_length}, {"show_center_marker", c.show_center_marker},
|
||||
{"show_track_ring", c.show_track_ring}, {"count_anchor", c.count_anchor},
|
||||
{"inside_box_only", c.inside_box_only}, {"validated_only", c.validated_only},
|
||||
{"pending_blink", c.pending_blink},
|
||||
{"pending_colors", c.pending_colors}};
|
||||
}
|
||||
inline void from_json(const nlohmann::json& j, OverlayConfig& c) {
|
||||
c.show_boxes = j.value("show_boxes", true);
|
||||
c.show_track_trails = j.value("show_track_trails", true);
|
||||
c.trail_length = j.value("trail_length", 20);
|
||||
c.show_center_marker = j.value("show_center_marker", true);
|
||||
c.show_track_ring = j.value("show_track_ring", false);
|
||||
c.count_anchor = j.value("count_anchor", cv::Point2i{900, 120});
|
||||
c.inside_box_only = j.value("inside_box_only", true);
|
||||
c.validated_only = j.value("validated_only", false);
|
||||
c.pending_blink = j.value("pending_blink", true);
|
||||
c.pending_colors = j.value("pending_colors",
|
||||
std::vector<cv::Scalar>{cv::Scalar(255, 255, 0), cv::Scalar(0, 255, 255)});
|
||||
}
|
||||
|
||||
struct DisplayConfig {
|
||||
std::string window_name = "Chicken Counter";
|
||||
bool show_window = true;
|
||||
std::string output_path; // empty = no output
|
||||
float write_fps = -1.0f; // -1 = auto
|
||||
int max_frames = -1; // -1 = unlimited
|
||||
std::string encoder = "auto";
|
||||
int output_bitrate_kbps = 4000;
|
||||
std::vector<std::string> codec_preference = {"avc1", "mp4v", "H264"};
|
||||
};
|
||||
inline void to_json(nlohmann::json& j, const DisplayConfig& c) {
|
||||
j = {{"window_name", c.window_name}, {"show_window", c.show_window},
|
||||
{"encoder", c.encoder}, {"output_bitrate_kbps", c.output_bitrate_kbps},
|
||||
{"codec_preference", c.codec_preference}};
|
||||
if (!c.output_path.empty()) j["output_path"] = c.output_path;
|
||||
if (c.write_fps >= 0) j["write_fps"] = c.write_fps;
|
||||
if (c.max_frames >= 0) j["max_frames"] = c.max_frames;
|
||||
}
|
||||
inline void from_json(const nlohmann::json& j, DisplayConfig& c) {
|
||||
c.window_name = j.value("window_name", "Chicken Counter");
|
||||
c.show_window = j.value("show_window", true);
|
||||
if (j.contains("output_path") && !j["output_path"].is_null())
|
||||
c.output_path = j["output_path"].get<std::string>();
|
||||
c.write_fps = j.value("write_fps", -1.0f);
|
||||
c.max_frames = j.value("max_frames", -1);
|
||||
c.encoder = j.value("encoder", "auto");
|
||||
c.output_bitrate_kbps = j.value("output_bitrate_kbps", 4000);
|
||||
c.codec_preference = j.value("codec_preference",
|
||||
std::vector<std::string>{"avc1", "mp4v", "H264"});
|
||||
}
|
||||
|
||||
struct PerformanceConfig {
|
||||
bool half = false;
|
||||
bool overlay_buffer_reuse = true;
|
||||
int inference_stride = 1;
|
||||
bool verbose = false;
|
||||
};
|
||||
inline void to_json(nlohmann::json& j, const PerformanceConfig& c) {
|
||||
j = {{"half", c.half}, {"overlay_buffer_reuse", c.overlay_buffer_reuse},
|
||||
{"inference_stride", c.inference_stride}, {"verbose", c.verbose}};
|
||||
}
|
||||
inline void from_json(const nlohmann::json& j, PerformanceConfig& c) {
|
||||
c.half = j.value("half", false);
|
||||
c.overlay_buffer_reuse = j.value("overlay_buffer_reuse", true);
|
||||
c.inference_stride = j.value("inference_stride", 1);
|
||||
c.verbose = j.value("verbose", false);
|
||||
}
|
||||
|
||||
struct StreamConfig {
|
||||
bool enabled = false;
|
||||
std::string shm_dir = "/dev/shm";
|
||||
int interval_frames = 5;
|
||||
};
|
||||
inline void to_json(nlohmann::json& j, const StreamConfig& c) {
|
||||
j = {{"enabled", c.enabled}, {"shm_dir", c.shm_dir},
|
||||
{"interval_frames", c.interval_frames}};
|
||||
}
|
||||
inline void from_json(const nlohmann::json& j, StreamConfig& c) {
|
||||
c.enabled = j.value("enabled", false);
|
||||
c.shm_dir = j.value("shm_dir", "/dev/shm");
|
||||
c.interval_frames = j.value("interval_frames", 5);
|
||||
}
|
||||
|
||||
struct FeedbackConfig {
|
||||
bool enabled = false;
|
||||
int every_n_frames = 300;
|
||||
bool save_images = true;
|
||||
std::string image_output_dir = "output/checkpoints";
|
||||
bool log_to_terminal = true;
|
||||
};
|
||||
inline void to_json(nlohmann::json& j, const FeedbackConfig& c) {
|
||||
j = {{"enabled", c.enabled}, {"every_n_frames", c.every_n_frames},
|
||||
{"save_images", c.save_images}, {"image_output_dir", c.image_output_dir},
|
||||
{"log_to_terminal", c.log_to_terminal}};
|
||||
}
|
||||
inline void from_json(const nlohmann::json& j, FeedbackConfig& c) {
|
||||
c.enabled = j.value("enabled", false);
|
||||
c.every_n_frames = j.value("every_n_frames", 300);
|
||||
c.save_images = j.value("save_images", true);
|
||||
c.image_output_dir = j.value("image_output_dir", "output/checkpoints");
|
||||
c.log_to_terminal = j.value("log_to_terminal", true);
|
||||
}
|
||||
|
||||
struct CameraConfig {
|
||||
std::string camera_id;
|
||||
std::string source;
|
||||
DetectionConfig detection;
|
||||
TrackerConfig tracker;
|
||||
RoiConfig roi;
|
||||
GateConfig gate;
|
||||
MotionConfig motion;
|
||||
OverlayConfig overlay;
|
||||
DisplayConfig display;
|
||||
PerformanceConfig performance;
|
||||
FeedbackConfig feedback;
|
||||
DetectionZoneConfig detection_zone;
|
||||
StreamConfig stream;
|
||||
};
|
||||
inline void to_json(nlohmann::json& j, const CameraConfig& c) {
|
||||
j = {{"camera_id", c.camera_id}, {"source", c.source},
|
||||
{"detection", c.detection}, {"tracker", c.tracker},
|
||||
{"roi", c.roi}, {"gate", c.gate}, {"motion", c.motion},
|
||||
{"overlay", c.overlay}, {"display", c.display},
|
||||
{"performance", c.performance}, {"feedback", c.feedback},
|
||||
{"detection_zone", c.detection_zone}, {"stream", c.stream}};
|
||||
}
|
||||
inline void from_json(const nlohmann::json& j, CameraConfig& c) {
|
||||
j.at("camera_id").get_to(c.camera_id);
|
||||
j.at("source").get_to(c.source);
|
||||
c.detection = j.value("detection", DetectionConfig{});
|
||||
c.tracker = j.value("tracker", TrackerConfig{});
|
||||
c.roi = j.value("roi", RoiConfig{});
|
||||
c.gate = j.value("gate", GateConfig{});
|
||||
c.motion = j.value("motion", MotionConfig{});
|
||||
c.overlay = j.value("overlay", OverlayConfig{});
|
||||
c.display = j.value("display", DisplayConfig{});
|
||||
c.performance = j.value("performance", PerformanceConfig{});
|
||||
c.feedback = j.value("feedback", FeedbackConfig{});
|
||||
c.detection_zone = j.value("detection_zone", DetectionZoneConfig{});
|
||||
c.stream = j.value("stream", StreamConfig{});
|
||||
}
|
||||
|
||||
struct BatchConfig {
|
||||
std::string root_dir;
|
||||
std::string camera_glob = "kandang_*_camera_{num}_*.mp4";
|
||||
std::string output_subdir = "output";
|
||||
int compress_max_mb = 200;
|
||||
bool delete_intermediate = false;
|
||||
int checkpoint_every_n_frames = 3000;
|
||||
};
|
||||
inline void to_json(nlohmann::json& j, const BatchConfig& c) {
|
||||
j = {{"root_dir", c.root_dir}, {"camera_glob", c.camera_glob},
|
||||
{"output_subdir", c.output_subdir}, {"compress_max_mb", c.compress_max_mb},
|
||||
{"delete_intermediate", c.delete_intermediate},
|
||||
{"checkpoint_every_n_frames", c.checkpoint_every_n_frames}};
|
||||
}
|
||||
inline void from_json(const nlohmann::json& j, BatchConfig& c) {
|
||||
j.at("root_dir").get_to(c.root_dir);
|
||||
c.camera_glob = j.value("camera_glob", "kandang_*_camera_{num}_*.mp4");
|
||||
c.output_subdir = j.value("output_subdir", "output");
|
||||
c.compress_max_mb = j.value("compress_max_mb", 200);
|
||||
c.delete_intermediate = j.value("delete_intermediate", false);
|
||||
c.checkpoint_every_n_frames = j.value("checkpoint_every_n_frames", 3000);
|
||||
}
|
||||
|
||||
struct CameraPreset {
|
||||
std::string camera_id;
|
||||
int camera_num;
|
||||
RoiConfig roi;
|
||||
cv::Point2i count_anchor = {-1, -1};
|
||||
GateConfig gate;
|
||||
MotionConfig motion;
|
||||
nlohmann::json detection_overrides;
|
||||
bool has_gate = false;
|
||||
bool has_motion = false;
|
||||
};
|
||||
|
||||
struct BatchSettings {
|
||||
BatchConfig batch;
|
||||
nlohmann::json defaults = nlohmann::json::object();
|
||||
std::unordered_map<std::string, CameraPreset> cameras;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config-loading functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
inline nlohmann::json load_data(const std::string& path) {
|
||||
if (path.size() >= 5 && path.compare(path.size() - 5, 5, ".json") == 0) {
|
||||
std::ifstream f(path);
|
||||
return nlohmann::json::parse(f);
|
||||
}
|
||||
YAML::Node yaml = YAML::LoadFile(path);
|
||||
return yaml_to_json(yaml);
|
||||
}
|
||||
|
||||
inline nlohmann::json deep_merge(nlohmann::json base, const nlohmann::json& override) {
|
||||
for (auto it = override.begin(); it != override.end(); ++it) {
|
||||
if (it.value().is_object() && base.contains(it.key()) && base[it.key()].is_object()) {
|
||||
base[it.key()] = deep_merge(base[it.key()], it.value());
|
||||
} else {
|
||||
base[it.key()] = it.value();
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
inline CameraConfig load_camera_config(const std::string& path, const std::string& camera_id = "") {
|
||||
auto raw = load_data(path);
|
||||
if (raw.contains("batch")) {
|
||||
throw std::runtime_error(
|
||||
"This is a batch config file. Use 'chicken-counter batch --config ...' instead.");
|
||||
}
|
||||
if (raw.contains("cameras") && !raw.contains("defaults")) {
|
||||
if (camera_id.empty())
|
||||
throw std::runtime_error("camera_id is required when config contains multiple cameras");
|
||||
raw = raw["cameras"][camera_id];
|
||||
}
|
||||
return raw.get<CameraConfig>();
|
||||
}
|
||||
|
||||
inline BatchSettings load_batch_config(const std::string& path) {
|
||||
auto raw = load_data(path);
|
||||
if (!raw.contains("batch"))
|
||||
throw std::runtime_error("Batch config must contain a top-level 'batch' section");
|
||||
|
||||
BatchSettings settings;
|
||||
settings.batch = raw["batch"].get<BatchConfig>();
|
||||
settings.defaults = raw.value("defaults", nlohmann::json::object());
|
||||
|
||||
if (raw.contains("cameras")) {
|
||||
for (auto& [id, cam] : raw["cameras"].items()) {
|
||||
CameraPreset preset;
|
||||
preset.camera_id = id;
|
||||
preset.camera_num = cam["camera_num"].get<int>();
|
||||
preset.roi.points = cam["roi"]["points"].get<std::vector<cv::Point2i>>();
|
||||
|
||||
if (cam.contains("count_anchor")) {
|
||||
preset.count_anchor = cam["count_anchor"].get<cv::Point2i>();
|
||||
} else if (cam.contains("overlay") && cam["overlay"].contains("count_anchor")) {
|
||||
preset.count_anchor = cam["overlay"]["count_anchor"].get<cv::Point2i>();
|
||||
}
|
||||
|
||||
if (cam.contains("gate")) {
|
||||
preset.gate = cam["gate"].get<GateConfig>();
|
||||
preset.has_gate = true;
|
||||
}
|
||||
if (cam.contains("motion")) {
|
||||
preset.motion = cam["motion"].get<MotionConfig>();
|
||||
preset.has_motion = true;
|
||||
}
|
||||
if (cam.contains("detection")) {
|
||||
preset.detection_overrides = cam["detection"];
|
||||
}
|
||||
|
||||
settings.cameras[id] = std::move(preset);
|
||||
}
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
inline CameraConfig build_camera_config_from_batch(
|
||||
const BatchSettings& settings,
|
||||
const std::string& camera_id,
|
||||
const std::string& source,
|
||||
const std::string& output_path,
|
||||
const std::string& checkpoint_dir)
|
||||
{
|
||||
auto it = settings.cameras.find(camera_id);
|
||||
if (it == settings.cameras.end())
|
||||
throw std::runtime_error("Unknown camera_id in batch config: " + camera_id);
|
||||
|
||||
const auto& preset = it->second;
|
||||
auto raw = deep_merge(settings.defaults, {{"camera_id", camera_id}, {"source", source}});
|
||||
|
||||
if (preset.count_anchor.x >= 0) {
|
||||
if (!raw.contains("overlay")) raw["overlay"] = nlohmann::json::object();
|
||||
raw["overlay"]["count_anchor"] = preset.count_anchor;
|
||||
}
|
||||
if (!preset.detection_overrides.empty()) {
|
||||
if (!raw.contains("detection")) raw["detection"] = nlohmann::json::object();
|
||||
raw["detection"].update(preset.detection_overrides);
|
||||
}
|
||||
if (!raw.contains("roi")) raw["roi"] = nlohmann::json::object();
|
||||
raw["roi"]["points"] = preset.roi.points;
|
||||
|
||||
if (preset.has_gate) {
|
||||
raw["gate"] = {{"mode", preset.gate.mode},
|
||||
{"lines_y", preset.gate.lines_y},
|
||||
{"direction", preset.gate.direction}};
|
||||
}
|
||||
if (preset.has_motion) {
|
||||
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 (!raw.contains("display")) raw["display"] = nlohmann::json::object();
|
||||
if (!output_path.empty()) raw["display"]["output_path"] = output_path;
|
||||
raw["display"]["show_window"] = false;
|
||||
|
||||
if (!raw.contains("feedback")) raw["feedback"] = nlohmann::json::object();
|
||||
raw["feedback"]["enabled"] = true;
|
||||
raw["feedback"]["every_n_frames"] = settings.batch.checkpoint_every_n_frames;
|
||||
raw["feedback"]["save_images"] = !output_path.empty();
|
||||
raw["feedback"]["image_output_dir"] = checkpoint_dir;
|
||||
raw["feedback"]["log_to_terminal"] = true;
|
||||
|
||||
return raw.get<CameraConfig>();
|
||||
}
|
||||
|
||||
} // namespace cc
|
||||
@@ -1,199 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <iostream>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include <opencv2/imgproc.hpp>
|
||||
#include <opencv2/core/types.hpp>
|
||||
|
||||
#include "chicken_counter/config.hpp"
|
||||
#include "chicken_counter/types.hpp"
|
||||
|
||||
namespace cc {
|
||||
|
||||
class CountingZone {
|
||||
public:
|
||||
CountingZone() {}
|
||||
|
||||
CountingZone(const RoiConfig& roi,
|
||||
const GateConfig& gate,
|
||||
int trail_length,
|
||||
int track_buffer,
|
||||
int min_box_area_px = 0,
|
||||
bool validate_while_inside = true,
|
||||
bool verbose = false)
|
||||
: roi(roi), gate(gate), trail_length(trail_length),
|
||||
track_buffer(track_buffer), min_box_area_px(min_box_area_px),
|
||||
min_overlap_ratio(roi.min_overlap_ratio),
|
||||
validate_while_inside(validate_while_inside),
|
||||
verbose(verbose)
|
||||
{
|
||||
for (auto& p : roi.counting_polygon())
|
||||
counting_polygon.push_back(p);
|
||||
auto r = roi.counting_rect();
|
||||
counting_rect = r;
|
||||
}
|
||||
|
||||
std::vector<CountEvent> update(
|
||||
const std::vector<TrackObservation>& tracks,
|
||||
int frame_index,
|
||||
bool counting_paused = false)
|
||||
{
|
||||
std::vector<CountEvent> events;
|
||||
std::unordered_set<int> active_ids, inside_ids;
|
||||
|
||||
for (const auto& track : tracks) {
|
||||
active_ids.insert(track.track_id);
|
||||
last_seen_frame[track.track_id] = frame_index;
|
||||
auto& hist = histories[track.track_id];
|
||||
if (hist.size() >= static_cast<size_t>(trail_length))
|
||||
hist.pop_front();
|
||||
hist.push_back({track.centroid_x, track.centroid_y});
|
||||
|
||||
if (inside_roi({track.centroid_x, track.centroid_y}))
|
||||
inside_ids.insert(track.track_id);
|
||||
|
||||
if (counting_paused) continue;
|
||||
if (inside_ids.find(track.track_id) == inside_ids.end()) continue;
|
||||
if (counted_ids.find(track.track_id) != counted_ids.end()) continue;
|
||||
|
||||
bool should_validate = false;
|
||||
if (validate_while_inside) {
|
||||
should_validate = meets_validation_thresholds(track);
|
||||
} else {
|
||||
bool just_entered = inside_ids.find(track.track_id) != inside_ids.end()
|
||||
&& prev_inside_ids.find(track.track_id) == prev_inside_ids.end();
|
||||
should_validate = just_entered && meets_validation_thresholds(track);
|
||||
}
|
||||
|
||||
if (should_validate) {
|
||||
counted_ids.insert(track.track_id);
|
||||
++total_entered_count;
|
||||
sequence_numbers[track.track_id] = total_entered_count;
|
||||
latest_validated_track_id = track.track_id;
|
||||
CountEvent ev;
|
||||
ev.track_id = track.track_id;
|
||||
ev.frame_index = frame_index;
|
||||
ev.total_entered_after_event = total_entered_count;
|
||||
ev.sequence_number = total_entered_count;
|
||||
events.push_back(ev);
|
||||
|
||||
if (verbose) {
|
||||
int bbox_area = std::max(0, track.bbox_x2 - track.bbox_x1)
|
||||
* std::max(0, track.bbox_y2 - track.bbox_y1);
|
||||
double overlap = bbox_overlap_ratio(track);
|
||||
std::cerr << "[count] track=" << track.track_id
|
||||
<< " seq=#" << total_entered_count
|
||||
<< " frame=" << frame_index
|
||||
<< " area=" << bbox_area
|
||||
<< " overlap=" << overlap
|
||||
<< " conf=" << track.confidence
|
||||
<< " centroid=" << track.centroid_x << "," << track.centroid_y << "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inside_box_count = static_cast<int>(inside_ids.size());
|
||||
current_inside_ids = inside_ids;
|
||||
prev_inside_ids = inside_ids;
|
||||
purge_stale(frame_index, active_ids);
|
||||
return events;
|
||||
}
|
||||
|
||||
std::vector<cv::Point2i> trail_for(int track_id) const {
|
||||
auto it = histories.find(track_id);
|
||||
if (it == histories.end()) return {};
|
||||
return {it->second.begin(), it->second.end()};
|
||||
}
|
||||
|
||||
size_t sequence_number_for(int track_id) const {
|
||||
auto it = sequence_numbers.find(track_id);
|
||||
return (it != sequence_numbers.end()) ? it->second : 0;
|
||||
}
|
||||
|
||||
bool is_inside(int track_id) const {
|
||||
return current_inside_ids.find(track_id) != current_inside_ids.end();
|
||||
}
|
||||
|
||||
bool is_validated(int track_id) const {
|
||||
return counted_ids.find(track_id) != counted_ids.end();
|
||||
}
|
||||
|
||||
int inside_box_count = 0;
|
||||
int total_entered_count = 0;
|
||||
std::optional<int> latest_validated_track_id;
|
||||
|
||||
private:
|
||||
RoiConfig roi;
|
||||
GateConfig gate;
|
||||
int trail_length;
|
||||
int track_buffer;
|
||||
int min_box_area_px;
|
||||
float min_overlap_ratio;
|
||||
bool validate_while_inside;
|
||||
bool verbose;
|
||||
|
||||
std::vector<cv::Point2i> counting_polygon;
|
||||
cv::Rect counting_rect;
|
||||
std::unordered_map<int, std::deque<cv::Point2i>> histories;
|
||||
std::unordered_map<int, int> last_seen_frame;
|
||||
std::unordered_set<int> counted_ids;
|
||||
std::unordered_set<int> prev_inside_ids;
|
||||
std::unordered_set<int> current_inside_ids;
|
||||
std::unordered_map<int, int> sequence_numbers;
|
||||
|
||||
bool inside_roi(cv::Point2i p) const {
|
||||
return cv::pointPolygonTest(counting_polygon, p, false) > 0;
|
||||
}
|
||||
|
||||
bool meets_size_threshold(const TrackObservation& track) const {
|
||||
int area = std::max(0, track.bbox_x2 - track.bbox_x1)
|
||||
* std::max(0, track.bbox_y2 - track.bbox_y1);
|
||||
return area >= min_box_area_px;
|
||||
}
|
||||
|
||||
double bbox_overlap_ratio(const TrackObservation& track) const {
|
||||
int bbox_area = std::max(0, track.bbox_x2 - track.bbox_x1)
|
||||
* std::max(0, track.bbox_y2 - track.bbox_y1);
|
||||
if (bbox_area <= 0) return 0.0;
|
||||
|
||||
int ix1 = std::max(track.bbox_x1, counting_rect.x);
|
||||
int iy1 = std::max(track.bbox_y1, counting_rect.y);
|
||||
int ix2 = std::min(track.bbox_x2, counting_rect.x + counting_rect.width);
|
||||
int iy2 = std::min(track.bbox_y2, counting_rect.y + counting_rect.height);
|
||||
if (ix2 <= ix1 || iy2 <= iy1) return 0.0;
|
||||
|
||||
double intersection = (ix2 - ix1) * (iy2 - iy1);
|
||||
return intersection / bbox_area;
|
||||
}
|
||||
|
||||
bool meets_overlap_threshold(const TrackObservation& track) const {
|
||||
if (min_overlap_ratio <= 0) return true;
|
||||
return bbox_overlap_ratio(track) >= min_overlap_ratio;
|
||||
}
|
||||
|
||||
bool meets_validation_thresholds(const TrackObservation& track) const {
|
||||
return meets_size_threshold(track) && meets_overlap_threshold(track);
|
||||
}
|
||||
|
||||
void purge_stale(int frame_index, const std::unordered_set<int>& active_ids) {
|
||||
std::vector<int> stale;
|
||||
for (const auto& [id, last] : last_seen_frame) {
|
||||
if (active_ids.find(id) == active_ids.end()
|
||||
&& frame_index - last > track_buffer)
|
||||
stale.push_back(id);
|
||||
}
|
||||
for (int id : stale) {
|
||||
last_seen_frame.erase(id);
|
||||
histories.erase(id);
|
||||
prev_inside_ids.erase(id);
|
||||
current_inside_ids.erase(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace cc
|
||||
@@ -1,146 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
|
||||
#include <opencv2/core/types.hpp>
|
||||
#include <opencv2/imgproc.hpp>
|
||||
#include <opencv2/video.hpp>
|
||||
|
||||
#include "chicken_counter/config.hpp"
|
||||
#include "chicken_counter/types.hpp"
|
||||
|
||||
namespace cc {
|
||||
|
||||
class BackwardMotionDetector {
|
||||
public:
|
||||
BackwardMotionDetector() {}
|
||||
|
||||
BackwardMotionDetector(const MotionConfig& config,
|
||||
const RoiConfig& roi,
|
||||
bool verbose = false)
|
||||
: config(config), roi(roi), verbose(verbose)
|
||||
{
|
||||
int x1 = roi.points[0].x, y1 = roi.points[0].y;
|
||||
int x2 = x1, y2 = y1;
|
||||
for (const auto& p : roi.points) {
|
||||
if (p.x < x1) x1 = p.x; if (p.y < y1) y1 = p.y;
|
||||
if (p.x > x2) x2 = p.x; if (p.y > y2) y2 = p.y;
|
||||
}
|
||||
roi_bounds = cv::Rect(x1, y1, x2 - x1, y2 - y1);
|
||||
}
|
||||
|
||||
MotionState update(const cv::Mat& frame,
|
||||
const std::vector<TrackObservation>& tracks,
|
||||
int frame_index)
|
||||
{
|
||||
if (!config.enabled) return state;
|
||||
|
||||
int stride = std::max(1, config.stride_frames);
|
||||
if (frame_index % stride != 0) return state;
|
||||
|
||||
cv::Mat gray;
|
||||
cv::cvtColor(frame, gray, cv::COLOR_BGR2GRAY);
|
||||
|
||||
cv::Mat gray_roi = gray(roi_bounds);
|
||||
|
||||
double scale = config.flow_scale;
|
||||
if (scale < 1.0) {
|
||||
int tw = std::max(1, static_cast<int>(gray_roi.cols * scale));
|
||||
int th = std::max(1, static_cast<int>(gray_roi.rows * scale));
|
||||
cv::resize(gray_roi, gray_roi, {tw, th}, 0, 0, cv::INTER_AREA);
|
||||
} else {
|
||||
scale = 1.0;
|
||||
}
|
||||
|
||||
cv::Mat mask(gray_roi.size(), CV_8UC1, cv::Scalar(255));
|
||||
int r = config.block_radius;
|
||||
for (const auto& track : tracks) {
|
||||
int lx1 = static_cast<int>((std::max(0, track.bbox_x1 - r) - roi_bounds.x) * scale);
|
||||
int ly1 = static_cast<int>((std::max(0, track.bbox_y1 - r) - roi_bounds.y) * scale);
|
||||
int lx2 = static_cast<int>((std::min(roi_bounds.x + roi_bounds.width, track.bbox_x2 + r) - roi_bounds.x) * scale);
|
||||
int ly2 = static_cast<int>((std::min(roi_bounds.y + roi_bounds.height, track.bbox_y2 + r) - roi_bounds.y) * scale);
|
||||
if (lx2 <= lx1 || ly2 <= ly1) continue;
|
||||
cv::rectangle(mask, {lx1, ly1}, {lx2, ly2}, 0, -1);
|
||||
}
|
||||
|
||||
std::vector<cv::Point2f> points;
|
||||
cv::goodFeaturesToTrack(gray_roi, points, config.max_corners,
|
||||
config.quality_level, config.min_distance, mask, config.block_radius);
|
||||
|
||||
if (previous_gray.empty() || static_cast<int>(points.size()) < config.min_features) {
|
||||
previous_gray = gray_roi.clone();
|
||||
return state;
|
||||
}
|
||||
|
||||
std::vector<cv::Point2f> next_points;
|
||||
std::vector<uint8_t> status;
|
||||
std::vector<float> err;
|
||||
cv::calcOpticalFlowPyrLK(previous_gray, gray_roi, points, next_points, status, err);
|
||||
|
||||
previous_gray = gray_roi.clone();
|
||||
|
||||
if (next_points.empty() || status.empty()) return state;
|
||||
|
||||
int valid_count = 0;
|
||||
double flow_sum = 0.0;
|
||||
for (size_t i = 0; i < status.size(); ++i) {
|
||||
if (!status[i]) continue;
|
||||
double v = (config.axis == "vertical")
|
||||
? (next_points[i].y - points[i].y)
|
||||
: (next_points[i].x - points[i].x);
|
||||
flow_sum += v;
|
||||
valid_count++;
|
||||
}
|
||||
if (valid_count < config.min_features) return state;
|
||||
|
||||
double median_speed = flow_sum / valid_count * config.forward_sign;
|
||||
|
||||
double alpha = config.ema_alpha;
|
||||
state.smoothed_speed = static_cast<float>(
|
||||
alpha * median_speed + (1.0 - alpha) * state.smoothed_speed);
|
||||
|
||||
if (state.smoothed_speed <= config.reverse_enter_threshold) {
|
||||
++state.consecutive_reverse_frames;
|
||||
} else if (state.smoothed_speed > config.reverse_exit_threshold) {
|
||||
state.consecutive_reverse_frames = 0;
|
||||
state.backward_active = false;
|
||||
}
|
||||
|
||||
if (state.consecutive_reverse_frames >= config.debounce_frames) {
|
||||
bool was_active = state.backward_active;
|
||||
state.backward_active = true;
|
||||
if (verbose && !was_active) {
|
||||
std::cerr << "[motion] BACKWARD TRIGGERED! smoothed="
|
||||
<< state.smoothed_speed
|
||||
<< " consecutive=" << state.consecutive_reverse_frames << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
++update_count;
|
||||
std::cerr << "[motion #" << update_count
|
||||
<< "] features=" << valid_count
|
||||
<< " median_speed=" << median_speed
|
||||
<< " smoothed=" << state.smoothed_speed
|
||||
<< " consecutive_rev=" << state.consecutive_reverse_frames
|
||||
<< " backward=" << state.backward_active << "\n";
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
MotionState state;
|
||||
|
||||
private:
|
||||
MotionConfig config;
|
||||
RoiConfig roi;
|
||||
bool verbose;
|
||||
cv::Rect roi_bounds;
|
||||
cv::Mat previous_gray;
|
||||
int update_count = 0;
|
||||
};
|
||||
|
||||
} // namespace cc
|
||||
@@ -1,162 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include <opencv2/imgproc.hpp>
|
||||
#include <opencv2/core/types.hpp>
|
||||
|
||||
#include "chicken_counter/config.hpp"
|
||||
#include "chicken_counter/counting.hpp"
|
||||
#include "chicken_counter/types.hpp"
|
||||
|
||||
namespace cc {
|
||||
|
||||
const cv::Scalar WHITE(255, 255, 255);
|
||||
const cv::Scalar BLACK(0, 0, 0);
|
||||
const cv::Scalar CYAN(255, 255, 0);
|
||||
const cv::Scalar RED(0, 0, 255);
|
||||
const cv::Scalar YELLOW(0, 255, 255);
|
||||
const cv::Scalar ORANGE(0, 165, 255);
|
||||
const cv::Scalar BLUE(255, 120, 0);
|
||||
const cv::Scalar LIME(80, 220, 80);
|
||||
const cv::Scalar GRAY(160, 160, 160);
|
||||
|
||||
inline void draw_outlined_text(
|
||||
cv::Mat& frame,
|
||||
const std::string& text,
|
||||
cv::Point origin,
|
||||
double font_scale,
|
||||
const cv::Scalar& fill_color,
|
||||
const cv::Scalar& outline_color,
|
||||
int thickness,
|
||||
int outline_thickness)
|
||||
{
|
||||
int font = cv::FONT_HERSHEY_SIMPLEX;
|
||||
cv::putText(frame, text, origin, font, font_scale, outline_color, outline_thickness, cv::LINE_AA);
|
||||
cv::putText(frame, text, origin, font, font_scale, fill_color, thickness, cv::LINE_AA);
|
||||
}
|
||||
|
||||
inline void draw_trail(cv::Mat& frame, const std::vector<cv::Point2i>& trail) {
|
||||
if (trail.size() < 2) return;
|
||||
for (size_t i = 1; i < trail.size(); ++i)
|
||||
cv::line(frame, trail[i - 1], trail[i], YELLOW, 2);
|
||||
}
|
||||
|
||||
inline void draw_dashed_rectangle(
|
||||
cv::Mat& frame,
|
||||
cv::Point pt1,
|
||||
cv::Point pt2,
|
||||
const cv::Scalar& color,
|
||||
int thickness = 1,
|
||||
int dash_length = 12)
|
||||
{
|
||||
int x1 = pt1.x, y1 = pt1.y, x2 = pt2.x, y2 = pt2.y;
|
||||
for (int x = x1; x < x2; x += dash_length * 2)
|
||||
cv::line(frame, {x, y1}, {std::min(x + dash_length, x2), y1}, color, thickness);
|
||||
for (int x = x1; x < x2; x += dash_length * 2)
|
||||
cv::line(frame, {x, y2}, {std::min(x + dash_length, x2), y2}, color, thickness);
|
||||
for (int y = y1; y < y2; y += dash_length * 2)
|
||||
cv::line(frame, {x1, y}, {x1, std::min(y + dash_length, y2)}, color, thickness);
|
||||
for (int y = y1; y < y2; y += dash_length * 2)
|
||||
cv::line(frame, {x2, y}, {x2, std::min(y + dash_length, y2)}, color, thickness);
|
||||
}
|
||||
|
||||
inline void draw_detection_zone(cv::Mat& frame, const CameraConfig& config) {
|
||||
int h = frame.rows, w = frame.cols;
|
||||
auto r = config.detection_zone.compute_rect(config.roi, w, h);
|
||||
draw_dashed_rectangle(frame, {r.x, r.y}, {r.x + r.width, r.y + r.height}, GRAY, 1);
|
||||
}
|
||||
|
||||
inline void draw_roi_and_gates(cv::Mat& frame, const CameraConfig& config) {
|
||||
std::vector<cv::Point2i> pts = config.roi.counting_polygon();
|
||||
std::vector<std::vector<cv::Point>> contours(1);
|
||||
for (const auto& p : pts) contours[0].emplace_back(p);
|
||||
cv::polylines(frame, contours, true, BLUE, 3);
|
||||
}
|
||||
|
||||
inline cv::Mat draw_overlay(
|
||||
cv::Mat& frame,
|
||||
const CameraConfig& config,
|
||||
CountingZone& counting_zone,
|
||||
const std::vector<TrackObservation>& tracks,
|
||||
const MotionState& motion_state,
|
||||
int frame_index = 0,
|
||||
cv::Mat* buffer = nullptr)
|
||||
{
|
||||
cv::Mat annotated;
|
||||
if (buffer) {
|
||||
frame.copyTo(*buffer);
|
||||
annotated = *buffer;
|
||||
} else {
|
||||
annotated = frame.clone();
|
||||
}
|
||||
|
||||
if (config.detection_zone.enabled && config.detection_zone.show_in_overlay)
|
||||
draw_detection_zone(annotated, config);
|
||||
|
||||
draw_roi_and_gates(annotated, config);
|
||||
|
||||
bool blink_on = (frame_index / 8) % 2 == 0;
|
||||
const auto& pc = config.overlay.pending_colors;
|
||||
auto pending_colors = pc.empty()
|
||||
? std::vector<cv::Scalar>{CYAN, YELLOW}
|
||||
: pc;
|
||||
|
||||
for (const auto& track : tracks) {
|
||||
bool inside_box = counting_zone.is_inside(track.track_id);
|
||||
if (config.overlay.inside_box_only && !inside_box) continue;
|
||||
|
||||
bool validated = counting_zone.is_validated(track.track_id);
|
||||
if (config.overlay.validated_only && !validated) continue;
|
||||
int x1 = track.bbox_x1, y1 = track.bbox_y1, x2 = track.bbox_x2, y2 = track.bbox_y2;
|
||||
int cx = track.centroid_x, cy = track.centroid_y;
|
||||
int seq = static_cast<int>(counting_zone.sequence_number_for(track.track_id));
|
||||
|
||||
if (config.overlay.show_boxes) {
|
||||
cv::Scalar box_color;
|
||||
if (validated) {
|
||||
box_color = ORANGE;
|
||||
} else if (config.overlay.pending_blink) {
|
||||
box_color = pending_colors[blink_on ? 0 : 1 % pending_colors.size()];
|
||||
} else {
|
||||
box_color = pending_colors[0];
|
||||
}
|
||||
cv::rectangle(annotated, {x1, y1}, {x2, y2}, box_color, 2);
|
||||
}
|
||||
|
||||
if (validated && seq > 0) {
|
||||
draw_outlined_text(annotated, std::to_string(seq),
|
||||
{x1, std::max(24, y1 - 8)}, 0.8, LIME, BLACK, 2, 4);
|
||||
}
|
||||
|
||||
if (config.overlay.show_center_marker) {
|
||||
cv::Scalar marker_color = validated ? ORANGE
|
||||
: pending_colors[blink_on ? 0 : 1 % pending_colors.size()];
|
||||
cv::circle(annotated, {cx, cy}, 4, marker_color, -1);
|
||||
if (config.overlay.show_track_ring) {
|
||||
int radius = std::max(20, static_cast<int>(std::max(x2 - x1, y2 - y1) * 0.6));
|
||||
cv::circle(annotated, {cx, cy}, radius, WHITE, 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.overlay.show_track_trails) {
|
||||
auto trail = counting_zone.trail_for(track.track_id);
|
||||
draw_trail(annotated, trail);
|
||||
}
|
||||
}
|
||||
|
||||
auto& anchor = config.overlay.count_anchor;
|
||||
draw_outlined_text(annotated,
|
||||
"TOTAL ENTERED: " + std::to_string(counting_zone.total_entered_count),
|
||||
anchor, 1.35, BLUE, BLACK, 4, 6);
|
||||
|
||||
const char* motion_label = motion_state.backward_active ? "BACKWARD STOP" : "FORWARD";
|
||||
cv::Scalar motion_color = motion_state.backward_active ? RED : YELLOW;
|
||||
cv::putText(annotated, motion_label, {anchor.x, anchor.y + 42},
|
||||
cv::FONT_HERSHEY_SIMPLEX, 0.8, motion_color, 2, cv::LINE_AA);
|
||||
|
||||
return annotated;
|
||||
}
|
||||
|
||||
} // namespace cc
|
||||
@@ -1,39 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <optional>
|
||||
|
||||
#include <opencv2/core.hpp>
|
||||
#include <opencv2/videoio.hpp>
|
||||
|
||||
#include "chicken_counter/config.hpp"
|
||||
#include "chicken_counter/counting.hpp"
|
||||
#include "chicken_counter/motion.hpp"
|
||||
#include "chicken_counter/tracking.hpp"
|
||||
#include "chicken_counter/types.hpp"
|
||||
|
||||
namespace cc {
|
||||
|
||||
struct PipelineArtifacts {
|
||||
cv::VideoCapture capture;
|
||||
DetectionTracker* tracker;
|
||||
CountingZone counting_zone;
|
||||
BackwardMotionDetector motion_detector;
|
||||
cv::VideoWriter writer;
|
||||
cv::Mat overlay_buffer;
|
||||
double run_start_time;
|
||||
int total_source_frames;
|
||||
bool owns_tracker;
|
||||
cv::Rect detection_zone_rect;
|
||||
bool has_writer;
|
||||
bool has_detection_zone;
|
||||
};
|
||||
|
||||
PipelineArtifacts build_pipeline(const CameraConfig& config,
|
||||
DetectionTracker* tracker = nullptr);
|
||||
|
||||
PipelineResult run_pipeline(const CameraConfig& config,
|
||||
DetectionTracker* tracker = nullptr,
|
||||
bool show_progress = false);
|
||||
|
||||
} // namespace cc
|
||||
@@ -1,139 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "chicken_counter/types.hpp"
|
||||
|
||||
namespace cc {
|
||||
|
||||
inline std::string now_iso() {
|
||||
auto now = std::chrono::system_clock::now();
|
||||
auto t = std::chrono::system_clock::to_time_t(now);
|
||||
std::ostringstream oss;
|
||||
oss << std::put_time(std::gmtime(&t), "%FT%TZ");
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
inline std::string relative_output_path(const std::string& path,
|
||||
const std::string& base_dir) {
|
||||
if (path.empty()) return "";
|
||||
if (base_dir.empty()) return std::filesystem::path(path).filename().string();
|
||||
try {
|
||||
auto rel = std::filesystem::relative(path, base_dir);
|
||||
return rel.string();
|
||||
} catch (...) {
|
||||
return std::filesystem::path(path).filename().string();
|
||||
}
|
||||
}
|
||||
|
||||
inline nlohmann::json build_camera_report_entry(
|
||||
const CameraBatchResult& item,
|
||||
const std::string& output_dir = "")
|
||||
{
|
||||
if (item.skipped) {
|
||||
return {{"skipped", true},
|
||||
{"skip_reason", item.skip_reason},
|
||||
{"total_entered", 0}};
|
||||
}
|
||||
|
||||
return {
|
||||
{"total_entered", item.pipeline.total_entered_count},
|
||||
{"source_video", std::filesystem::path(item.pipeline.source_video).filename().string()},
|
||||
{"vis_video", relative_output_path(item.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", item.pipeline.frames_processed},
|
||||
{"stopped_reason", item.pipeline.stopped_reason},
|
||||
{"elapsed_seconds", std::round(item.pipeline.elapsed_seconds * 10.0) / 10.0}
|
||||
};
|
||||
}
|
||||
|
||||
struct BatchReport {
|
||||
std::string date;
|
||||
std::string generated_at;
|
||||
nlohmann::json cameras;
|
||||
int total_entered_sum;
|
||||
};
|
||||
|
||||
inline BatchReport build_batch_report(
|
||||
const std::string& date,
|
||||
const std::vector<CameraBatchResult>& results,
|
||||
const std::string& output_dir = "")
|
||||
{
|
||||
BatchReport report;
|
||||
report.date = date;
|
||||
report.generated_at = now_iso();
|
||||
report.total_entered_sum = 0;
|
||||
|
||||
for (const auto& item : results) {
|
||||
auto entry = build_camera_report_entry(item, output_dir);
|
||||
if (entry.empty()) continue;
|
||||
report.cameras[item.camera_id] = entry;
|
||||
if (!item.skipped)
|
||||
report.total_entered_sum += entry.value("total_entered", 0);
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
inline std::string write_json_file(const std::string& path, const nlohmann::json& j) {
|
||||
namespace fs = std::filesystem;
|
||||
fs::create_directories(fs::path(path).parent_path());
|
||||
std::ofstream f(path);
|
||||
f << j.dump(2);
|
||||
std::cerr << "[report] wrote " << path << "\n";
|
||||
return path;
|
||||
}
|
||||
|
||||
inline std::string write_batch_report(const BatchReport& report,
|
||||
const std::string& output_path) {
|
||||
nlohmann::json j = {
|
||||
{"date", report.date},
|
||||
{"generated_at", report.generated_at},
|
||||
{"cameras", report.cameras},
|
||||
{"total_entered_sum", report.total_entered_sum}
|
||||
};
|
||||
return write_json_file(output_path, j);
|
||||
}
|
||||
|
||||
inline std::string write_camera_report(
|
||||
const std::string& date,
|
||||
const CameraBatchResult& item,
|
||||
const std::string& output_dir)
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
fs::create_directories(output_dir);
|
||||
auto entry = build_camera_report_entry(item, output_dir);
|
||||
nlohmann::json payload = {
|
||||
{"date", date},
|
||||
{"camera_id", item.camera_id},
|
||||
{"generated_at", now_iso()}
|
||||
};
|
||||
for (auto& [k, v] : entry.items()) payload[k] = v;
|
||||
std::string path = output_dir + "/" + item.camera_id + "_counts_" + date + ".json";
|
||||
return write_json_file(path, payload);
|
||||
}
|
||||
|
||||
inline std::string persist_batch_reports(
|
||||
const std::string& date,
|
||||
const std::vector<CameraBatchResult>& results,
|
||||
const std::string& output_dir)
|
||||
{
|
||||
const auto& latest = results.back();
|
||||
write_camera_report(date, latest, output_dir);
|
||||
std::string aggregate_path = output_dir + "/counts_" + date + ".json";
|
||||
auto report = build_batch_report(date, results, output_dir);
|
||||
write_batch_report(report, aggregate_path);
|
||||
return aggregate_path;
|
||||
}
|
||||
|
||||
} // namespace cc
|
||||
@@ -1,390 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include <opencv2/core.hpp>
|
||||
#include <opencv2/dnn.hpp>
|
||||
#include <opencv2/imgproc.hpp>
|
||||
#include <opencv2/video/tracking.hpp>
|
||||
|
||||
#include <NvInfer.h>
|
||||
#include <NvOnnxParser.h>
|
||||
#include <cuda_runtime.h>
|
||||
|
||||
#include "chicken_counter/config.hpp"
|
||||
#include "chicken_counter/types.hpp"
|
||||
|
||||
namespace cc {
|
||||
|
||||
template <typename T> struct TRTDeleter { void operator()(T* p) const { delete p; } };
|
||||
template <typename T> using TRT_ptr = std::unique_ptr<T, TRTDeleter<T>>;
|
||||
|
||||
struct CudaStreamDeleter { void operator()(cudaStream_t* s) const { cudaStreamDestroy(*s); delete s; } };
|
||||
using Cuda_stream_ptr = std::unique_ptr<cudaStream_t, CudaStreamDeleter>;
|
||||
|
||||
struct CudaHostDeleter { template <typename T> void operator()(T* p) const { cudaFreeHost(p); } };
|
||||
template <typename T> using Cuda_host_ptr = std::unique_ptr<T, CudaHostDeleter>;
|
||||
|
||||
inline void trt_check(cudaError_t e, const char* m = "") {
|
||||
if (e != cudaSuccess) throw std::runtime_error(std::string("CUDA:") + cudaGetErrorString(e) + " " + m);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TensorRT engine (build from ONNX, cache to disk)
|
||||
// ---------------------------------------------------------------------------
|
||||
class TensorRTEngine {
|
||||
public:
|
||||
TensorRTEngine(const std::string& onnx_path, const std::string& cache_path = "") {
|
||||
if (!cache_path.empty()) {
|
||||
std::ifstream fc(cache_path, std::ios::binary | std::ios::ate);
|
||||
if (fc) {
|
||||
size_t sz = fc.tellg(); fc.seekg(0);
|
||||
std::vector<char> data(sz);
|
||||
fc.read(data.data(), sz);
|
||||
runtime.reset(nvinfer1::createInferRuntime(logger));
|
||||
engine.reset(runtime->deserializeCudaEngine(data.data(), sz));
|
||||
if (engine) {
|
||||
std::cerr << "[trt] loaded cache: " << cache_path << "\n";
|
||||
init_io();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::cerr << "[trt] building from " << onnx_path << " ...\n";
|
||||
auto builder = TRT_ptr<nvinfer1::IBuilder>(nvinfer1::createInferBuilder(logger));
|
||||
auto network = TRT_ptr<nvinfer1::INetworkDefinition>(
|
||||
builder->createNetworkV2(1U << static_cast<uint32_t>(
|
||||
nvinfer1::NetworkDefinitionCreationFlag::kEXPLICIT_BATCH)));
|
||||
auto parser = TRT_ptr<nvonnxparser::IParser>(nvonnxparser::createParser(*network, logger));
|
||||
if (!parser->parseFromFile(onnx_path.c_str(),
|
||||
static_cast<int>(nvinfer1::ILogger::Severity::kWARNING)))
|
||||
throw std::runtime_error("ONNX parse failed");
|
||||
|
||||
auto config = TRT_ptr<nvinfer1::IBuilderConfig>(builder->createBuilderConfig());
|
||||
config->setMemoryPoolLimit(nvinfer1::MemoryPoolType::kWORKSPACE, 256ULL << 20);
|
||||
if (builder->platformHasFastFp16()) config->setFlag(nvinfer1::BuilderFlag::kFP16);
|
||||
|
||||
// set optimisation profile if input has dynamic dims
|
||||
int nb = network->getNbInputs();
|
||||
if (nb > 0) {
|
||||
auto in = network->getInput(0);
|
||||
auto prof = builder->createOptimizationProfile();
|
||||
nvinfer1::Dims minD = in->getDimensions(), optD = minD, maxD = minD;
|
||||
for (int d = 0; d < minD.nbDims; ++d) {
|
||||
if (minD.d[d] < 0) {
|
||||
minD.d[d] = 1; optD.d[d] = 1; maxD.d[d] = 1;
|
||||
if (d == 2) { optD.d[d] = 640; maxD.d[d] = 640; }
|
||||
if (d == 3) { optD.d[d] = 640; maxD.d[d] = 640; }
|
||||
}
|
||||
}
|
||||
prof->setDimensions(in->getName(), nvinfer1::OptProfileSelector::kMIN, minD);
|
||||
prof->setDimensions(in->getName(), nvinfer1::OptProfileSelector::kOPT, optD);
|
||||
prof->setDimensions(in->getName(), nvinfer1::OptProfileSelector::kMAX, maxD);
|
||||
config->addOptimizationProfile(prof);
|
||||
}
|
||||
|
||||
auto plan = TRT_ptr<nvinfer1::IHostMemory>(
|
||||
builder->buildSerializedNetwork(*network, *config));
|
||||
if (!plan) throw std::runtime_error("buildSerializedNetwork failed");
|
||||
|
||||
runtime.reset(nvinfer1::createInferRuntime(logger));
|
||||
engine.reset(runtime->deserializeCudaEngine(plan->data(), plan->size()));
|
||||
|
||||
if (!cache_path.empty()) {
|
||||
std::ofstream out(cache_path, std::ios::binary);
|
||||
out.write(static_cast<const char*>(plan->data()), plan->size());
|
||||
std::cerr << "[trt] cached: " << cache_path << " (" << plan->size() << " B)\n";
|
||||
}
|
||||
init_io();
|
||||
}
|
||||
|
||||
~TensorRTEngine() {
|
||||
for (auto& kv : buffers) cudaFree(kv.second);
|
||||
}
|
||||
|
||||
void run(const float* input, float* output) {
|
||||
trt_check(cudaMemcpyAsync(buffers[input_name], input, input_bytes,
|
||||
cudaMemcpyHostToDevice, *stream));
|
||||
context->enqueueV3(*stream);
|
||||
trt_check(cudaMemcpyAsync(output, buffers[output_name], output_bytes,
|
||||
cudaMemcpyDeviceToHost, *stream));
|
||||
cudaStreamSynchronize(*stream);
|
||||
}
|
||||
|
||||
std::string input_name, output_name;
|
||||
size_t input_bytes = 0, output_bytes = 0;
|
||||
|
||||
private:
|
||||
struct Logger : nvinfer1::ILogger {
|
||||
void log(Severity sev, const char* msg) noexcept override {
|
||||
if (sev <= Severity::kWARNING) std::cerr << "[trt] " << msg << std::endl;
|
||||
}
|
||||
};
|
||||
Logger logger;
|
||||
TRT_ptr<nvinfer1::IRuntime> runtime;
|
||||
TRT_ptr<nvinfer1::ICudaEngine> engine;
|
||||
TRT_ptr<nvinfer1::IExecutionContext> context;
|
||||
std::unordered_map<std::string, void*> buffers;
|
||||
Cuda_stream_ptr stream;
|
||||
|
||||
void init_io() {
|
||||
context.reset(engine->createExecutionContext());
|
||||
if (!context) throw std::runtime_error("createExecutionContext failed");
|
||||
|
||||
int nb = engine->getNbIOTensors();
|
||||
for (int i = 0; i < nb; ++i) {
|
||||
auto name = engine->getIOTensorName(i);
|
||||
auto mode = engine->getTensorIOMode(name);
|
||||
auto shape = engine->getTensorShape(name);
|
||||
size_t bytes = 1;
|
||||
for (int d = 0; d < shape.nbDims; ++d) bytes *= shape.d[d];
|
||||
bytes *= sizeof(float);
|
||||
void* ptr = nullptr;
|
||||
cudaError_t e = cudaMalloc(&ptr, bytes);
|
||||
if (e != cudaSuccess) throw std::runtime_error(std::string("cudaMalloc:") + cudaGetErrorString(e));
|
||||
if (!context->setTensorAddress(name, ptr))
|
||||
throw std::runtime_error(std::string("setTensorAddress: ") + name);
|
||||
buffers[name] = ptr;
|
||||
|
||||
if (mode == nvinfer1::TensorIOMode::kINPUT) {
|
||||
input_name = name; input_bytes = bytes;
|
||||
} else {
|
||||
output_name = name; output_bytes = bytes;
|
||||
}
|
||||
}
|
||||
stream.reset(new cudaStream_t{});
|
||||
trt_check(cudaStreamCreate(stream.get()));
|
||||
std::cerr << "[trt] ready: in=" << input_name << " (" << input_bytes
|
||||
<< "B) out=" << output_name << " (" << output_bytes << "B)\n";
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DetectionTracker (TensorRT inference + SORT tracking)
|
||||
// ---------------------------------------------------------------------------
|
||||
class DetectionTracker {
|
||||
public:
|
||||
DetectionTracker(const CameraConfig& config)
|
||||
: config(config), imgsz(config.detection.imgsz),
|
||||
conf_thresh(config.detection.conf),
|
||||
iou_thresh(config.detection.iou),
|
||||
verbose(config.performance.verbose),
|
||||
track_buffer(config.tracker.track_buffer)
|
||||
{
|
||||
std::string path = config.detection.model_path;
|
||||
size_t dot = path.rfind('.');
|
||||
std::string kind = (dot != std::string::npos) ? path.substr(dot) : "";
|
||||
|
||||
if (kind == ".engine" || kind == ".onnx") {
|
||||
std::string onnx = (kind == ".engine")
|
||||
? path.substr(0, path.size() - 7) + ".onnx" : path;
|
||||
use_trt = true;
|
||||
trt = std::make_unique<TensorRTEngine>(onnx, path + ".cache");
|
||||
} else {
|
||||
throw std::runtime_error("Unsupported model format: " + kind);
|
||||
}
|
||||
|
||||
// alloc pinned output buffer (reused every inference)
|
||||
int out_floats = static_cast<int>(trt->output_bytes / sizeof(float));
|
||||
cudaMallocHost(&output_buf, trt->output_bytes);
|
||||
output_buf_size = out_floats;
|
||||
|
||||
std::cerr << "[model] ready\n";
|
||||
}
|
||||
|
||||
~DetectionTracker() {
|
||||
if (output_buf) cudaFreeHost(output_buf);
|
||||
}
|
||||
|
||||
void reset_tracking() {
|
||||
active_tracks.clear();
|
||||
last_frame_id = 0;
|
||||
}
|
||||
|
||||
std::vector<TrackObservation> infer(const cv::Mat& frame,
|
||||
const cv::Rect& crop_rect = {}) {
|
||||
++last_frame_id;
|
||||
cv::Mat source;
|
||||
int off_x = 0, off_y = 0;
|
||||
if (!crop_rect.empty() && crop_rect.width > 0 && crop_rect.height > 0) {
|
||||
source = frame(crop_rect);
|
||||
off_x = crop_rect.x; off_y = crop_rect.y;
|
||||
} else {
|
||||
source = frame;
|
||||
}
|
||||
|
||||
float scale; int pad_x, pad_y;
|
||||
cv::Mat blob = preprocess(source, scale, pad_x, pad_y);
|
||||
|
||||
// inference (blob.data is already NCHW float, use directly)
|
||||
trt->run(reinterpret_cast<float*>(blob.data), output_buf);
|
||||
|
||||
// decode
|
||||
auto dets = decode(scale, pad_x, pad_y, source.cols, source.rows);
|
||||
auto tracks = associate(dets, off_x, off_y);
|
||||
|
||||
if (verbose && last_frame_id % 30 == 0)
|
||||
std::cerr << "[track] f=" << last_frame_id << " det=" << dets.size()
|
||||
<< " trk=" << tracks.size() << "\n";
|
||||
return tracks;
|
||||
}
|
||||
|
||||
CameraConfig config;
|
||||
|
||||
private:
|
||||
bool use_trt = false;
|
||||
std::unique_ptr<TensorRTEngine> trt;
|
||||
int imgsz;
|
||||
float conf_thresh, iou_thresh;
|
||||
bool verbose;
|
||||
int track_buffer, last_frame_id = 0;
|
||||
|
||||
float* output_buf = nullptr;
|
||||
int output_buf_size = 0;
|
||||
|
||||
// --- Kalman track ---
|
||||
struct KalmanTrack {
|
||||
int id; cv::KalmanFilter kf; cv::Rect2f bbox;
|
||||
int hits = 0, time_since_update = 0;
|
||||
KalmanTrack(int tid, const cv::Rect2f& b) : id(tid), bbox(b) {
|
||||
kf.init(7, 4, 0, CV_32F);
|
||||
kf.transitionMatrix = (cv::Mat_<float>(7, 7) <<
|
||||
1,0,0,0,1,0,0, 0,1,0,0,0,1,0, 0,0,1,0,0,0,1, 0,0,0,1,0,0,0,
|
||||
0,0,0,0,1,0,0, 0,0,0,0,0,1,0, 0,0,0,0,0,0,1);
|
||||
cv::setIdentity(kf.measurementMatrix);
|
||||
cv::setIdentity(kf.processNoiseCov, cv::Scalar::all(1e-2));
|
||||
cv::setIdentity(kf.measurementNoiseCov, cv::Scalar::all(1e-1));
|
||||
cv::setIdentity(kf.errorCovPost, cv::Scalar::all(1));
|
||||
kf.statePost.at<float>(0) = b.x + b.width/2;
|
||||
kf.statePost.at<float>(1) = b.y + b.height/2;
|
||||
kf.statePost.at<float>(2) = b.area();
|
||||
kf.statePost.at<float>(3) = b.width / b.height;
|
||||
}
|
||||
cv::Rect2f predict() {
|
||||
cv::Mat p = kf.predict();
|
||||
float w = std::sqrt(std::max(1.0f, p.at<float>(2) * p.at<float>(3)));
|
||||
float h = std::max(1.0f, p.at<float>(2) / w);
|
||||
bbox = cv::Rect2f(p.at<float>(0) - w/2, p.at<float>(1) - h/2, w, h);
|
||||
return bbox;
|
||||
}
|
||||
void update(const cv::Rect2f& b) {
|
||||
float cx = b.x + b.width/2, cy = b.y + b.height/2;
|
||||
kf.correct((cv::Mat_<float>(4, 1) << cx, cy, b.area(), b.width / b.height));
|
||||
bbox = b; hits++; time_since_update = 0;
|
||||
}
|
||||
};
|
||||
std::vector<KalmanTrack> active_tracks;
|
||||
int next_track_id = 1;
|
||||
|
||||
// --- preprocess ---
|
||||
cv::Mat preprocess(const cv::Mat& img, float& scale, int& pad_x, int& pad_y) {
|
||||
int w = img.cols, h = img.rows;
|
||||
scale = static_cast<float>(imgsz) / std::max(w, h);
|
||||
int nw = static_cast<int>(w * scale), nh = static_cast<int>(h * scale);
|
||||
pad_x = (imgsz - nw) / 2;
|
||||
pad_y = (imgsz - nh) / 2;
|
||||
cv::Mat r, p;
|
||||
cv::resize(img, r, {nw, nh});
|
||||
cv::copyMakeBorder(r, p, pad_y, imgsz - nh - pad_y, pad_x, imgsz - nw - pad_x,
|
||||
cv::BORDER_CONSTANT, {114, 114, 114});
|
||||
return cv::dnn::blobFromImage(p, 1.0/255.0, {imgsz, imgsz}, cv::Scalar(), true, false);
|
||||
}
|
||||
|
||||
// --- decode: model outputs (1, 300, 6) = [x1,y1,x2,y2,conf,cls] in letterbox space ---
|
||||
std::vector<cv::Rect2f> decode(float scale, int pad_x, int pad_y, int ow, int oh) {
|
||||
std::vector<cv::Rect> iboxes; iboxes.reserve(32);
|
||||
std::vector<float> scores; scores.reserve(32);
|
||||
|
||||
const float* d = output_buf;
|
||||
int stride = 6; // (x1,y1,x2,y2,conf,cls) per detection
|
||||
|
||||
for (int i = 0; i < output_buf_size / stride; ++i) {
|
||||
const float* row = d + i * stride;
|
||||
float conf = row[4];
|
||||
if (conf < conf_thresh) continue;
|
||||
|
||||
// boxes are in letterbox coordinates, scale back
|
||||
float x1 = (row[0] - pad_x) / scale;
|
||||
float y1 = (row[1] - pad_y) / scale;
|
||||
float x2 = (row[2] - pad_x) / scale;
|
||||
float y2 = (row[3] - pad_y) / scale;
|
||||
|
||||
int ix1 = std::max(0, std::min(static_cast<int>(x1), ow));
|
||||
int iy1 = std::max(0, std::min(static_cast<int>(y1), oh));
|
||||
int ix2 = std::max(0, std::min(static_cast<int>(x2), ow));
|
||||
int iy2 = std::max(0, std::min(static_cast<int>(y2), oh));
|
||||
if (ix2 > ix1 && iy2 > iy1) {
|
||||
iboxes.push_back({ix1, iy1, ix2 - ix1, iy2 - iy1});
|
||||
scores.push_back(conf);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<int> idx;
|
||||
cv::dnn::NMSBoxes(iboxes, scores, conf_thresh, iou_thresh, idx);
|
||||
std::vector<cv::Rect2f> out; out.reserve(idx.size());
|
||||
for (int i : idx) out.push_back(iboxes[i]);
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- SORT association ---
|
||||
std::vector<TrackObservation> associate(const std::vector<cv::Rect2f>& dets, int ox, int oy) {
|
||||
for (auto& t : active_tracks) { t.predict(); t.time_since_update++; }
|
||||
int nd = static_cast<int>(dets.size()), nt = static_cast<int>(active_tracks.size());
|
||||
if (nd == 0) goto cleanup;
|
||||
|
||||
{
|
||||
std::vector<std::vector<double>> iou(nt, std::vector<double>(nd));
|
||||
for (int t = 0; t < nt; ++t)
|
||||
for (int d = 0; d < nd; ++d)
|
||||
iou[t][d] = 1.0 - _iou(active_tracks[t].bbox, dets[d]);
|
||||
std::vector<int> order(nd); for (int i = 0; i < nd; ++i) order[i] = i;
|
||||
std::sort(order.begin(), order.end(), [&](int a, int b){ return dets[a].area() > dets[b].area(); });
|
||||
std::vector<bool> used(nd, false); std::vector<int> match(nt, -1);
|
||||
for (int d : order) {
|
||||
int best = -1; double best_cost = 0.3;
|
||||
for (int t = 0; t < nt; ++t) {
|
||||
if (match[t] >= 0) continue;
|
||||
if (iou[t][d] < best_cost) { best_cost = iou[t][d]; best = t; }
|
||||
}
|
||||
if (best >= 0) { match[best] = d; used[d] = true; }
|
||||
}
|
||||
for (int t = 0; t < nt; ++t) if (match[t] >= 0) active_tracks[t].update(dets[match[t]]);
|
||||
for (int d = 0; d < nd; ++d) if (!used[d]) {
|
||||
KalmanTrack tk(++next_track_id, dets[d]); tk.hits = 1; active_tracks.push_back(tk);
|
||||
}
|
||||
}
|
||||
|
||||
cleanup:
|
||||
active_tracks.erase(std::remove_if(active_tracks.begin(), active_tracks.end(),
|
||||
[this](const KalmanTrack& t){ return t.time_since_update > track_buffer; }),
|
||||
active_tracks.end());
|
||||
|
||||
std::vector<TrackObservation> out;
|
||||
for (const auto& t : active_tracks) {
|
||||
if (t.hits < 3) continue;
|
||||
auto& b = t.bbox;
|
||||
int x1 = static_cast<int>(b.x) + ox, y1 = static_cast<int>(b.y) + oy;
|
||||
int x2 = static_cast<int>(b.x + b.width) + ox, y2 = static_cast<int>(b.y + b.height) + oy;
|
||||
out.push_back({t.id, 0, 0.9f, x1, y1, x2, y2, (x1+x2)/2, (y1+y2)/2, {}});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static double _iou(const cv::Rect2f& a, const cv::Rect2f& b) {
|
||||
float ix1 = std::max(a.x, b.x), iy1 = std::max(a.y, b.y);
|
||||
float ix2 = std::min(a.x + a.width, b.x + b.width);
|
||||
float iy2 = std::min(a.y + a.height, b.y + b.height);
|
||||
if (ix2 <= ix1 || iy2 <= iy1) return 0.0;
|
||||
float I = (ix2 - ix1) * (iy2 - iy1);
|
||||
float U = a.area() + b.area() - I;
|
||||
return U > 0 ? I / U : 0.0;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace cc
|
||||
@@ -1,63 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <opencv2/core/types.hpp>
|
||||
|
||||
namespace cc {
|
||||
|
||||
struct TrackObservation {
|
||||
int track_id;
|
||||
int class_id;
|
||||
float confidence;
|
||||
int bbox_x1, bbox_y1, bbox_x2, bbox_y2;
|
||||
int centroid_x, centroid_y;
|
||||
std::vector<cv::Point2i> mask_polygon_xy;
|
||||
};
|
||||
|
||||
struct CountEvent {
|
||||
int track_id;
|
||||
int frame_index;
|
||||
int total_entered_after_event;
|
||||
int sequence_number;
|
||||
};
|
||||
|
||||
struct MotionState {
|
||||
float smoothed_speed = 0.0f;
|
||||
int consecutive_reverse_frames = 0;
|
||||
bool backward_active = false;
|
||||
};
|
||||
|
||||
struct FrameResult {
|
||||
int frame_index = 0;
|
||||
std::vector<TrackObservation> tracks;
|
||||
int inside_box_count = 0;
|
||||
int total_entered_count = 0;
|
||||
std::optional<int> latest_validated_track_id;
|
||||
MotionState motion_state;
|
||||
std::vector<CountEvent> count_events;
|
||||
};
|
||||
|
||||
struct PipelineResult {
|
||||
std::string camera_id;
|
||||
int total_entered_count;
|
||||
int frames_processed;
|
||||
std::string stopped_reason;
|
||||
std::string vis_video_path;
|
||||
std::string source_video;
|
||||
double elapsed_seconds;
|
||||
};
|
||||
|
||||
struct CameraBatchResult {
|
||||
std::string camera_id;
|
||||
PipelineResult pipeline;
|
||||
bool skipped = false;
|
||||
std::string skip_reason;
|
||||
std::string compressed_video_path;
|
||||
double compressed_size_mb = 0.0;
|
||||
};
|
||||
|
||||
} // namespace cc
|
||||
@@ -1,66 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <opencv2/videoio.hpp>
|
||||
|
||||
namespace cc {
|
||||
|
||||
inline cv::VideoWriter make_video_writer(
|
||||
const std::string& path,
|
||||
const cv::Size& frame_size,
|
||||
double fps,
|
||||
const std::string& encoder = "auto",
|
||||
int output_bitrate_kbps = 4000,
|
||||
const std::vector<std::string>& codec_preference = {})
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
fs::create_directories(fs::path(path).parent_path());
|
||||
|
||||
int bitrate_bps = std::max(1, output_bitrate_kbps) * 1000;
|
||||
auto codecs = codec_preference.empty()
|
||||
? std::vector<std::string>{"avc1", "mp4v", "H264"}
|
||||
: codec_preference;
|
||||
|
||||
if (encoder == "auto" || encoder == "gstreamer") {
|
||||
int fps_int = std::max(1, static_cast<int>(std::round(fps)));
|
||||
std::string pipeline =
|
||||
"appsrc ! video/x-raw, format=BGR ! "
|
||||
"video/x-raw,width=" + std::to_string(frame_size.width) +
|
||||
",height=" + std::to_string(frame_size.height) +
|
||||
",framerate=" + std::to_string(fps_int) + "/1 ! "
|
||||
"videoconvert ! nvvidconv ! "
|
||||
"video/x-raw(memory:NVMM),format=NV12 ! "
|
||||
"nvv4l2h264enc bitrate=" + std::to_string(bitrate_bps) +
|
||||
" insert-sps-pps=true ! "
|
||||
"h264parse ! mp4mux ! filesink location=" + path;
|
||||
|
||||
cv::VideoWriter writer(pipeline, cv::CAP_GSTREAMER, 0, fps, frame_size, true);
|
||||
if (writer.isOpened()) {
|
||||
std::cerr << "[video] opened GStreamer hardware encoder (bitrate="
|
||||
<< output_bitrate_kbps << " kbps)\n";
|
||||
return writer;
|
||||
}
|
||||
writer.release();
|
||||
if (encoder == "gstreamer")
|
||||
throw std::runtime_error("GStreamer video writer failed for: " + path);
|
||||
}
|
||||
|
||||
for (const auto& codec : codecs) {
|
||||
int fourcc = cv::VideoWriter::fourcc(codec[0], codec[1], codec[2], codec[3]);
|
||||
cv::VideoWriter writer(path, fourcc, fps, frame_size);
|
||||
if (writer.isOpened()) {
|
||||
std::cerr << "[video] opened OpenCV encoder (codec=" << codec << ")\n";
|
||||
return writer;
|
||||
}
|
||||
writer.release();
|
||||
}
|
||||
|
||||
throw std::runtime_error("Unable to open video writer: " + path);
|
||||
}
|
||||
|
||||
} // namespace cc
|
||||
@@ -1,140 +0,0 @@
|
||||
#include "chicken_counter/batch_runner.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "chicken_counter/batch_discovery.hpp"
|
||||
#include "chicken_counter/compress.hpp"
|
||||
#include "chicken_counter/pipeline.hpp"
|
||||
#include "chicken_counter/report.hpp"
|
||||
#include "chicken_counter/tracking.hpp"
|
||||
#include "chicken_counter/types.hpp"
|
||||
|
||||
namespace cc {
|
||||
|
||||
static std::string today_iso() {
|
||||
auto now = std::chrono::system_clock::now();
|
||||
auto t = std::chrono::system_clock::to_time_t(now);
|
||||
char buf[16];
|
||||
std::strftime(buf, sizeof(buf), "%Y-%m-%d", std::localtime(&t));
|
||||
return buf;
|
||||
}
|
||||
|
||||
std::string run_daily_batch(const BatchSettings& settings,
|
||||
const std::string& date,
|
||||
bool verbose,
|
||||
bool no_video,
|
||||
bool show_progress) {
|
||||
namespace fs = std::filesystem;
|
||||
std::string run_date = date.empty() ? today_iso() : date;
|
||||
|
||||
auto day_dir = fs::path(settings.batch.root_dir) / run_date;
|
||||
auto output_dir = day_dir / settings.batch.output_subdir;
|
||||
fs::create_directories(output_dir);
|
||||
|
||||
std::fprintf(stderr, "[batch] starting daily run for %s\n", run_date.c_str());
|
||||
std::fprintf(stderr, "[batch] input folder: %s\n", day_dir.c_str());
|
||||
std::fprintf(stderr, "[batch] output folder: %s\n", output_dir.c_str());
|
||||
if (no_video)
|
||||
std::fprintf(stderr, "[batch] --no-video: skipping video output, overlay, and compression\n");
|
||||
|
||||
auto discovery = discover_camera_videos(day_dir.string(), settings);
|
||||
|
||||
using pair_t = std::pair<std::string, CameraPreset>;
|
||||
std::vector<pair_t> sorted_cams(settings.cameras.begin(), settings.cameras.end());
|
||||
std::sort(sorted_cams.begin(), sorted_cams.end(),
|
||||
[](const pair_t& a, const pair_t& b) {
|
||||
return a.second.camera_num < b.second.camera_num;
|
||||
});
|
||||
|
||||
std::string first_id;
|
||||
for (const auto& [id, _] : sorted_cams) {
|
||||
if (discovery.found.count(id)) { first_id = id; break; }
|
||||
}
|
||||
auto first_source = discovery.found[first_id];
|
||||
auto init_out = no_video ? "" : (output_dir / (first_id + "_vis.mp4")).string();
|
||||
auto init_ckpt = (output_dir / "checkpoints" / first_id).string();
|
||||
|
||||
auto init_cfg = build_camera_config_from_batch(
|
||||
settings, first_id, first_source, init_out, init_ckpt);
|
||||
DetectionTracker shared_tracker(init_cfg);
|
||||
|
||||
std::vector<CameraBatchResult> camera_results;
|
||||
auto report_path = output_dir / ("counts_" + run_date + ".json");
|
||||
|
||||
for (const auto& [camera_id, _] : sorted_cams) {
|
||||
if (discovery.skipped.count(camera_id)) {
|
||||
auto reason = discovery.skipped.at(camera_id);
|
||||
std::fprintf(stderr, "[batch] skipping %s: %s\n", camera_id.c_str(), reason.c_str());
|
||||
CameraBatchResult cr;
|
||||
cr.camera_id = camera_id;
|
||||
cr.skipped = true;
|
||||
cr.skip_reason = reason;
|
||||
camera_results.push_back(cr);
|
||||
persist_batch_reports(run_date, camera_results, output_dir.string());
|
||||
continue;
|
||||
}
|
||||
|
||||
auto source_path = discovery.found.at(camera_id);
|
||||
auto vis_path = no_video ? "" : (output_dir / (camera_id + "_vis.mp4")).string();
|
||||
auto checkpoint_dir = (output_dir / "checkpoints" / camera_id).string();
|
||||
|
||||
std::fprintf(stderr, "[batch] processing %s from %s\n",
|
||||
camera_id.c_str(),
|
||||
fs::path(source_path).filename().c_str());
|
||||
|
||||
auto cam_cfg = build_camera_config_from_batch(
|
||||
settings, camera_id, source_path, vis_path, checkpoint_dir);
|
||||
cam_cfg.performance.verbose = verbose;
|
||||
|
||||
auto result = run_pipeline(cam_cfg, &shared_tracker, show_progress);
|
||||
|
||||
CameraBatchResult cr;
|
||||
cr.camera_id = camera_id;
|
||||
cr.pipeline = result;
|
||||
camera_results.push_back(cr);
|
||||
|
||||
std::fprintf(stderr, "[batch] finished %s: total_entered=%d frames=%d reason=%s\n",
|
||||
camera_id.c_str(), result.total_entered_count,
|
||||
result.frames_processed, result.stopped_reason.c_str());
|
||||
|
||||
persist_batch_reports(run_date, camera_results, output_dir.string());
|
||||
}
|
||||
|
||||
if (no_video) {
|
||||
auto report = build_batch_report(run_date, camera_results, output_dir.string());
|
||||
std::fprintf(stderr, "[batch] complete for %s: total_entered_sum=%d report=%s\n",
|
||||
run_date.c_str(), report.total_entered_sum, report_path.c_str());
|
||||
return report_path.string();
|
||||
}
|
||||
|
||||
std::fprintf(stderr, "[batch] all cameras complete; starting compression\n");
|
||||
for (auto& item : camera_results) {
|
||||
if (item.skipped) continue;
|
||||
auto vis_path = item.pipeline.vis_video_path;
|
||||
if (vis_path.empty()) continue;
|
||||
auto compressed_path = (output_dir / (item.camera_id + "_compressed.mp4")).string();
|
||||
double size_mb = compress_video_to_target(
|
||||
vis_path, compressed_path,
|
||||
settings.batch.compress_max_mb);
|
||||
item.compressed_video_path = compressed_path;
|
||||
item.compressed_size_mb = size_mb;
|
||||
|
||||
if (settings.batch.delete_intermediate)
|
||||
fs::remove(vis_path);
|
||||
|
||||
persist_batch_reports(run_date, camera_results, output_dir.string());
|
||||
}
|
||||
|
||||
auto report = build_batch_report(run_date, camera_results, output_dir.string());
|
||||
std::fprintf(stderr, "[batch] complete for %s: total_entered_sum=%d report=%s\n",
|
||||
run_date.c_str(), report.total_entered_sum, report_path.c_str());
|
||||
return report_path.string();
|
||||
}
|
||||
|
||||
} // namespace cc
|
||||
@@ -1,284 +0,0 @@
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static const int DEFAULT_PORT = 8080;
|
||||
static const char* DEFAULT_SHM = "/dev/shm";
|
||||
static const int DEFAULT_POLL_MS = 500;
|
||||
|
||||
static const char* HTML = R"~(<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Chicken Counter - Live Dashboard</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:hidden}
|
||||
#app{display:flex;height:100vh}
|
||||
#sidebar{width:260px;background:#16161e;padding:16px;overflow-y:auto;flex-shrink:0}
|
||||
#sidebar h1{font-size:18px;color:#80dc5a;margin-bottom:16px}
|
||||
#sidebar .stat{margin-bottom:12px}
|
||||
#sidebar .stat label{display:block;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:1px}
|
||||
#sidebar .stat .value{font-size:22px;font-weight:700;color:#e0e0e0}
|
||||
#sidebar .stat .value.warn{color:#ff9f43}
|
||||
#sidebar .stat .value.good{color:#80dc5a}
|
||||
#cam-list{list-style:none;margin-top:16px}
|
||||
#cam-list li{padding:8px 10px;margin:2px 0;border-radius:6px;cursor:pointer;font-size:13px;transition:background .2s}
|
||||
#cam-list li:hover{background:#222}
|
||||
#cam-list li.active{background:#1a3a2a;color:#80dc5a;font-weight:700}
|
||||
#cam-list li .cam-badge{float:right;font-size:10px;padding:1px 6px;border-radius:8px;background:#222;color:#888}
|
||||
#cam-list li.active .cam-badge{background:#2a5a3a;color:#80dc5a}
|
||||
#main{flex:1;display:flex;flex-direction:column}
|
||||
#frame-container{flex:1;display:flex;align-items:center;justify-content:center;background:#000;position:relative}
|
||||
#frame-img{max-width:100%;max-height:100%;object-fit:contain}
|
||||
#no-frame{color:#555;font-size:18px}
|
||||
#top-bar{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:#16161e;font-size:12px}
|
||||
#top-bar .refresh{color:#888}
|
||||
#top-bar .status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
|
||||
#top-bar .status-dot.online{background:#80dc5a;box-shadow:0 0 6px #80dc5a}
|
||||
#top-bar .status-dot.offline{background:#555}
|
||||
.refresh-btn{padding:4px 12px;border-radius:4px;background:#222;border:1px solid #444;color:#ccc;cursor:pointer;font-size:11px}
|
||||
.refresh-btn:hover{background:#333}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="sidebar">
|
||||
<h1>Chicken Counter</h1>
|
||||
<div class="stat"><label>Total Entered</label><div class="value good" id="stat-total">--</div></div>
|
||||
<div class="stat"><label>Inside Box</label><div class="value" id="stat-inside">--</div></div>
|
||||
<div class="stat"><label>Tracks</label><div class="value" id="stat-tracks">--</div></div>
|
||||
<div class="stat"><label>Frame</label><div class="value" id="stat-frame">--</div></div>
|
||||
<div class="stat"><label>Motion Speed</label><div class="value" id="stat-speed">--</div></div>
|
||||
<div class="stat"><label>Status</label><div class="value" id="stat-status">--</div></div>
|
||||
<ul id="cam-list"></ul>
|
||||
</div>
|
||||
<div id="main">
|
||||
<div id="top-bar">
|
||||
<span><span class="status-dot" id="status-dot"></span><span id="status-text">waiting for pipeline...</span></span>
|
||||
<span><span class="refresh" id="refresh-counter"></span> ago
|
||||
<button class="refresh-btn" onclick="load()">Refresh</button></span>
|
||||
</div>
|
||||
<div id="frame-container">
|
||||
<img id="frame-img" alt="live stream">
|
||||
<div id="no-frame"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
var POLL_MS=%%POLL%%;
|
||||
var SHM="%%SHM%%";
|
||||
var cameras=[],activeCam=null,lastUpdate=0;
|
||||
var img=document.getElementById("frame-img");
|
||||
var noFrame=document.getElementById("no-frame");
|
||||
function loadCameras(){fetch("/api/cameras").then(r=>r.json()).then(data=>{cameras=data.cameras||[];renderCamList();if(cameras.length&&!activeCam)selectCam(cameras[0]);if(!cameras.length){noFrame.textContent="No cameras in "+SHM;img.style.display="none";}});}
|
||||
function renderCamList(){var ul=document.getElementById("cam-list");ul.innerHTML=cameras.map(function(c){return'<li class="'+(c===activeCam?"active":"")+'" onclick="selectCam(\''+c+'\')">'+c+'<span class="cam-badge">▶</span></li>';}).join("");}
|
||||
function selectCam(id){activeCam=id;renderCamList();load();}
|
||||
function load(){if(!activeCam)return;var t=Date.now();img.src="/shm/"+activeCam+"/frame.jpg?t="+t;fetch("/shm/"+activeCam+"/stats.json?t="+t).then(function(r){if(!r.ok){setOffline();return;}return r.json();}).then(function(s){if(!s)return;lastUpdate=Date.now();document.getElementById("stat-total").textContent=s.total_entered_count;document.getElementById("stat-inside").textContent=s.inside_box_count;document.getElementById("stat-tracks").textContent=s.track_count;document.getElementById("stat-frame").textContent=s.frame_index;document.getElementById("stat-speed").textContent=s.smoothed_speed;document.getElementById("stat-status").textContent=s.backward_active?"BACKWARD STOP":"RUNNING";var el=document.getElementById("stat-status");el.className="value"+(s.backward_active?" warn":" good");document.getElementById("status-dot").className="status-dot online";document.getElementById("status-text").textContent=activeCam+" - frame "+s.frame_index;});}
|
||||
function setOffline(){document.getElementById("status-dot").className="status-dot offline";document.getElementById("status-text").textContent=activeCam+" - offline";}
|
||||
function updateRefresh(){var ago=Math.round((Date.now()-lastUpdate)/1000);document.getElementById("refresh-counter").textContent=ago+"s";}
|
||||
img.onerror=function(){img.style.display="none";noFrame.style.display="block";noFrame.textContent="Waiting for frame...";};
|
||||
img.onload=function(){img.style.display="block";noFrame.style.display="none";};
|
||||
setInterval(function(){load();},POLL_MS);
|
||||
setInterval(loadCameras,3000);
|
||||
setInterval(updateRefresh,1000);
|
||||
loadCameras();
|
||||
</script>
|
||||
</body>
|
||||
</html>)~";
|
||||
|
||||
static std::string url_decode(const std::string& s) {
|
||||
std::string r;
|
||||
for (size_t i = 0; i < s.size(); ++i) {
|
||||
if (s[i] == '%' && i + 2 < s.size()) {
|
||||
int v;
|
||||
sscanf(s.c_str() + i + 1, "%2x", &v);
|
||||
r += static_cast<char>(v);
|
||||
i += 2;
|
||||
} else {
|
||||
r += s[i];
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
static std::string read_file(const std::string& path) {
|
||||
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||
if (!f) return "";
|
||||
auto sz = f.tellg();
|
||||
f.seekg(0);
|
||||
std::string data(sz, 0);
|
||||
f.read(data.data(), sz);
|
||||
return data;
|
||||
}
|
||||
|
||||
static bool ends_with(const std::string& s, const std::string& suffix) {
|
||||
return s.size() >= suffix.size() && s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0;
|
||||
}
|
||||
static bool starts_with(const std::string& s, const std::string& prefix) {
|
||||
return s.size() >= prefix.size() && s.compare(0, prefix.size(), prefix) == 0;
|
||||
}
|
||||
|
||||
static std::string get_mime(const std::string& path) {
|
||||
if (ends_with(path, ".jpg") || ends_with(path, ".jpeg")) return "image/jpeg";
|
||||
if (ends_with(path, ".json")) return "application/json";
|
||||
if (ends_with(path, ".html")) return "text/html; charset=utf-8";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
static std::string http_response(int code, const std::string& ct,
|
||||
const std::string& body) {
|
||||
std::ostringstream r;
|
||||
r << "HTTP/1.0 " << code << " OK\r\n";
|
||||
r << "Content-Type: " << ct << "\r\n";
|
||||
r << "Content-Length: " << body.size() << "\r\n";
|
||||
r << "Cache-Control: no-cache, no-store, must-revalidate\r\n";
|
||||
r << "Connection: close\r\n";
|
||||
r << "\r\n" << body;
|
||||
return r.str();
|
||||
}
|
||||
|
||||
static std::string str_replace(std::string s, const std::string& from,
|
||||
const std::string& to) {
|
||||
size_t pos = s.find(from);
|
||||
if (pos != std::string::npos) s.replace(pos, from.size(), to);
|
||||
return s;
|
||||
}
|
||||
|
||||
static std::string json_escape(const std::string& s) {
|
||||
std::ostringstream r;
|
||||
r << '"';
|
||||
for (char c : s) {
|
||||
if (c == '"') r << "\\\"";
|
||||
else if (c == '\\') r << "\\\\";
|
||||
else r << c;
|
||||
}
|
||||
r << '"';
|
||||
return r.str();
|
||||
}
|
||||
|
||||
static void handle_client(int fd, const std::string& shm_dir, int poll_ms) {
|
||||
char buf[8192];
|
||||
ssize_t n = recv(fd, buf, sizeof(buf) - 1, 0);
|
||||
if (n <= 0) { close(fd); return; }
|
||||
buf[n] = 0;
|
||||
|
||||
std::string req(buf);
|
||||
if (req.find("GET ") != 0) { close(fd); return; }
|
||||
|
||||
// parse path
|
||||
size_t p1 = req.find(' ');
|
||||
size_t p2 = req.find(' ', p1 + 1);
|
||||
std::string path = url_decode(req.substr(p1 + 1, p2 - p1 - 1));
|
||||
|
||||
// strip query string
|
||||
size_t q = path.find('?');
|
||||
if (q != std::string::npos) path = path.substr(0, q);
|
||||
|
||||
std::string resp;
|
||||
|
||||
if (path == "/" || path == "/index.html") {
|
||||
std::string html = HTML;
|
||||
html = str_replace(html, "%%POLL%%", std::to_string(poll_ms));
|
||||
html = str_replace(html, "%%SHM%%", shm_dir);
|
||||
resp = http_response(200, "text/html; charset=utf-8", html);
|
||||
} else if (path == "/api/cameras") {
|
||||
std::string cams = "[]";
|
||||
if (std::filesystem::is_directory(shm_dir)) {
|
||||
std::ostringstream arr;
|
||||
arr << "[";
|
||||
bool first = true;
|
||||
for (auto& entry : std::filesystem::directory_iterator(shm_dir)) {
|
||||
if (!entry.is_directory()) continue;
|
||||
std::string name = entry.path().filename().string();
|
||||
if (!starts_with(name, "chicken_counter_")) continue;
|
||||
if (!first) arr << ","; first = false;
|
||||
arr << json_escape(name.substr(17)); // strip "chicken_counter_"
|
||||
}
|
||||
arr << "]";
|
||||
cams = arr.str();
|
||||
}
|
||||
resp = http_response(200, "application/json", "{\"cameras\":" + cams + "}");
|
||||
} else if (starts_with(path, "/shm/")) {
|
||||
std::string rel = path.substr(5);
|
||||
size_t slash = rel.find('/');
|
||||
if (slash != std::string::npos) {
|
||||
std::string cam = "chicken_counter_" + rel.substr(0, slash);
|
||||
std::string file = rel.substr(slash + 1);
|
||||
std::string fpath = shm_dir + "/" + cam + "/" + file;
|
||||
|
||||
// security: avoid path traversal
|
||||
auto resolved = std::filesystem::weakly_canonical(fpath);
|
||||
auto base = std::filesystem::weakly_canonical(shm_dir);
|
||||
if (starts_with(resolved.string(), base.string())) {
|
||||
auto data = read_file(resolved.string());
|
||||
if (!data.empty()) {
|
||||
resp = http_response(200, get_mime(file), data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resp.empty())
|
||||
resp = "HTTP/1.0 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
|
||||
send(fd, resp.data(), resp.size(), 0);
|
||||
close(fd);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
int port = DEFAULT_PORT;
|
||||
std::string shm_dir = DEFAULT_SHM;
|
||||
int poll_ms = DEFAULT_POLL_MS;
|
||||
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
std::string arg = argv[i];
|
||||
if (arg == "--port" && i + 1 < argc) port = std::stoi(argv[++i]);
|
||||
else if (arg == "--shm-dir" && i + 1 < argc) shm_dir = argv[++i];
|
||||
else if (arg == "--poll-ms" && i + 1 < argc) poll_ms = std::stoi(argv[++i]);
|
||||
}
|
||||
|
||||
int sock = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (sock < 0) { perror("socket"); return 1; }
|
||||
|
||||
int opt = 1;
|
||||
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
|
||||
|
||||
sockaddr_in addr{};
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_addr.s_addr = INADDR_ANY;
|
||||
addr.sin_port = htons(port);
|
||||
|
||||
if (bind(sock, (sockaddr*)&addr, sizeof(addr)) < 0) {
|
||||
perror("bind"); return 1;
|
||||
}
|
||||
listen(sock, 16);
|
||||
|
||||
std::cerr << "[dashboard] serving at http://0.0.0.0:" << port << "\n";
|
||||
std::cerr << "[dashboard] shm_dir=" << shm_dir << " poll=" << poll_ms << "ms\n";
|
||||
|
||||
while (true) {
|
||||
sockaddr_in client{};
|
||||
socklen_t len = sizeof(client);
|
||||
int client_fd = accept(sock, (sockaddr*)&client, &len);
|
||||
if (client_fd < 0) continue;
|
||||
|
||||
std::thread(handle_client, client_fd, shm_dir, poll_ms).detach();
|
||||
}
|
||||
|
||||
close(sock);
|
||||
return 0;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
#include "chicken_counter/batch_runner.hpp"
|
||||
#include "chicken_counter/config.hpp"
|
||||
#include "chicken_counter/pipeline.hpp"
|
||||
|
||||
static void print_usage() {
|
||||
std::cerr <<
|
||||
"Usage: chicken_counter run --config PATH [--camera-id ID] [--verbose] [--progress-bar]\n"
|
||||
" chicken_counter batch --config PATH [--date YYYY-MM-DD] [--verbose] [--no-video] [--progress-bar]\n";
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc < 2) { print_usage(); return 1; }
|
||||
|
||||
std::string command = argv[1];
|
||||
|
||||
// parse optional args
|
||||
std::string config_path, camera_id, date;
|
||||
bool verbose = false, no_video = false, progress_bar = false;
|
||||
|
||||
for (int i = 2; i < argc; ++i) {
|
||||
std::string arg = argv[i];
|
||||
if (arg == "--config" && i + 1 < argc) config_path = argv[++i];
|
||||
else if (arg == "--camera-id" && i + 1 < argc) camera_id = argv[++i];
|
||||
else if (arg == "--date" && i + 1 < argc) date = argv[++i];
|
||||
else if (arg == "--verbose") verbose = true;
|
||||
else if (arg == "--no-video") no_video = true;
|
||||
else if (arg == "--progress-bar") progress_bar = true;
|
||||
}
|
||||
|
||||
if (config_path.empty()) {
|
||||
std::cerr << "error: --config is required\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (command == "batch") {
|
||||
auto settings = cc::load_batch_config(config_path);
|
||||
cc::run_daily_batch(settings, date, verbose, no_video, progress_bar);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (command == "run") {
|
||||
auto cfg = cc::load_camera_config(config_path, camera_id);
|
||||
cfg.performance.verbose = verbose;
|
||||
auto result = cc::run_pipeline(cfg, nullptr, progress_bar);
|
||||
std::cout << "[done] camera=" << result.camera_id
|
||||
<< " total_entered=" << result.total_entered_count
|
||||
<< " frames=" << result.frames_processed
|
||||
<< " reason=" << result.stopped_reason << "\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::cerr << "error: unknown command '" << command << "'\n";
|
||||
print_usage();
|
||||
return 1;
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
#include "chicken_counter/pipeline.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <opencv2/highgui.hpp>
|
||||
#include <opencv2/imgcodecs.hpp>
|
||||
#include <opencv2/videoio.hpp>
|
||||
|
||||
#include "chicken_counter/capture.hpp"
|
||||
#include "chicken_counter/overlay.hpp"
|
||||
#include "chicken_counter/video_writer.hpp"
|
||||
|
||||
namespace cc {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Progress bar (same as Python _ProgressBar, stderr inline)
|
||||
// ---------------------------------------------------------------------------
|
||||
static std::string format_duration(double seconds) {
|
||||
if (seconds < 60.0) return std::to_string(static_cast<int>(seconds)) + "s";
|
||||
int total = static_cast<int>(seconds);
|
||||
int minutes = total / 60;
|
||||
int secs = total % 60;
|
||||
if (minutes < 60) return std::to_string(minutes) + "m" + (secs < 10 ? "0" : "") + std::to_string(secs) + "s";
|
||||
int hours = minutes / 60;
|
||||
minutes %= 60;
|
||||
return std::to_string(hours) + "h" + (minutes < 10 ? "0" : "") + std::to_string(minutes) + "m";
|
||||
}
|
||||
|
||||
class ProgressBar {
|
||||
public:
|
||||
ProgressBar(int total, int width = 30) : _total(total), _width(width) {}
|
||||
|
||||
void render(int frame_idx, double elapsed, double fps,
|
||||
int inside, int total_entered, bool backward) {
|
||||
double now = std::chrono::duration<double>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
if (now - _last_render < 0.2 && frame_idx > 1 && _checkpoint_msg.empty()) return;
|
||||
_last_render = now;
|
||||
|
||||
std::string cp = build_checkpoint_suffix();
|
||||
std::string status = backward ? "backward" : "running";
|
||||
std::string elapsed_s = format_duration(elapsed);
|
||||
|
||||
std::string line;
|
||||
if (_total > 0) {
|
||||
int pct = std::min(100, frame_idx * 100 / _total);
|
||||
int filled = _width * pct / 100;
|
||||
std::string bar = "[" + std::string(filled, '=') + ">" + std::string(_width - filled, ' ') + "]";
|
||||
|
||||
double eta_s = fps > 0 ? (_total - frame_idx) / fps : 0.0;
|
||||
char buf[256];
|
||||
snprintf(buf, sizeof(buf), "\r%s %3d%% %d/%d %s eta=%s %.1ffps count=%d/%d %s%s",
|
||||
bar.c_str(), pct, frame_idx, _total,
|
||||
elapsed_s.c_str(), format_duration(eta_s).c_str(),
|
||||
fps, inside, total_entered, status.c_str(), cp.c_str());
|
||||
line = buf;
|
||||
} else {
|
||||
char buf[256];
|
||||
snprintf(buf, sizeof(buf), "\rframe=%d %s %.1ffps count=%d/%d %s%s",
|
||||
frame_idx, elapsed_s.c_str(), fps,
|
||||
inside, total_entered, status.c_str(), cp.c_str());
|
||||
line = buf;
|
||||
}
|
||||
|
||||
int pad = std::max(0, _last_line_len - static_cast<int>(line.size()));
|
||||
_last_line_len = static_cast<int>(line.size());
|
||||
std::fprintf(stderr, "%s%s", line.c_str(), std::string(pad, ' ').c_str());
|
||||
std::fflush(stderr);
|
||||
}
|
||||
|
||||
void emit(const std::string& msg) {
|
||||
_checkpoint_msg = msg;
|
||||
_last_render = 0.0;
|
||||
}
|
||||
|
||||
void finish() {
|
||||
std::fprintf(stderr, "\n");
|
||||
std::fflush(stderr);
|
||||
}
|
||||
|
||||
bool has_pending() const { return !_checkpoint_msg.empty(); }
|
||||
|
||||
private:
|
||||
int _total, _width;
|
||||
double _last_render = 0.0;
|
||||
int _last_line_len = 0;
|
||||
std::string _checkpoint_msg;
|
||||
|
||||
std::string build_checkpoint_suffix() {
|
||||
if (_checkpoint_msg.empty()) return "";
|
||||
std::string m = _checkpoint_msg;
|
||||
_checkpoint_msg.clear();
|
||||
return " [" + m + "]";
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// build_pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
PipelineArtifacts build_pipeline(const CameraConfig& config,
|
||||
DetectionTracker* tracker) {
|
||||
PipelineArtifacts art{};
|
||||
|
||||
art.capture = open_capture(config.source);
|
||||
art.owns_tracker = (tracker == nullptr);
|
||||
art.tracker = tracker ? tracker : new DetectionTracker(config);
|
||||
|
||||
art.counting_zone = CountingZone(
|
||||
config.roi, config.gate,
|
||||
config.overlay.trail_length,
|
||||
config.tracker.track_buffer,
|
||||
config.detection.min_box_area_px,
|
||||
config.detection.validate_while_inside,
|
||||
config.performance.verbose);
|
||||
|
||||
art.motion_detector = BackwardMotionDetector(
|
||||
config.motion, config.roi, config.performance.verbose);
|
||||
|
||||
int width = static_cast<int>(art.capture.get(cv::CAP_PROP_FRAME_WIDTH));
|
||||
int height = static_cast<int>(art.capture.get(cv::CAP_PROP_FRAME_HEIGHT));
|
||||
int fc = static_cast<int>(art.capture.get(cv::CAP_PROP_FRAME_COUNT));
|
||||
art.total_source_frames = (fc > 0) ? fc : 0;
|
||||
|
||||
if (config.detection_zone.enabled && width > 0 && height > 0) {
|
||||
art.detection_zone_rect = config.detection_zone.compute_rect(config.roi, width, height);
|
||||
art.has_detection_zone = true;
|
||||
std::fprintf(stderr, "[detection_zone] enabled crop=(%d,%d)-(%d,%d)\n",
|
||||
art.detection_zone_rect.x, art.detection_zone_rect.y,
|
||||
art.detection_zone_rect.x + art.detection_zone_rect.width,
|
||||
art.detection_zone_rect.y + art.detection_zone_rect.height);
|
||||
}
|
||||
|
||||
if (config.performance.overlay_buffer_reuse && width > 0 && height > 0)
|
||||
art.overlay_buffer = cv::Mat(height, width, CV_8UC3);
|
||||
|
||||
art.has_writer = !config.display.output_path.empty();
|
||||
if (art.has_writer) {
|
||||
double fps = config.display.write_fps > 0
|
||||
? static_cast<double>(config.display.write_fps)
|
||||
: art.capture.get(cv::CAP_PROP_FPS);
|
||||
if (fps <= 0) fps = 30.0;
|
||||
art.writer = make_video_writer(
|
||||
config.display.output_path, {width, height}, fps,
|
||||
config.display.encoder, config.display.output_bitrate_kbps,
|
||||
config.display.codec_preference);
|
||||
}
|
||||
|
||||
if (config.stream.enabled) {
|
||||
namespace fs = std::filesystem;
|
||||
auto cam_dir = fs::path(config.stream.shm_dir) / ("chicken_counter_" + config.camera_id);
|
||||
if (fs::exists(cam_dir)) {
|
||||
fs::remove_all(cam_dir);
|
||||
std::fprintf(stderr, "[stream] cleaned %s\n", cam_dir.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
art.run_start_time = std::chrono::duration<double>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
|
||||
return art;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: should_emit_feedback
|
||||
// ---------------------------------------------------------------------------
|
||||
static bool should_emit_feedback(const CameraConfig& cfg, int frame_idx) {
|
||||
if (!cfg.feedback.enabled || cfg.feedback.every_n_frames <= 0) return false;
|
||||
return frame_idx % cfg.feedback.every_n_frames == 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: emit_periodic_feedback
|
||||
// ---------------------------------------------------------------------------
|
||||
static void emit_periodic_feedback(const CameraConfig& cfg,
|
||||
const PipelineArtifacts& art,
|
||||
const cv::Mat& annotated,
|
||||
const FrameResult& result,
|
||||
ProgressBar* progress) {
|
||||
if (cfg.feedback.log_to_terminal) {
|
||||
double elapsed = std::chrono::duration<double>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()).count()
|
||||
- art.run_start_time;
|
||||
double fps = result.frame_index / elapsed;
|
||||
std::string status = result.motion_state.backward_active ? "backward_stop" : "running";
|
||||
|
||||
char buf[512];
|
||||
if (art.total_source_frames > 0) {
|
||||
double eta_s = fps > 0 ? (art.total_source_frames - result.frame_index) / fps : 0.0;
|
||||
snprintf(buf, sizeof(buf),
|
||||
"[checkpoint] frame=%d/%d elapsed=%s fps=%.1f inside_box=%d "
|
||||
"total_entered=%d backward_active=%d status=%s eta=%s",
|
||||
result.frame_index, art.total_source_frames,
|
||||
format_duration(elapsed).c_str(), fps,
|
||||
result.inside_box_count, result.total_entered_count,
|
||||
result.motion_state.backward_active, status.c_str(),
|
||||
format_duration(eta_s).c_str());
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf),
|
||||
"[checkpoint] frame=%d elapsed=%s fps=%.1f inside_box=%d "
|
||||
"total_entered=%d backward_active=%d status=%s",
|
||||
result.frame_index, format_duration(elapsed).c_str(), fps,
|
||||
result.inside_box_count, result.total_entered_count,
|
||||
result.motion_state.backward_active, status.c_str());
|
||||
}
|
||||
|
||||
if (progress) progress->emit(buf);
|
||||
else std::fprintf(stderr, "\r\033[K%s\n", buf);
|
||||
}
|
||||
|
||||
if (cfg.feedback.save_images) {
|
||||
namespace fs = std::filesystem;
|
||||
fs::create_directories(cfg.feedback.image_output_dir);
|
||||
char fname[512];
|
||||
snprintf(fname, sizeof(fname), "%s/frame_%06d.jpg",
|
||||
cfg.feedback.image_output_dir.c_str(), result.frame_index);
|
||||
cv::imwrite(fname, annotated);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: write_stream_frame
|
||||
// ---------------------------------------------------------------------------
|
||||
static void write_stream_frame(const std::string& shm_dir,
|
||||
const std::string& camera_id,
|
||||
const cv::Mat& frame,
|
||||
const FrameResult& result) {
|
||||
namespace fs = std::filesystem;
|
||||
auto cam_dir = fs::path(shm_dir) / ("chicken_counter_" + camera_id);
|
||||
fs::create_directories(cam_dir);
|
||||
|
||||
auto jpg_path = cam_dir / "frame.jpg";
|
||||
auto tmp_jpg = cam_dir / ".frame_tmp.jpg";
|
||||
cv::imwrite(tmp_jpg.string(), frame, {cv::IMWRITE_JPEG_QUALITY, 75});
|
||||
fs::rename(tmp_jpg, jpg_path);
|
||||
|
||||
// Write stats.json atomically
|
||||
auto stats_path = cam_dir / "stats.json";
|
||||
auto tmp_stats = cam_dir / ".stats_tmp.json";
|
||||
{
|
||||
std::ofstream f(tmp_stats);
|
||||
f << "{\"frame_index\":" << result.frame_index
|
||||
<< ",\"inside_box_count\":" << result.inside_box_count
|
||||
<< ",\"total_entered_count\":" << result.total_entered_count
|
||||
<< ",\"track_count\":" << result.tracks.size()
|
||||
<< ",\"backward_active\":" << (result.motion_state.backward_active ? "true" : "false")
|
||||
<< ",\"smoothed_speed\":" << result.motion_state.smoothed_speed
|
||||
<< ",\"count_events\":" << result.count_events.size() << "}";
|
||||
}
|
||||
fs::rename(tmp_stats, stats_path);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// run_pipeline (main loop)
|
||||
// ---------------------------------------------------------------------------
|
||||
PipelineResult run_pipeline(const CameraConfig& config,
|
||||
DetectionTracker* tracker,
|
||||
bool show_progress) {
|
||||
if (tracker) {
|
||||
tracker->config = config;
|
||||
tracker->reset_tracking();
|
||||
}
|
||||
|
||||
auto art = build_pipeline(config, tracker);
|
||||
std::unique_ptr<DetectionTracker> owned_tracker;
|
||||
if (art.owns_tracker) owned_tracker.reset(art.tracker);
|
||||
|
||||
int inference_stride = std::max(1, config.performance.inference_stride);
|
||||
std::fprintf(stderr, "[perf] inference_stride=%d motion.stride_frames=%d motion.flow_scale=%.1f\n",
|
||||
inference_stride, std::max(1, config.motion.stride_frames),
|
||||
config.motion.flow_scale);
|
||||
|
||||
int frame_index = 0;
|
||||
cv::Mat last_annotated;
|
||||
std::vector<TrackObservation> last_tracks;
|
||||
std::string stopped_reason = "eof";
|
||||
bool user_quit = false;
|
||||
bool verbose = config.performance.verbose;
|
||||
int total_source_frames = art.total_source_frames;
|
||||
int verbose_interval = std::max(1, inference_stride * 30);
|
||||
|
||||
ProgressBar* progress = nullptr;
|
||||
if (show_progress) progress = new ProgressBar(total_source_frames);
|
||||
|
||||
// verbose timing accumulators
|
||||
double cum_read = 0, cum_infer = 0, cum_motion = 0, cum_count = 0, cum_overlay = 0, cum_write = 0;
|
||||
int timed_frames = 0;
|
||||
|
||||
auto t_loop_start = std::chrono::steady_clock::now();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
auto t0 = verbose ? std::chrono::steady_clock::now() : t_loop_start;
|
||||
|
||||
cv::Mat frame;
|
||||
if (!art.capture.read(frame)) break;
|
||||
++frame_index;
|
||||
|
||||
auto t_read = std::chrono::steady_clock::now();
|
||||
|
||||
if (frame_index % inference_stride == 0 || last_tracks.empty()) {
|
||||
cv::Rect crop = art.has_detection_zone ? art.detection_zone_rect : cv::Rect{};
|
||||
last_tracks = art.tracker->infer(frame, crop);
|
||||
}
|
||||
|
||||
auto t_infer = std::chrono::steady_clock::now();
|
||||
|
||||
auto& tracks = last_tracks;
|
||||
auto motion_state = art.motion_detector.update(frame, tracks, frame_index);
|
||||
|
||||
auto t_motion = std::chrono::steady_clock::now();
|
||||
|
||||
auto count_events = art.counting_zone.update(
|
||||
tracks, frame_index, motion_state.backward_active);
|
||||
|
||||
auto t_count = std::chrono::steady_clock::now();
|
||||
|
||||
bool needs_overlay = config.display.show_window
|
||||
|| art.has_writer
|
||||
|| config.stream.enabled;
|
||||
cv::Mat annotated;
|
||||
if (needs_overlay) {
|
||||
annotated = draw_overlay(frame, config, art.counting_zone,
|
||||
tracks, motion_state, frame_index,
|
||||
art.overlay_buffer.empty() ? nullptr : &art.overlay_buffer);
|
||||
} else {
|
||||
annotated = frame;
|
||||
}
|
||||
|
||||
auto t_overlay = std::chrono::steady_clock::now();
|
||||
|
||||
FrameResult result;
|
||||
result.frame_index = frame_index;
|
||||
result.tracks = tracks;
|
||||
result.inside_box_count = art.counting_zone.inside_box_count;
|
||||
result.total_entered_count = art.counting_zone.total_entered_count;
|
||||
result.motion_state = motion_state;
|
||||
result.count_events = count_events;
|
||||
|
||||
// consume result
|
||||
if (config.display.show_window) {
|
||||
cv::imshow(config.display.window_name, annotated);
|
||||
}
|
||||
if (art.has_writer && art.writer.isOpened()) {
|
||||
art.writer.write(annotated);
|
||||
}
|
||||
for (const auto& ev : count_events) {
|
||||
if (config.performance.verbose) {
|
||||
char buf[256];
|
||||
snprintf(buf, sizeof(buf),
|
||||
"[frame %d] counted track=%d inside_box=%d total_entered=%d",
|
||||
ev.frame_index, ev.track_id,
|
||||
result.inside_box_count, ev.total_entered_after_event);
|
||||
if (progress) progress->emit(buf);
|
||||
else std::fprintf(stderr, "%s\n", buf);
|
||||
}
|
||||
}
|
||||
if (should_emit_feedback(config, frame_index)) {
|
||||
emit_periodic_feedback(config, art, annotated, result, progress);
|
||||
}
|
||||
|
||||
last_annotated = annotated;
|
||||
|
||||
if (config.stream.enabled
|
||||
&& frame_index % std::max(1, config.stream.interval_frames) == 0) {
|
||||
write_stream_frame(config.stream.shm_dir, config.camera_id,
|
||||
annotated, result);
|
||||
}
|
||||
|
||||
auto t_write = std::chrono::steady_clock::now();
|
||||
|
||||
if (verbose) {
|
||||
auto to_ms = [](auto start, auto end) {
|
||||
return std::chrono::duration<double, std::milli>(end - start).count();
|
||||
};
|
||||
if (frame_index % inference_stride == 0) {
|
||||
cum_read += to_ms(t0, t_read);
|
||||
cum_infer += to_ms(t_read, t_infer);
|
||||
cum_motion += to_ms(t_infer, t_motion);
|
||||
cum_count += to_ms(t_motion, t_count);
|
||||
cum_overlay += to_ms(t_count, t_overlay);
|
||||
cum_write += to_ms(t_overlay, t_write);
|
||||
++timed_frames;
|
||||
}
|
||||
if (frame_index % verbose_interval == 0 && timed_frames > 0) {
|
||||
double n = timed_frames;
|
||||
std::fprintf(stderr,
|
||||
"[debug ~%df avg ms] read=%.1f infer=%.1f motion=%.1f "
|
||||
"count=%.1f overlay=%.1f write=%.1f tracks=%zu inside=%d total=%d "
|
||||
"motion_speed=%.1f backward=%d\n",
|
||||
verbose_interval,
|
||||
cum_read / n, cum_infer / n, cum_motion / n,
|
||||
cum_count / n, cum_overlay / n, cum_write / n,
|
||||
tracks.size(),
|
||||
art.counting_zone.inside_box_count,
|
||||
art.counting_zone.total_entered_count,
|
||||
motion_state.smoothed_speed,
|
||||
motion_state.backward_active);
|
||||
cum_read = cum_infer = cum_motion = cum_count = cum_overlay = cum_write = 0;
|
||||
timed_frames = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (progress) {
|
||||
auto elapsed = std::chrono::duration<double>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()).count()
|
||||
- art.run_start_time;
|
||||
double fps = frame_index / elapsed;
|
||||
progress->render(frame_index, elapsed, fps,
|
||||
art.counting_zone.inside_box_count,
|
||||
art.counting_zone.total_entered_count,
|
||||
motion_state.backward_active);
|
||||
}
|
||||
|
||||
if (motion_state.backward_active) {
|
||||
stopped_reason = "backward";
|
||||
char buf[128];
|
||||
snprintf(buf, sizeof(buf),
|
||||
"[stop] backward detection confirmed at frame=%d; ending pipeline",
|
||||
frame_index);
|
||||
if (progress) progress->emit(buf);
|
||||
else std::fprintf(stderr, "%s\n", buf);
|
||||
break;
|
||||
}
|
||||
|
||||
if (config.display.max_frames > 0 && frame_index >= config.display.max_frames) {
|
||||
stopped_reason = "max_frames";
|
||||
break;
|
||||
}
|
||||
if (config.display.show_window && (cv::waitKey(1) & 0xFF) == 'q') {
|
||||
stopped_reason = "user_quit";
|
||||
user_quit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// freeze frame
|
||||
if (art.has_writer && art.writer.isOpened() && !last_annotated.empty()) {
|
||||
int freeze_count = static_cast<int>(((config.display.write_fps > 0
|
||||
? config.display.write_fps : 30.0f) * 2));
|
||||
freeze_count = std::max(1, freeze_count);
|
||||
for (int i = 0; i < freeze_count; ++i)
|
||||
art.writer.write(last_annotated);
|
||||
}
|
||||
} catch (...) {
|
||||
art.capture.release();
|
||||
if (art.has_writer && art.writer.isOpened()) art.writer.release();
|
||||
if (config.display.show_window) cv::destroyAllWindows();
|
||||
if (progress) { progress->finish(); delete progress; }
|
||||
throw;
|
||||
}
|
||||
|
||||
art.capture.release();
|
||||
if (art.has_writer && art.writer.isOpened()) art.writer.release();
|
||||
if (config.display.show_window) cv::destroyAllWindows();
|
||||
|
||||
double elapsed_seconds = std::chrono::duration<double>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()).count()
|
||||
- art.run_start_time;
|
||||
if (user_quit) stopped_reason = "user_quit";
|
||||
|
||||
if (progress) { progress->finish(); delete progress; }
|
||||
|
||||
PipelineResult pr;
|
||||
pr.camera_id = config.camera_id;
|
||||
pr.total_entered_count = art.counting_zone.total_entered_count;
|
||||
pr.frames_processed = frame_index;
|
||||
pr.stopped_reason = stopped_reason;
|
||||
pr.vis_video_path = config.display.output_path;
|
||||
pr.source_video = config.source;
|
||||
pr.elapsed_seconds = elapsed_seconds;
|
||||
return pr;
|
||||
}
|
||||
|
||||
} // namespace cc
|
||||
@@ -1,56 +0,0 @@
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
|
||||
#include "chicken_counter/config.hpp"
|
||||
|
||||
int main() {
|
||||
std::cout << "=== test_config ===" << std::endl;
|
||||
|
||||
auto raw = cc::load_data(
|
||||
"/media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/configs/cameras/example_camera.yaml");
|
||||
|
||||
// print detection device type for debugging
|
||||
std::cout << "device type: " << raw["detection"]["device"].type_name()
|
||||
<< " value: " << raw["detection"]["device"] << std::endl;
|
||||
|
||||
auto cam = raw.get<cc::CameraConfig>();
|
||||
|
||||
assert(cam.camera_id == "coop_cam_03");
|
||||
assert(cam.detection.conf > 0.0f);
|
||||
assert(cam.roi.points.size() >= 2);
|
||||
assert(cam.roi.is_polygon());
|
||||
|
||||
auto cpoly = cam.roi.counting_polygon();
|
||||
assert(cpoly.size() == 4);
|
||||
auto crect = cam.roi.counting_rect();
|
||||
assert(crect.width >= 20 && crect.height >= 20);
|
||||
|
||||
std::cout << " camera_config: " << cam.camera_id << " OK" << std::endl;
|
||||
std::cout << " detection model: " << cam.detection.model_path << std::endl;
|
||||
std::cout << " device: " << cam.detection.device << std::endl;
|
||||
std::cout << " counting rect: "
|
||||
<< crect.x << "," << crect.y << " "
|
||||
<< crect.width << "x" << crect.height << std::endl;
|
||||
|
||||
auto batch = cc::load_batch_config(
|
||||
"/media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/configs/cycle7_batch.yaml");
|
||||
assert(!batch.cameras.empty());
|
||||
assert(batch.batch.compress_max_mb > 0);
|
||||
std::cout << " batch_config: " << batch.cameras.size() << " cameras OK" << std::endl;
|
||||
|
||||
auto first_id = batch.cameras.begin()->first;
|
||||
auto built = cc::build_camera_config_from_batch(
|
||||
batch, first_id,
|
||||
"/tmp/test.mp4",
|
||||
"/tmp/output/test.mp4",
|
||||
"/tmp/checkpoints/" + first_id);
|
||||
assert(built.camera_id == first_id);
|
||||
assert(built.source == "/tmp/test.mp4");
|
||||
assert(built.display.output_path == "/tmp/output/test.mp4");
|
||||
assert(!built.display.show_window);
|
||||
assert(built.feedback.enabled);
|
||||
std::cout << " build_from_batch: " << built.camera_id << " OK" << std::endl;
|
||||
|
||||
std::cout << "=== all tests passed ===" << std::endl;
|
||||
return 0;
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
|
||||
#include <opencv2/core.hpp>
|
||||
|
||||
#include "chicken_counter/capture.hpp"
|
||||
#include "chicken_counter/video_writer.hpp"
|
||||
#include "chicken_counter/counting.hpp"
|
||||
#include "chicken_counter/motion.hpp"
|
||||
#include "chicken_counter/overlay.hpp"
|
||||
#include "chicken_counter/batch_discovery.hpp"
|
||||
#include "chicken_counter/report.hpp"
|
||||
#include "chicken_counter/compress.hpp"
|
||||
|
||||
int main() {
|
||||
std::cout << "=== test_modules ===" << std::endl;
|
||||
|
||||
// test CountingZone
|
||||
{
|
||||
cc::RoiConfig roi;
|
||||
roi.points = {{100, 200}, {1600, 200}, {1600, 800}, {100, 800}};
|
||||
roi.min_overlap_ratio = 0.3f;
|
||||
|
||||
cc::GateConfig gate;
|
||||
gate.mode = "two_line";
|
||||
gate.lines_y = {320, 600};
|
||||
|
||||
cc::CountingZone zone(roi, gate, 20, 75, 500, false, false);
|
||||
|
||||
cc::TrackObservation t;
|
||||
t.track_id = 1;
|
||||
t.bbox_x1 = 200; t.bbox_y1 = 300;
|
||||
t.bbox_x2 = 350; t.bbox_y2 = 500;
|
||||
t.centroid_x = 275; t.centroid_y = 400;
|
||||
t.confidence = 0.9f;
|
||||
|
||||
auto events = zone.update({t}, 100, false);
|
||||
assert(zone.total_entered_count == 1);
|
||||
assert(events.size() == 1);
|
||||
assert(events[0].track_id == 1);
|
||||
assert(zone.is_inside(1));
|
||||
assert(zone.is_validated(1));
|
||||
|
||||
auto trail = zone.trail_for(1);
|
||||
assert(trail.size() == 1);
|
||||
std::cout << " CountingZone OK" << std::endl;
|
||||
}
|
||||
|
||||
// test BackwardMotionDetector (disabled)
|
||||
{
|
||||
cc::MotionConfig mc;
|
||||
mc.enabled = false;
|
||||
|
||||
cc::RoiConfig roi;
|
||||
roi.points = {{0, 0}, {100, 0}, {100, 100}, {0, 100}};
|
||||
|
||||
cc::BackwardMotionDetector detector(mc, roi, false);
|
||||
|
||||
cv::Mat frame(100, 100, CV_8UC3, cv::Scalar(0, 0, 0));
|
||||
std::vector<cc::TrackObservation> tracks;
|
||||
auto state = detector.update(frame, tracks, 0);
|
||||
assert(!state.backward_active);
|
||||
std::cout << " BackwardMotionDetector OK" << std::endl;
|
||||
}
|
||||
|
||||
// test overlay
|
||||
{
|
||||
cc::RoiConfig roi;
|
||||
roi.points = {{50, 50}, {200, 50}, {200, 200}, {50, 200}};
|
||||
|
||||
cc::GateConfig gate;
|
||||
|
||||
cc::CountingZone zone(roi, gate, 20, 75);
|
||||
|
||||
cc::CameraConfig cfg;
|
||||
cfg.camera_id = "test";
|
||||
cfg.roi = roi;
|
||||
cfg.overlay.trail_length = 20;
|
||||
|
||||
cv::Mat frame(300, 400, CV_8UC3, cv::Scalar(60, 60, 60));
|
||||
cc::MotionState ms;
|
||||
|
||||
cc::TrackObservation t;
|
||||
t.track_id = 99;
|
||||
t.bbox_x1 = 100; t.bbox_y1 = 80;
|
||||
t.bbox_x2 = 150; t.bbox_y2 = 130;
|
||||
t.centroid_x = 125; t.centroid_y = 105;
|
||||
t.confidence = 0.9f;
|
||||
|
||||
zone.update({t}, 1, false);
|
||||
|
||||
auto annotated = cc::draw_overlay(frame, cfg, zone, {t}, ms, 1);
|
||||
assert(annotated.rows == 300 && annotated.cols == 400);
|
||||
std::cout << " Overlay OK" << std::endl;
|
||||
}
|
||||
|
||||
// test report
|
||||
{
|
||||
cc::PipelineResult pr;
|
||||
pr.camera_id = "CC1";
|
||||
pr.total_entered_count = 42;
|
||||
pr.frames_processed = 1000;
|
||||
pr.stopped_reason = "eof";
|
||||
pr.source_video = "/tmp/CC1.mp4";
|
||||
pr.elapsed_seconds = 10.5;
|
||||
|
||||
cc::CameraBatchResult cr;
|
||||
cr.camera_id = "CC1";
|
||||
cr.pipeline = pr;
|
||||
|
||||
auto entry = cc::build_camera_report_entry(cr, "/tmp/output");
|
||||
assert(entry["total_entered"] == 42);
|
||||
std::cout << " Report OK" << std::endl;
|
||||
}
|
||||
|
||||
// test batch_discovery
|
||||
{
|
||||
cc::CameraPreset preset;
|
||||
preset.camera_id = "CC1";
|
||||
preset.camera_num = 1;
|
||||
preset.roi.points = {{0, 0}, {100, 100}};
|
||||
|
||||
cc::BatchSettings settings;
|
||||
settings.batch.root_dir = "/tmp/batch";
|
||||
settings.batch.camera_glob = "kandang_*_camera_{num}_*.mp4";
|
||||
settings.cameras["CC1"] = preset;
|
||||
|
||||
// test pattern replacement (not actual filesystem)
|
||||
auto pattern = cc::replace_glob_placeholder(settings.batch.camera_glob, 1);
|
||||
assert(pattern == "kandang_*_camera_1_*.mp4");
|
||||
std::cout << " BatchDiscovery OK" << std::endl;
|
||||
}
|
||||
|
||||
std::cout << "=== all tests passed ===" << std::endl;
|
||||
return 0;
|
||||
}
|
||||
Executable → Regular
+564
-179
@@ -1,255 +1,640 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Standalone live dashboard for chicken-counter pipeline.
|
||||
|
||||
Serve from project root:
|
||||
PYTHONPATH=src python3 dashboard.py [--port 8080]
|
||||
"""
|
||||
"""Live dashboard for chicken-counter pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import mimetypes
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
from socketserver import ThreadingMixIn
|
||||
from urllib.parse import unquote, urlparse
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
|
||||
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
DEFAULT_SHM_DIR = "/dev/shm"
|
||||
DEFAULT_PORT = 8080
|
||||
TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
|
||||
|
||||
DASHBOARD_HTML = r"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Chicken Counter — Live Dashboard</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:hidden}
|
||||
#app{display:flex;height:100vh}
|
||||
#sidebar{width:260px;background:#16161e;padding:16px;overflow-y:auto;flex-shrink:0}
|
||||
#sidebar h1{font-size:18px;color:#80dc5a;margin-bottom:16px}
|
||||
#sidebar .stat{margin-bottom:12px}
|
||||
#sidebar .stat label{display:block;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:1px}
|
||||
#sidebar .stat .value{font-size:22px;font-weight:700;color:#e0e0e0}
|
||||
#sidebar .stat .value.warn{color:#ff9f43}
|
||||
#sidebar .stat .value.good{color:#80dc5a}
|
||||
#cam-list{list-style:none;margin-top:16px}
|
||||
#cam-list li{padding:8px 10px;margin:2px 0;border-radius:6px;cursor:pointer;font-size:13px;transition:background .2s}
|
||||
#cam-list li:hover{background:#222}
|
||||
#cam-list li.active{background:#1a3a2a;color:#80dc5a;font-weight:700}
|
||||
#cam-list li .cam-badge{float:right;font-size:10px;padding:1px 6px;border-radius:8px;background:#222;color:#888}
|
||||
#cam-list li.active .cam-badge{background:#2a5a3a;color:#80dc5a}
|
||||
#main{flex:1;display:flex;flex-direction:column}
|
||||
#frame-container{flex:1;display:flex;align-items:center;justify-content:center;background:#000;position:relative}
|
||||
#frame-img{max-width:100%;max-height:100%;object-fit:contain}
|
||||
#no-frame{color:#555;font-size:18px}
|
||||
#top-bar{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:#16161e;font-size:12px}
|
||||
#top-bar .refresh{color:#888}
|
||||
#top-bar .status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
|
||||
#top-bar .status-dot.online{background:#80dc5a;box-shadow:0 0 6px #80dc5a}
|
||||
#top-bar .status-dot.offline{background:#555}
|
||||
.refresh-btn{padding:4px 12px;border-radius:4px;background:#222;border:1px solid #444;color:#ccc;cursor:pointer;font-size:11px}
|
||||
.refresh-btn:hover{background:#333}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="sidebar">
|
||||
<h1>🐔 Chicken Counter</h1>
|
||||
<div class="stat"><label>Total Entered</label><div class="value good" id="stat-total">--</div></div>
|
||||
<div class="stat"><label>Inside Box</label><div class="value" id="stat-inside">--</div></div>
|
||||
<div class="stat"><label>Tracks</label><div class="value" id="stat-tracks">--</div></div>
|
||||
<div class="stat"><label>Frame</label><div class="value" id="stat-frame">--</div></div>
|
||||
<div class="stat"><label>Motion Speed</label><div class="value" id="stat-speed">--</div></div>
|
||||
<div class="stat"><label>Status</label><div class="value" id="stat-status">--</div></div>
|
||||
<ul id="cam-list"></ul>
|
||||
</div>
|
||||
<div id="main">
|
||||
<div id="top-bar">
|
||||
<span><span class="status-dot" id="status-dot"></span><span id="status-text">waiting for pipeline...</span></span>
|
||||
<span><span class="refresh" id="refresh-counter"></span> ago
|
||||
<button class="refresh-btn" onclick="load()">↻ Refresh</button></span>
|
||||
</div>
|
||||
<div id="frame-container">
|
||||
<img id="frame-img" alt="live stream">
|
||||
<div id="no-frame"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
var POLL_MS = %%POLL_MS%%;
|
||||
var SHM = "%%SHM_DIR%%";
|
||||
var cameras = [];
|
||||
var activeCam = null;
|
||||
var lastUpdate = 0;
|
||||
var img = document.getElementById("frame-img");
|
||||
var noFrame = document.getElementById("no-frame");
|
||||
_thread_local = threading.local()
|
||||
_db_lock = threading.Lock()
|
||||
_db_path = ""
|
||||
_mortality_dirs: list[Path] = []
|
||||
_mortality_cache_lock = threading.Lock()
|
||||
_mortality_reports_cache: list[Path] = []
|
||||
_mortality_reports_mtime: float = 0.0
|
||||
_mortality_image_cache: dict[str, Path] = {}
|
||||
_MORTALITY_CACHE_TTL: float = 15.0 # seconds
|
||||
|
||||
function loadCameras() {{
|
||||
fetch("/api/cameras").then(r => r.json()).then(data => {{
|
||||
cameras = data.cameras || [];
|
||||
renderCamList();
|
||||
if (cameras.length && !activeCam) selectCam(cameras[0]);
|
||||
if (!cameras.length) {{ noFrame.textContent = "No cameras found in " + SHM; img.style.display = "none"; }}
|
||||
}});
|
||||
}}
|
||||
|
||||
function renderCamList() {{
|
||||
var ul = document.getElementById("cam-list");
|
||||
ul.innerHTML = cameras.map(function(c) {{
|
||||
return '<li class="' + (c === activeCam ? "active" : "") + '" onclick="selectCam(\'' + c + '\')">' +
|
||||
c + '<span class="cam-badge">▶</span></li>';
|
||||
}}).join("");
|
||||
}}
|
||||
def _load_initial_cycle_start_date() -> str:
|
||||
"""Read cycle_start_date from configs/cycle7_batch_optimized.yaml if available."""
|
||||
cfg_path = Path(__file__).resolve().parent / "configs" / "cycle7_batch_optimized.yaml"
|
||||
if cfg_path.exists():
|
||||
content = cfg_path.read_text(encoding="utf-8")
|
||||
match = re.search(r"^\s*cycle_start_date:\s*['\"]?([^'\"\s#]+)['\"]?", content, re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return "2026-05-22"
|
||||
|
||||
function selectCam(id) {{
|
||||
activeCam = id;
|
||||
renderCamList();
|
||||
load();
|
||||
}}
|
||||
|
||||
function load() {{
|
||||
if (!activeCam) return;
|
||||
var t = Date.now();
|
||||
img.src = "/shm/" + activeCam + "/frame.jpg?t=" + t;
|
||||
fetch("/shm/" + activeCam + "/stats.json?t=" + t).then(function(r) {{
|
||||
if (!r.ok) {{ setOffline(); return; }}
|
||||
return r.json();
|
||||
}}).then(function(s) {{
|
||||
if (!s) return;
|
||||
lastUpdate = Date.now();
|
||||
document.getElementById("stat-total").textContent = s.total_entered_count;
|
||||
document.getElementById("stat-inside").textContent = s.inside_box_count;
|
||||
document.getElementById("stat-tracks").textContent = s.track_count;
|
||||
document.getElementById("stat-frame").textContent = s.frame_index;
|
||||
document.getElementById("stat-speed").textContent = s.smoothed_speed;
|
||||
document.getElementById("stat-status").textContent = s.backward_active ? "BACKWARD STOP" : "RUNNING";
|
||||
var el = document.getElementById("stat-status");
|
||||
el.className = "value" + (s.backward_active ? " warn" : " good");
|
||||
document.getElementById("status-dot").className = "status-dot online";
|
||||
document.getElementById("status-text").textContent = activeCam + " \u2022 frame " + s.frame_index;
|
||||
}});
|
||||
}}
|
||||
_cycle_start_date: str = _load_initial_cycle_start_date()
|
||||
|
||||
function setOffline() {{
|
||||
document.getElementById("status-dot").className = "status-dot offline";
|
||||
document.getElementById("status-text").textContent = activeCam + " \u2022 offline";
|
||||
}}
|
||||
|
||||
function updateRefresh() {{
|
||||
var ago = Math.round((Date.now() - lastUpdate) / 1000);
|
||||
document.getElementById("refresh-counter").textContent = ago + "s";
|
||||
}}
|
||||
def _persist_cycle_start_date(new_date: str) -> bool:
|
||||
"""Update in-memory cycle_start_date and save to config YAML files."""
|
||||
global _cycle_start_date
|
||||
_cycle_start_date = new_date
|
||||
updated_any = False
|
||||
for cfg_name in ("cycle7_batch_optimized.yaml", "cycle7_batch.yaml"):
|
||||
cfg_path = Path(__file__).resolve().parent / "configs" / cfg_name
|
||||
if cfg_path.exists():
|
||||
content = cfg_path.read_text(encoding="utf-8")
|
||||
pattern = r"^([ \t]*cycle_start_date:[ \t]*)(?:['\"]?)([^'\"\r\n#]+)(?:['\"]?)([ \t]*(?:#.*)?)$"
|
||||
|
||||
img.onerror = function() {{ img.style.display = "none"; noFrame.style.display = "block"; noFrame.textContent = "Waiting for frame..."; }};
|
||||
img.onload = function() {{ img.style.display = "block"; noFrame.style.display = "none"; }};
|
||||
def replacer(match: re.Match) -> str:
|
||||
prefix = match.group(1)
|
||||
comment = match.group(3) or ""
|
||||
if comment and not comment.startswith(" "):
|
||||
comment = f" {comment.lstrip()}"
|
||||
if not comment.startswith(" "):
|
||||
comment = f" {comment}"
|
||||
return f'{prefix}"{new_date}"{comment}'
|
||||
|
||||
setInterval(function() {{ load(); }}, POLL_MS);
|
||||
setInterval(loadCameras, 3000);
|
||||
setInterval(updateRefresh, 1000);
|
||||
loadCameras();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
new_content, count = re.subn(pattern, replacer, content, count=1, flags=re.MULTILINE)
|
||||
if count > 0:
|
||||
cfg_path.write_text(new_content, encoding="utf-8")
|
||||
updated_any = True
|
||||
print(f"[dashboard] 📅 Updated cycle_start_date to: {new_date} (persisted in configs: {updated_any})")
|
||||
return updated_any
|
||||
|
||||
|
||||
def _calc_cycle_info(target_date_str: str) -> dict:
|
||||
if not _cycle_start_date or not target_date_str:
|
||||
return {}
|
||||
try:
|
||||
from datetime import date as date_type
|
||||
start_d = date_type.fromisoformat(str(_cycle_start_date))
|
||||
run_d = date_type.fromisoformat(str(target_date_str))
|
||||
c_day = (run_d - start_d).days
|
||||
stage = "early_cycle" if 0 <= c_day <= 15 else ("mid_cycle" if c_day >= 16 else "pre_cycle")
|
||||
return {"cycle_day": c_day, "stage": stage}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _get_db():
|
||||
if not _db_path:
|
||||
return None
|
||||
conn = getattr(_thread_local, "conn", None)
|
||||
if conn is None:
|
||||
conn = sqlite3.connect(_db_path, timeout=30.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
conn.execute("PRAGMA cache_size=-8000")
|
||||
_thread_local.conn = conn
|
||||
return conn
|
||||
|
||||
|
||||
def _init_db(db_path: str) -> None:
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS batch_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL, location TEXT NOT NULL, camera_id TEXT NOT NULL,
|
||||
total_entered INTEGER NOT NULL DEFAULT 0,
|
||||
frames_processed INTEGER NOT NULL DEFAULT 0,
|
||||
elapsed_seconds REAL NOT NULL DEFAULT 0.0,
|
||||
stopped_reason TEXT NOT NULL DEFAULT '',
|
||||
source_video TEXT NOT NULL DEFAULT '',
|
||||
generated_at TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE(date, location, camera_id))""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def _discover_cameras(shm_dir):
|
||||
shm = Path(shm_dir)
|
||||
cameras = []
|
||||
if shm.is_dir():
|
||||
for entry in sorted(shm.iterdir()):
|
||||
if entry.is_dir() and entry.name.startswith("chicken_counter_"):
|
||||
cameras.append(entry.name[len("chicken_counter_"):])
|
||||
return cameras
|
||||
|
||||
|
||||
class DashboardHandler(SimpleHTTPRequestHandler):
|
||||
shm_dir = DEFAULT_SHM_DIR
|
||||
poll_ms = 500
|
||||
poll_ms = 1000
|
||||
run_date = ""
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
try:
|
||||
self._handle_request()
|
||||
self._handle()
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
|
||||
def _handle_request(self):
|
||||
def do_POST(self):
|
||||
try:
|
||||
parsed = urlparse(self.path)
|
||||
path = unquote(parsed.path)
|
||||
if path == "/api/config/cycle_start_date":
|
||||
self._handle_set_cycle_start_date()
|
||||
return
|
||||
self._send_error(404)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
|
||||
def _handle(self):
|
||||
parsed = urlparse(self.path)
|
||||
path = unquote(parsed.path)
|
||||
|
||||
if path == "/" or path == "/index.html":
|
||||
html = DASHBOARD_HTML.replace("%%POLL_MS%%", str(self.poll_ms)).replace("%%SHM_DIR%%", self.shm_dir)
|
||||
self._send_html(html)
|
||||
if path == "/":
|
||||
self._serve_html()
|
||||
return
|
||||
|
||||
if path.startswith("/stream/"):
|
||||
self._handle_stream(path)
|
||||
return
|
||||
|
||||
if path in ("/api/status", "/api/system/status"):
|
||||
self._handle_status()
|
||||
return
|
||||
|
||||
if path == "/api/cameras":
|
||||
cameras = self._discover_cameras()
|
||||
self._send_json({"cameras": cameras})
|
||||
self._send_json({"cameras": _discover_cameras(self.shm_dir)})
|
||||
return
|
||||
|
||||
if path == "/api/config/cycle_start_date":
|
||||
query = parse_qs(parsed.query)
|
||||
if "set" in query and query["set"]:
|
||||
new_date = query["set"][0].strip()
|
||||
try:
|
||||
from datetime import date as date_type
|
||||
date_type.fromisoformat(new_date)
|
||||
persisted = _persist_cycle_start_date(new_date)
|
||||
self._send_json({
|
||||
"status": "ok",
|
||||
"message": f"Cycle start date successfully set to {new_date}",
|
||||
"cycle_start_date": _cycle_start_date,
|
||||
"persisted": persisted,
|
||||
})
|
||||
return
|
||||
except ValueError as err:
|
||||
self._send_json({"error": f"Invalid date format (expected YYYY-MM-DD): {err}"}, status_code=400)
|
||||
return
|
||||
self._send_json({"cycle_start_date": _cycle_start_date})
|
||||
return
|
||||
|
||||
if path == "/api/config":
|
||||
self._send_json({
|
||||
"cycle_start_date": _cycle_start_date,
|
||||
"shm_dir": self.shm_dir,
|
||||
"db_path": _db_path,
|
||||
})
|
||||
return
|
||||
|
||||
if path.startswith("/api/db/"):
|
||||
self._handle_db(path)
|
||||
return
|
||||
|
||||
if path.startswith("/api/mortality"):
|
||||
self._handle_mortality(path)
|
||||
return
|
||||
|
||||
if path.startswith("/shm/"):
|
||||
rel = path[len("/shm/"):]
|
||||
parts = rel.split("/", 1)
|
||||
if len(parts) >= 1:
|
||||
parts[0] = f"chicken_counter_{parts[0]}"
|
||||
rel = "/".join(parts)
|
||||
shm_path = Path(self.shm_dir) / rel
|
||||
resolved = shm_path.resolve()
|
||||
if not str(resolved).startswith(str(Path(self.shm_dir).resolve())):
|
||||
self.send_error(403)
|
||||
return
|
||||
if not resolved.exists():
|
||||
self.send_error(404)
|
||||
return
|
||||
ct = "image/jpeg" if resolved.suffix in (".jpg", ".jpeg") else "application/json"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", ct)
|
||||
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
self.end_headers()
|
||||
self.wfile.write(resolved.read_bytes())
|
||||
self._handle_shm(path)
|
||||
return
|
||||
|
||||
self.send_error(404)
|
||||
self._send_error(404)
|
||||
|
||||
def _discover_cameras(self):
|
||||
shm = Path(self.shm_dir)
|
||||
cameras = []
|
||||
if shm.is_dir():
|
||||
for entry in sorted(shm.iterdir()):
|
||||
if entry.is_dir() and entry.name.startswith("chicken_counter_"):
|
||||
cam_id = entry.name[len("chicken_counter_"):]
|
||||
cameras.append(cam_id)
|
||||
return cameras
|
||||
def _handle_set_cycle_start_date(self):
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
if content_length > 0:
|
||||
body = self.rfile.read(content_length).decode("utf-8")
|
||||
try:
|
||||
data = json.loads(body)
|
||||
new_date = str(data.get("cycle_start_date", "")).strip()
|
||||
if new_date:
|
||||
from datetime import date as date_type
|
||||
date_type.fromisoformat(new_date)
|
||||
persisted = _persist_cycle_start_date(new_date)
|
||||
self._send_json({
|
||||
"status": "ok",
|
||||
"message": f"Cycle start date successfully set to {new_date}",
|
||||
"cycle_start_date": _cycle_start_date,
|
||||
"persisted": persisted,
|
||||
})
|
||||
return
|
||||
else:
|
||||
self._send_json({"error": "Missing 'cycle_start_date' in request body"}, status_code=400)
|
||||
return
|
||||
except ValueError as err:
|
||||
self._send_json({"error": f"Invalid date format (expected YYYY-MM-DD): {err}"}, status_code=400)
|
||||
return
|
||||
except Exception as err:
|
||||
self._send_json({"error": str(err)}, status_code=400)
|
||||
return
|
||||
self._send_json({"error": "Empty request body"}, status_code=400)
|
||||
|
||||
def _send_html(self, html: str):
|
||||
def _handle_status(self):
|
||||
cams = _discover_cameras(self.shm_dir)
|
||||
now = time.time()
|
||||
active_streams = []
|
||||
for cam in cams:
|
||||
stat_file = Path(self.shm_dir) / f"chicken_counter_{cam}" / "stats.json"
|
||||
if stat_file.is_file() and (now - stat_file.stat().st_mtime) < 15.0:
|
||||
active_streams.append(cam)
|
||||
|
||||
is_running = len(active_streams) > 0
|
||||
conn = _get_db()
|
||||
latest_date = None
|
||||
total_chickens = 0
|
||||
if conn:
|
||||
row = conn.execute("SELECT MAX(date) AS latest_date, SUM(total_entered) AS total FROM batch_runs").fetchone()
|
||||
if row:
|
||||
latest_date = row["latest_date"]
|
||||
total_chickens = row["total"] or 0
|
||||
|
||||
self._send_json({
|
||||
"status": "running" if is_running else "idle",
|
||||
"is_counting_active": is_running,
|
||||
"active_cameras": active_streams,
|
||||
"latest_counted_date": latest_date,
|
||||
"total_chickens_all_time": total_chickens,
|
||||
"cycle_start_date": _cycle_start_date,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
def _handle_stream(self, path):
|
||||
cam_id = path[len("/stream/"):]
|
||||
frame_path = Path(self.shm_dir) / f"chicken_counter_{cam_id}" / "frame.jpg"
|
||||
if not frame_path.exists():
|
||||
self._send_error(404)
|
||||
return
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
|
||||
last_mtime = 0
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
mtime = frame_path.stat().st_mtime
|
||||
if mtime != last_mtime:
|
||||
last_mtime = mtime
|
||||
data = frame_path.read_bytes()
|
||||
self.wfile.write(
|
||||
b"--frame\r\n"
|
||||
b"Content-Type: image/jpeg\r\n"
|
||||
b"Content-Length: " + str(len(data)).encode() + b"\r\n\r\n" +
|
||||
data + b"\r\n"
|
||||
)
|
||||
self.wfile.flush()
|
||||
except (FileNotFoundError, OSError):
|
||||
pass
|
||||
time.sleep(0.1)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
|
||||
def _handle_shm(self, path):
|
||||
rel = path[len("/shm/"):]
|
||||
parts = rel.split("/", 1)
|
||||
if len(parts) < 2:
|
||||
self._send_error(404)
|
||||
return
|
||||
|
||||
cam_id = parts[0]
|
||||
file = parts[1]
|
||||
fpath = Path(self.shm_dir) / f"chicken_counter_{cam_id}" / file
|
||||
|
||||
if str(fpath.resolve()).startswith(str(Path(self.shm_dir).resolve())):
|
||||
if fpath.exists():
|
||||
ct = "image/jpeg" if file.endswith(".jpg") else "application/json"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", ct)
|
||||
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
self.end_headers()
|
||||
self.wfile.write(fpath.read_bytes())
|
||||
return
|
||||
self._send_error(404)
|
||||
|
||||
def _handle_db(self, path):
|
||||
conn = _get_db()
|
||||
if not conn:
|
||||
self._send_json({})
|
||||
return
|
||||
|
||||
if path == "/api/db/summary":
|
||||
row = conn.execute("SELECT COUNT(DISTINCT date) AS days, COUNT(DISTINCT location) AS locations, COUNT(*) AS total_runs, SUM(total_entered) AS total_chickens, ROUND(SUM(elapsed_seconds)/3600.0,1) AS total_hours FROM batch_runs").fetchone()
|
||||
d = dict(row)
|
||||
d["cycle_start_date"] = _cycle_start_date
|
||||
self._send_json(d)
|
||||
return
|
||||
|
||||
if path == "/api/db/history":
|
||||
rows = conn.execute("SELECT date, location, COUNT(*) AS cams, SUM(total_entered) AS total, ROUND(SUM(elapsed_seconds)/60.0,1) AS minutes FROM batch_runs GROUP BY date, location ORDER BY date DESC, location LIMIT 50").fetchall()
|
||||
history = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d.update(_calc_cycle_info(d["date"]))
|
||||
history.append(d)
|
||||
self._send_json(history)
|
||||
return
|
||||
|
||||
# /api/db/date/<date>
|
||||
prefix = "/api/db/date/"
|
||||
if path.startswith(prefix):
|
||||
date = path[len(prefix):]
|
||||
cameras = conn.execute("SELECT camera_id, total_entered, frames_processed, ROUND(elapsed_seconds,1) AS elapsed_seconds, stopped_reason, source_video, location FROM batch_runs WHERE date=? ORDER BY camera_id", (date,)).fetchall()
|
||||
total = conn.execute("SELECT SUM(total_entered) AS total, ROUND(SUM(elapsed_seconds)/60.0,1) AS minutes FROM batch_runs WHERE date=?", (date,)).fetchone()
|
||||
res = {"date": date, "total": dict(total), "cameras": [dict(r) for r in cameras]}
|
||||
res.update(_calc_cycle_info(date))
|
||||
self._send_json(res)
|
||||
return
|
||||
|
||||
# /api/db/camera/<id>
|
||||
prefix = "/api/db/camera/"
|
||||
if path.startswith(prefix):
|
||||
cam_id = path[len(prefix):]
|
||||
rows = conn.execute("SELECT date, location, total_entered, frames_processed, ROUND(elapsed_seconds,1) AS elapsed_seconds, stopped_reason FROM batch_runs WHERE camera_id=? ORDER BY date DESC LIMIT 50", (cam_id,)).fetchall()
|
||||
self._send_json([dict(r) for r in rows])
|
||||
return
|
||||
|
||||
# /api/db/location/<name>
|
||||
prefix = "/api/db/location/"
|
||||
if path.startswith(prefix):
|
||||
loc = path[len(prefix):]
|
||||
history = conn.execute("SELECT date, GROUP_CONCAT(camera_id,', ') AS cameras, SUM(total_entered) AS total, ROUND(SUM(elapsed_seconds)/60.0,1) AS minutes FROM batch_runs WHERE location=? GROUP BY date ORDER BY date DESC LIMIT 50", (loc,)).fetchall()
|
||||
summary = conn.execute("SELECT COUNT(DISTINCT date) AS days, SUM(total_entered) AS total, ROUND(SUM(elapsed_seconds)/3600.0,1) AS hours FROM batch_runs WHERE location=?", (loc,)).fetchone()
|
||||
self._send_json({"location": loc, "summary": dict(summary), "history": [dict(r) for r in history]})
|
||||
return
|
||||
|
||||
self._send_json({})
|
||||
|
||||
def _handle_mortality(self, path: str) -> None:
|
||||
"""Serve mortality detection results.
|
||||
|
||||
GET /api/mortality/latest - Most recent mortality_report.json across all dirs.
|
||||
GET /api/mortality/history - List of all mortality reports found (newest first).
|
||||
GET /api/mortality/date/<date> - Mortality breakdown for a specific date (YYYY-MM-DD).
|
||||
GET /api/mortality/image/<name> - Serve an output_*.jpg annotated image by filename.
|
||||
"""
|
||||
if not _mortality_dirs:
|
||||
self._send_json({"error": "No mortality directory configured. Start dashboard with --mortality-dir."})
|
||||
return
|
||||
|
||||
def _enrich_report(data: dict) -> dict:
|
||||
if "total_mortality_count" not in data and "results" in data:
|
||||
data["total_mortality_count"] = sum(r.get("count", 0) for r in data["results"])
|
||||
return data
|
||||
|
||||
def _find_report_paths(force_refresh: bool = False) -> list[Path]:
|
||||
global _mortality_reports_cache, _mortality_reports_mtime, _mortality_image_cache
|
||||
now = time.time()
|
||||
with _mortality_cache_lock:
|
||||
if not force_refresh and (now - _mortality_reports_mtime) < _MORTALITY_CACHE_TTL and _mortality_reports_cache:
|
||||
return list(_mortality_reports_cache)
|
||||
|
||||
found = []
|
||||
img_cache: dict[str, Path] = {}
|
||||
for mdir in _mortality_dirs:
|
||||
p = Path(mdir)
|
||||
if p.is_dir():
|
||||
for report in p.rglob("mortality_report.json"):
|
||||
found.append(report)
|
||||
# Index images in the same directory as the report
|
||||
for img in report.parent.glob("output_*.jpg"):
|
||||
img_cache[img.name] = img
|
||||
for img in report.parent.glob("output_*.png"):
|
||||
img_cache[img.name] = img
|
||||
|
||||
_mortality_reports_cache = found
|
||||
_mortality_image_cache = img_cache
|
||||
_mortality_reports_mtime = now
|
||||
return list(found)
|
||||
|
||||
def _find_image_path(filename: str) -> Path | None:
|
||||
# 1. Fast memory cache lookup (O(1))
|
||||
with _mortality_cache_lock:
|
||||
if filename in _mortality_image_cache:
|
||||
img = _mortality_image_cache[filename]
|
||||
if img.is_file():
|
||||
return img
|
||||
|
||||
# 2. Fast check in cached report parent directories
|
||||
reports = _find_report_paths()
|
||||
for r in reports:
|
||||
candidate = r.parent / filename
|
||||
if candidate.is_file():
|
||||
with _mortality_cache_lock:
|
||||
_mortality_image_cache[filename] = candidate
|
||||
return candidate
|
||||
|
||||
# 3. Direct check in base mortality directories
|
||||
for mdir in _mortality_dirs:
|
||||
candidate = Path(mdir) / filename
|
||||
if candidate.is_file():
|
||||
with _mortality_cache_lock:
|
||||
_mortality_image_cache[filename] = candidate
|
||||
return candidate
|
||||
|
||||
# 4. Fallback search and update index
|
||||
for mdir in _mortality_dirs:
|
||||
p = Path(mdir)
|
||||
if p.is_dir():
|
||||
for img_path in p.rglob(filename):
|
||||
if img_path.is_file():
|
||||
with _mortality_cache_lock:
|
||||
_mortality_image_cache[filename] = img_path
|
||||
return img_path
|
||||
return None
|
||||
|
||||
# --- /api/mortality/history ---
|
||||
if path == "/api/mortality/history":
|
||||
results = []
|
||||
for report_path in _find_report_paths():
|
||||
try:
|
||||
data = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
data["_dir"] = str(report_path.parent)
|
||||
data["_report_mtime"] = report_path.stat().st_mtime
|
||||
results.append(_enrich_report(data))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
results.sort(key=lambda x: x.get("_report_mtime", 0), reverse=True)
|
||||
self._send_json(results)
|
||||
return
|
||||
|
||||
# --- /api/mortality/latest ---
|
||||
if path == "/api/mortality/latest":
|
||||
latest = None
|
||||
latest_mtime = 0.0
|
||||
for report_path in _find_report_paths():
|
||||
try:
|
||||
mtime = report_path.stat().st_mtime
|
||||
if mtime > latest_mtime:
|
||||
latest_mtime = mtime
|
||||
latest = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
latest["_dir"] = str(report_path.parent)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
if latest:
|
||||
self._send_json(_enrich_report(latest))
|
||||
else:
|
||||
self._send_json({"error": "No mortality report found."})
|
||||
return
|
||||
|
||||
# --- /api/mortality/date/<date> ---
|
||||
prefix_date = "/api/mortality/date/"
|
||||
if path.startswith(prefix_date):
|
||||
target_date = path[len(prefix_date):]
|
||||
matched_reports = []
|
||||
total_day_carcasses = 0
|
||||
total_day_images = 0
|
||||
all_results = []
|
||||
|
||||
for report_path in _find_report_paths():
|
||||
try:
|
||||
data = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
report_date = data.get("date") or time.strftime("%Y-%m-%d", time.localtime(report_path.stat().st_mtime))
|
||||
if report_date == target_date or report_path.parent.name == target_date:
|
||||
enriched = _enrich_report(data)
|
||||
total_day_carcasses += enriched.get("total_mortality_count", 0)
|
||||
total_day_images += enriched.get("total_images", 0)
|
||||
all_results.extend(enriched.get("results", []))
|
||||
matched_reports.append(enriched)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
||||
self._send_json({
|
||||
"date": target_date,
|
||||
"total_mortality_count": total_day_carcasses,
|
||||
"total_images": total_day_images,
|
||||
"reports": matched_reports,
|
||||
"results": all_results,
|
||||
})
|
||||
return
|
||||
|
||||
# --- /api/mortality/image/<filename> ---
|
||||
prefix_img = "/api/mortality/image/"
|
||||
if path.startswith(prefix_img):
|
||||
raw_name = path[len(prefix_img):]
|
||||
filename = Path(raw_name).name # Prevent path traversal attacks
|
||||
# Only allow serving output_*.jpg files for security
|
||||
if not (filename.startswith("output_") and filename.lower().endswith((".jpg", ".jpeg", ".png"))):
|
||||
self._send_error(403)
|
||||
return
|
||||
img_path = _find_image_path(filename)
|
||||
if img_path and img_path.is_file():
|
||||
try:
|
||||
data = img_path.read_bytes()
|
||||
ct = mimetypes.guess_type(filename)[0] or "image/jpeg"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", ct)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
return
|
||||
except (FileNotFoundError, OSError):
|
||||
pass
|
||||
self._send_error(404)
|
||||
return
|
||||
|
||||
self._send_error(404)
|
||||
|
||||
def _serve_html(self):
|
||||
html_path = TEMPLATE_DIR / "index.html"
|
||||
html = html_path.read_text(encoding="utf-8")
|
||||
html = html.replace("{{ poll_ms }}", str(self.poll_ms))
|
||||
html = html.replace("{{ shm_dir }}", self.shm_dir)
|
||||
html = html.replace("{{ date }}", self.run_date or "today")
|
||||
html = html.replace("{{ db_path }}", _db_path)
|
||||
data = html.encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _send_json(self, obj):
|
||||
def _send_json(self, obj, status_code=200):
|
||||
data = json.dumps(obj).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_response(status_code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _send_error(self, code):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
|
||||
def main():
|
||||
global _db_path, _mortality_dirs
|
||||
|
||||
parser = argparse.ArgumentParser(description="Chicken Counter live dashboard")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"HTTP port (default: {DEFAULT_PORT})")
|
||||
parser.add_argument("--shm-dir", default=DEFAULT_SHM_DIR, help=f"Shared memory directory (default: {DEFAULT_SHM_DIR})")
|
||||
parser.add_argument("--poll-ms", type=int, default=500, help="Image poll interval in ms (default: 500)")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
|
||||
parser.add_argument("--shm-dir", default=DEFAULT_SHM_DIR)
|
||||
parser.add_argument("--poll-ms", type=int, default=1000)
|
||||
parser.add_argument("--date", default="")
|
||||
parser.add_argument("--db", default="db/chicken_counts.db")
|
||||
parser.add_argument("--cycle-start-date", default="", help="Start date of cycle (Day 0) in YYYY-MM-DD format.")
|
||||
parser.add_argument(
|
||||
"--mortality-dir",
|
||||
action="append",
|
||||
dest="mortality_dirs",
|
||||
default=[],
|
||||
metavar="DIR",
|
||||
help="Directory containing mortality_report.json and output images. Repeatable for multiple dirs.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
_mortality_dirs = [Path(d).resolve() for d in args.mortality_dirs]
|
||||
if args.cycle_start_date:
|
||||
global _cycle_start_date
|
||||
_cycle_start_date = args.cycle_start_date
|
||||
|
||||
DashboardHandler.shm_dir = args.shm_dir
|
||||
DashboardHandler.poll_ms = args.poll_ms
|
||||
DashboardHandler.run_date = args.date
|
||||
_db_path = str(Path(args.db).resolve()) if args.db else ""
|
||||
|
||||
if _db_path:
|
||||
_init_db(_db_path)
|
||||
|
||||
server = ThreadingHTTPServer(("0.0.0.0", args.port), DashboardHandler)
|
||||
print(f"[dashboard] serving at http://0.0.0.0:{args.port}")
|
||||
print(f"[dashboard] shm_dir={args.shm_dir} poll={args.poll_ms}ms")
|
||||
date_info = f" date={args.date}" if args.date else ""
|
||||
print(f"[dashboard] http://0.0.0.0:{args.port} shm={args.shm_dir} db={args.db}{date_info}")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
|
||||
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,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export a YOLO .pt model to TensorRT .engine.
|
||||
|
||||
Usage:
|
||||
python3 export_engine.py chicken-detection-model-v26n-300e-best-2026-05-02-NEW.pt
|
||||
python3 export_engine.py model.pt --imgsz 640 --half --workspace 4
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def export_engine(
|
||||
model_path: str | Path,
|
||||
*,
|
||||
imgsz: int = 640,
|
||||
half: bool = True,
|
||||
int8: bool = False,
|
||||
batch: int = 1,
|
||||
workspace: int = 4, # GB
|
||||
simplify: bool = True,
|
||||
opset: int = 17,
|
||||
verbose: bool = True,
|
||||
) -> str:
|
||||
from ultralytics import YOLO
|
||||
|
||||
model = YOLO(model_path, task="detect")
|
||||
|
||||
output = model.export(
|
||||
format="engine",
|
||||
imgsz=imgsz,
|
||||
half=half,
|
||||
int8=int8,
|
||||
batch=batch,
|
||||
workspace=workspace,
|
||||
simplify=simplify,
|
||||
opset=opset,
|
||||
verbose=verbose,
|
||||
)
|
||||
|
||||
print(f"\nExported to: {output}")
|
||||
return str(output)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Export YOLO .pt → TensorRT .engine")
|
||||
parser.add_argument("model", help="Path to .pt model file")
|
||||
parser.add_argument("--imgsz", type=int, default=640, help="Input image size (default: 640)")
|
||||
parser.add_argument("--half", action="store_true", default=True, help="FP16 precision (default: on)")
|
||||
parser.add_argument("--no-half", dest="half", action="store_false", help="FP32 precision")
|
||||
parser.add_argument("--int8", action="store_true", help="INT8 quantization (needs calibration)")
|
||||
parser.add_argument("--batch", type=int, default=1, help="Batch size (default: 1)")
|
||||
parser.add_argument("--workspace", type=int, default=4, help="GPU workspace in GB (default: 4)")
|
||||
parser.add_argument("--opset", type=int, default=17, help="ONNX opset version (default: 17)")
|
||||
parser.add_argument("--quiet", action="store_true", help="Suppress verbose output")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not Path(args.model).exists():
|
||||
print(f"error: model file not found: {args.model}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
export_engine(
|
||||
args.model,
|
||||
imgsz=args.imgsz,
|
||||
half=args.half,
|
||||
int8=args.int8,
|
||||
batch=args.batch,
|
||||
workspace=args.workspace,
|
||||
opset=args.opset,
|
||||
verbose=not args.quiet,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,392 @@
|
||||
import sqlite3
|
||||
import pandas as pd
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.chart import BarChart, Reference
|
||||
|
||||
def generate_excel_report(db_path, output_excel_path, target_date=None):
|
||||
conn = sqlite3.connect(db_path)
|
||||
if target_date:
|
||||
df = pd.read_sql_query("SELECT * FROM batch_runs WHERE date = ? ORDER BY date ASC, camera_id ASC", conn, params=(target_date,))
|
||||
else:
|
||||
df = pd.read_sql_query("SELECT * FROM batch_runs ORDER BY date ASC, camera_id ASC", conn)
|
||||
conn.close()
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
# Remove default sheet
|
||||
wb.remove(wb.active)
|
||||
|
||||
font_family = "Segoe UI"
|
||||
if df.empty:
|
||||
ws_empty = wb.create_sheet(title="Daily Summary")
|
||||
ws_empty["A1"] = "Chicken Counter - Summary Report"
|
||||
ws_empty["A1"].font = Font(name=font_family, size=16, bold=True, color="1F4E78")
|
||||
target_info = f" for date {target_date}" if target_date else ""
|
||||
ws_empty["A3"] = f"No batch runs found in database{target_info}."
|
||||
ws_empty["A3"].font = Font(name=font_family, size=11, italic=True)
|
||||
wb.save(output_excel_path)
|
||||
print(f"No records found. Empty report saved to: {output_excel_path}")
|
||||
return
|
||||
|
||||
# Styles
|
||||
header_fill = PatternFill(start_color="1F4E78", end_color="1F4E78", fill_type="solid") # Dark Navy
|
||||
header_font = Font(name=font_family, size=11, bold=True, color="FFFFFF")
|
||||
|
||||
accent_fill = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") # Light Soft Blue
|
||||
total_fill = PatternFill(start_color="B4C6E7", end_color="B4C6E7", fill_type="solid")
|
||||
total_font = Font(name=font_family, size=11, bold=True, color="000000")
|
||||
|
||||
kpi_title_font = Font(name=font_family, size=9, bold=False, color="595959")
|
||||
kpi_value_font = Font(name=font_family, size=18, bold=True, color="1F4E78")
|
||||
|
||||
thin_border = Border(
|
||||
left=Side(style='thin', color='D9D9D9'),
|
||||
right=Side(style='thin', color='D9D9D9'),
|
||||
top=Side(style='thin', color='D9D9D9'),
|
||||
bottom=Side(style='thin', color='D9D9D9')
|
||||
)
|
||||
|
||||
top_thick_bottom_double = Border(
|
||||
top=Side(style='thin', color='000000'),
|
||||
bottom=Side(style='double', color='000000')
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# SHEET 1: Daily Summary
|
||||
# -------------------------------------------------------------
|
||||
ws_summary = wb.create_sheet(title="Daily Summary")
|
||||
ws_summary.views.sheetView[0].showGridLines = True
|
||||
|
||||
# Title Block
|
||||
ws_summary["A1"] = "Chicken Counter - Cycle 7 Summary Report"
|
||||
ws_summary["A1"].font = Font(name=font_family, size=16, bold=True, color="1F4E78")
|
||||
ws_summary["A2"] = f"Location: {df['location'].iloc[0]} | Date Range: {df['date'].min()} to {df['date'].max()}"
|
||||
ws_summary["A2"].font = Font(name=font_family, size=10, italic=True, color="595959")
|
||||
|
||||
# KPI Cards Block (Rows 4 to 6)
|
||||
kpis = [
|
||||
("TOTAL CHICKENS COUNTED", df['total_entered'].sum(), "#,##0"),
|
||||
("TOTAL BATCH RUNS", len(df), "#,##0"),
|
||||
("TOTAL FRAMES PROCESSED", df['frames_processed'].sum(), "#,##0"),
|
||||
("TOTAL ELAPSED (MINUTES)", round(df['elapsed_seconds'].sum() / 60, 1), "#,##0.0")
|
||||
]
|
||||
|
||||
col_starts = [1, 3, 5, 7]
|
||||
for (title, val, num_fmt), col_idx in zip(kpis, col_starts):
|
||||
c1 = ws_summary.cell(row=4, column=col_idx, value=title)
|
||||
c1.font = kpi_title_font
|
||||
c1.fill = accent_fill
|
||||
ws_summary.merge_cells(start_row=4, start_column=col_idx, end_row=4, end_column=col_idx+1)
|
||||
|
||||
c2 = ws_summary.cell(row=5, column=col_idx, value=val)
|
||||
c2.font = kpi_value_font
|
||||
c2.number_format = num_fmt
|
||||
c2.alignment = Alignment(horizontal='center', vertical='center')
|
||||
ws_summary.merge_cells(start_row=5, start_column=col_idx, end_row=6, end_column=col_idx+1)
|
||||
|
||||
# Pivot Data: Date vs Camera
|
||||
pivot = df.pivot_table(index='date', columns='camera_id', values='total_entered', aggfunc='sum', fill_value=0)
|
||||
cameras = sorted(list(pivot.columns))
|
||||
|
||||
start_row = 9
|
||||
ws_summary.cell(row=start_row, column=1, value="Date").font = header_font
|
||||
ws_summary.cell(row=start_row, column=1).fill = header_fill
|
||||
ws_summary.cell(row=start_row, column=1).alignment = Alignment(horizontal='center')
|
||||
|
||||
for idx, cam in enumerate(cameras):
|
||||
cell = ws_summary.cell(row=start_row, column=idx+2, value=cam)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = Alignment(horizontal='center')
|
||||
|
||||
tot_header = ws_summary.cell(row=start_row, column=len(cameras)+2, value="Daily Total")
|
||||
tot_header.font = header_font
|
||||
tot_header.fill = header_fill
|
||||
tot_header.alignment = Alignment(horizontal='center')
|
||||
|
||||
current_r = start_row + 1
|
||||
for date_val, row_data in pivot.iterrows():
|
||||
ws_summary.cell(row=current_r, column=1, value=str(date_val)).font = Font(name=font_family, size=11)
|
||||
ws_summary.cell(row=current_r, column=1).alignment = Alignment(horizontal='center')
|
||||
ws_summary.cell(row=current_r, column=1).border = thin_border
|
||||
|
||||
for idx, cam in enumerate(cameras):
|
||||
c = ws_summary.cell(row=current_r, column=idx+2, value=int(row_data[cam]))
|
||||
c.font = Font(name=font_family, size=11)
|
||||
c.number_format = "#,##0"
|
||||
c.alignment = Alignment(horizontal='right')
|
||||
c.border = thin_border
|
||||
|
||||
# Excel SUM Formula for Daily Total
|
||||
start_col_let = get_column_letter(2)
|
||||
end_col_let = get_column_letter(len(cameras) + 1)
|
||||
tot_c = ws_summary.cell(row=current_r, column=len(cameras)+2, value=f"=SUM({start_col_let}{current_r}:{end_col_let}{current_r})")
|
||||
tot_c.font = Font(name=font_family, size=11, bold=True)
|
||||
tot_c.number_format = "#,##0"
|
||||
tot_c.alignment = Alignment(horizontal='right')
|
||||
tot_c.border = thin_border
|
||||
|
||||
current_r += 1
|
||||
|
||||
# Total Row at Bottom
|
||||
ws_summary.cell(row=current_r, column=1, value="Total").font = total_font
|
||||
ws_summary.cell(row=current_r, column=1).fill = total_fill
|
||||
ws_summary.cell(row=current_r, column=1).alignment = Alignment(horizontal='center')
|
||||
ws_summary.cell(row=current_r, column=1).border = top_thick_bottom_double
|
||||
|
||||
for idx, cam in enumerate(cameras):
|
||||
col_let = get_column_letter(idx + 2)
|
||||
c = ws_summary.cell(row=current_r, column=idx+2, value=f"=SUM({col_let}{start_row+1}:{col_let}{current_r-1})")
|
||||
c.font = total_font
|
||||
c.fill = total_fill
|
||||
c.number_format = "#,##0"
|
||||
c.alignment = Alignment(horizontal='right')
|
||||
c.border = top_thick_bottom_double
|
||||
|
||||
final_col_let = get_column_letter(len(cameras) + 2)
|
||||
tot_final = ws_summary.cell(row=current_r, column=len(cameras)+2, value=f"=SUM({final_col_let}{start_row+1}:{final_col_let}{current_r-1})")
|
||||
tot_final.font = total_font
|
||||
tot_final.fill = total_fill
|
||||
tot_final.number_format = "#,##0"
|
||||
tot_final.alignment = Alignment(horizontal='right')
|
||||
tot_final.border = top_thick_bottom_double
|
||||
|
||||
# Add Chart to Summary Sheet
|
||||
chart = BarChart()
|
||||
chart.type = "col"
|
||||
chart.style = 10
|
||||
chart.title = "Daily Chicken Counts by Camera"
|
||||
chart.y_axis.title = "Chicken Count"
|
||||
chart.x_axis.title = "Date"
|
||||
chart.width = 16
|
||||
chart.height = 10
|
||||
|
||||
data_ref = Reference(ws_summary, min_col=2, min_row=start_row, max_col=len(cameras)+1, max_row=current_r-1)
|
||||
cats_ref = Reference(ws_summary, min_col=1, min_row=start_row+1, max_row=current_r-1)
|
||||
chart.add_data(data_ref, titles_from_data=True)
|
||||
chart.set_categories(cats_ref)
|
||||
|
||||
ws_summary.add_chart(chart, "I9")
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# SHEET 2: Camera Breakdown
|
||||
# -------------------------------------------------------------
|
||||
ws_cam = wb.create_sheet(title="Camera Summary")
|
||||
ws_cam.views.sheetView[0].showGridLines = True
|
||||
|
||||
ws_cam["A1"] = "Camera Performance Summary"
|
||||
ws_cam["A1"].font = Font(name=font_family, size=14, bold=True, color="1F4E78")
|
||||
|
||||
cam_pivot = df.groupby('camera_id').agg(
|
||||
total_entered=('total_entered', 'sum'),
|
||||
avg_entered=('total_entered', 'mean'),
|
||||
total_frames=('frames_processed', 'sum'),
|
||||
total_elapsed_sec=('elapsed_seconds', 'sum'),
|
||||
total_runs=('id', 'count')
|
||||
).reset_index()
|
||||
|
||||
cam_headers = ["Camera ID", "Total Chicken Count", "Average Count / Run", "Total Frames", "Total Time (Minutes)", "Total Runs"]
|
||||
for c_idx, h_text in enumerate(cam_headers, 1):
|
||||
cell = ws_cam.cell(row=3, column=c_idx, value=h_text)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = Alignment(horizontal='center')
|
||||
|
||||
for r_idx, row in cam_pivot.iterrows():
|
||||
row_num = 4 + r_idx
|
||||
ws_cam.cell(row=row_num, column=1, value=row['camera_id']).alignment = Alignment(horizontal='center')
|
||||
|
||||
ws_cam.cell(row=row_num, column=2, value=int(row['total_entered'])).number_format = "#,##0"
|
||||
ws_cam.cell(row=row_num, column=3, value=round(row['avg_entered'], 1)).number_format = "#,##0.0"
|
||||
ws_cam.cell(row=row_num, column=4, value=int(row['total_frames'])).number_format = "#,##0"
|
||||
ws_cam.cell(row=row_num, column=5, value=round(row['total_elapsed_sec'] / 60, 2)).number_format = "#,##0.00"
|
||||
ws_cam.cell(row=row_num, column=6, value=int(row['total_runs'])).number_format = "#,##0"
|
||||
|
||||
for c_idx in range(1, 7):
|
||||
ws_cam.cell(row=row_num, column=c_idx).font = Font(name=font_family, size=11)
|
||||
ws_cam.cell(row=row_num, column=c_idx).border = thin_border
|
||||
|
||||
# Total Row for Camera Summary
|
||||
tot_row_cam = 4 + len(cam_pivot)
|
||||
ws_cam.cell(row=tot_row_cam, column=1, value="Total").font = total_font
|
||||
ws_cam.cell(row=tot_row_cam, column=1).fill = total_fill
|
||||
ws_cam.cell(row=tot_row_cam, column=1).alignment = Alignment(horizontal='center')
|
||||
ws_cam.cell(row=tot_row_cam, column=1).border = top_thick_bottom_double
|
||||
|
||||
for c_idx in [2, 4, 6]:
|
||||
col_let = get_column_letter(c_idx)
|
||||
c = ws_cam.cell(row=tot_row_cam, column=c_idx, value=f"=SUM({col_let}4:{col_let}{tot_row_cam-1})")
|
||||
c.font = total_font
|
||||
c.fill = total_fill
|
||||
c.number_format = "#,##0"
|
||||
c.border = top_thick_bottom_double
|
||||
|
||||
# Average for Avg column
|
||||
c_avg = ws_cam.cell(row=tot_row_cam, column=3, value=f"=AVERAGE(C4:C{tot_row_cam-1})")
|
||||
c_avg.font = total_font
|
||||
c_avg.fill = total_fill
|
||||
c_avg.number_format = "#,##0.0"
|
||||
c_avg.border = top_thick_bottom_double
|
||||
|
||||
# Sum for elapsed
|
||||
c_time = ws_cam.cell(row=tot_row_cam, column=5, value=f"=SUM(E4:E{tot_row_cam-1})")
|
||||
c_time.font = total_font
|
||||
c_time.fill = total_fill
|
||||
c_time.number_format = "#,##0.00"
|
||||
c_time.border = top_thick_bottom_double
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# SHEET 3: Raw Batch Runs
|
||||
# -------------------------------------------------------------
|
||||
ws_raw = wb.create_sheet(title="Raw Batch Runs")
|
||||
ws_raw.views.sheetView[0].showGridLines = True
|
||||
|
||||
raw_headers = ["ID", "Date", "Location", "Camera ID", "Total Entered", "Frames Processed", "Elapsed (s)", "Stopped Reason", "Source Video", "Generated At"]
|
||||
for c_idx, h_text in enumerate(raw_headers, 1):
|
||||
cell = ws_raw.cell(row=1, column=c_idx, value=h_text)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = Alignment(horizontal='center')
|
||||
|
||||
for r_idx, row in df.iterrows():
|
||||
row_num = 2 + r_idx
|
||||
ws_raw.cell(row=row_num, column=1, value=int(row['id'])).alignment = Alignment(horizontal='center')
|
||||
ws_raw.cell(row=row_num, column=2, value=str(row['date'])).alignment = Alignment(horizontal='center')
|
||||
ws_raw.cell(row=row_num, column=3, value=str(row['location'])).alignment = Alignment(horizontal='center')
|
||||
ws_raw.cell(row=row_num, column=4, value=str(row['camera_id'])).alignment = Alignment(horizontal='center')
|
||||
|
||||
ws_raw.cell(row=row_num, column=5, value=int(row['total_entered'])).number_format = "#,##0"
|
||||
ws_raw.cell(row=row_num, column=6, value=int(row['frames_processed'])).number_format = "#,##0"
|
||||
ws_raw.cell(row=row_num, column=7, value=float(row['elapsed_seconds'])).number_format = "#,##0.0"
|
||||
ws_raw.cell(row=row_num, column=8, value=str(row['stopped_reason'])).alignment = Alignment(horizontal='center')
|
||||
ws_raw.cell(row=row_num, column=9, value=str(row['source_video']))
|
||||
ws_raw.cell(row=row_num, column=10, value=str(row['generated_at']))
|
||||
|
||||
for c_idx in range(1, 11):
|
||||
ws_raw.cell(row=row_num, column=c_idx).font = Font(name=font_family, size=10)
|
||||
ws_raw.cell(row=row_num, column=c_idx).border = thin_border
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# SHEET 4: Configurations
|
||||
# -------------------------------------------------------------
|
||||
ws_cfg = wb.create_sheet(title="Configurations")
|
||||
ws_cfg.views.sheetView[0].showGridLines = True
|
||||
|
||||
ws_cfg["A1"] = "Pipeline & Model Configurations"
|
||||
ws_cfg["A1"].font = Font(name=font_family, size=14, bold=True, color="1F4E78")
|
||||
|
||||
# Global Settings Table
|
||||
ws_cfg["A3"] = "Global Pipeline & Detection Settings"
|
||||
ws_cfg["A3"].font = Font(name=font_family, size=11, bold=True, color="1F4E78")
|
||||
|
||||
global_configs = [
|
||||
("Hardware / Target Platform", "ASUS NUC (AMD Ryzen 9 9955HX + NVIDIA GeForce RTX 5070 8GB)"),
|
||||
("Model Path (Engine)", "models/NUC5070_chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine"),
|
||||
("Model Architecture", "YOLOv8/v26n TensorRT FP16 compiled engine"),
|
||||
("Inference Image Size (imgsz)", "640 x 640"),
|
||||
("IoU Threshold", "0.55"),
|
||||
("Default Confidence Threshold (conf)", "0.40"),
|
||||
("Target Classes", "[0] (Ignored: [1, 2])"),
|
||||
("Default Min Box Area (px)", "3000"),
|
||||
("Tracker Architecture", "BoT-SORT (persist=True, track_buffer=90)"),
|
||||
("Gate Counting Mode", "two_line (lines_y: [420, 730], direction: bottom_to_up)"),
|
||||
("Motion Flow Analysis", "vertical (forward_sign: 1.0, ema_alpha: 0.2, min_features: 40)"),
|
||||
("Inference Stride", "2 frames (motion.stride_frames: 3)"),
|
||||
("Execution Mode", "parallel_processes (MAX_JOBS=4)")
|
||||
]
|
||||
|
||||
ws_cfg.cell(row=4, column=1, value="Configuration Parameter").font = header_font
|
||||
ws_cfg.cell(row=4, column=1).fill = header_fill
|
||||
ws_cfg.cell(row=4, column=2, value="Setting / Value").font = header_font
|
||||
ws_cfg.cell(row=4, column=2).fill = header_fill
|
||||
|
||||
for idx, (param, val) in enumerate(global_configs, start=5):
|
||||
c1 = ws_cfg.cell(row=idx, column=1, value=param)
|
||||
c2 = ws_cfg.cell(row=idx, column=2, value=val)
|
||||
c1.font = Font(name=font_family, size=10, bold=True)
|
||||
c2.font = Font(name=font_family, size=10)
|
||||
c1.border = thin_border
|
||||
c2.border = thin_border
|
||||
c1.fill = accent_fill
|
||||
|
||||
# Stages Table
|
||||
stage_start_row = 5 + len(global_configs) + 2
|
||||
ws_cfg.cell(row=stage_start_row-1, column=1, value="Cycle Stage Adaptation Rules").font = Font(name=font_family, size=11, bold=True, color="1F4E78")
|
||||
|
||||
stage_configs = [
|
||||
("early_cycle (Day 0 - 15)", "conf: 0.10, min_box_area_px: 100, min_overlap_ratio: 0.10 (Chicks adaptation)"),
|
||||
("mid_cycle (Day 16+)", "conf: 0.40, min_box_area_px: 3000, default filters (Grown chicken standard)")
|
||||
]
|
||||
|
||||
ws_cfg.cell(row=stage_start_row, column=1, value="Cycle Stage").font = header_font
|
||||
ws_cfg.cell(row=stage_start_row, column=1).fill = header_fill
|
||||
ws_cfg.cell(row=stage_start_row, column=2, value="Applied Overrides").font = header_font
|
||||
ws_cfg.cell(row=stage_start_row, column=2).fill = header_fill
|
||||
|
||||
for idx, (stg, desc) in enumerate(stage_configs, start=stage_start_row+1):
|
||||
c1 = ws_cfg.cell(row=idx, column=1, value=stg)
|
||||
c2 = ws_cfg.cell(row=idx, column=2, value=desc)
|
||||
c1.font = Font(name=font_family, size=10, bold=True)
|
||||
c2.font = Font(name=font_family, size=10)
|
||||
c1.border = thin_border
|
||||
c2.border = thin_border
|
||||
c1.fill = accent_fill
|
||||
|
||||
# Per Camera Settings Table
|
||||
cam_cfg_start = stage_start_row + len(stage_configs) + 3
|
||||
ws_cfg.cell(row=cam_cfg_start-1, column=1, value="Per-Camera Specific Configurations").font = Font(name=font_family, size=11, bold=True, color="1F4E78")
|
||||
|
||||
cam_headers_cfg = ["Camera ID", "Count Anchor [X, Y]", "Confidence (conf)", "Min Box Area (px)", "Dedupe Settings", "ROI Polygon Points [X, Y]"]
|
||||
for c_idx, h in enumerate(cam_headers_cfg, 1):
|
||||
cell = ws_cfg.cell(row=cam_cfg_start, column=c_idx, value=h)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = Alignment(horizontal='center')
|
||||
|
||||
cam_details = [
|
||||
("CC1", "[780, 120]", "0.35", "2500", "Disabled", "[(250, 330), (1650, 330), (1650, 720), (250, 720)]"),
|
||||
("CC2", "[900, 120]", "0.50", "3000", "radius=32px, frames=24", "[(20, 380), (1880, 380), (1880, 720), (20, 720)]"),
|
||||
("CC3", "[900, 120]", "0.40", "3000", "radius=24px, frames=16", "[(20, 330), (1880, 330), (1880, 720), (20, 720)]"),
|
||||
("CC4", "[700, 120]", "0.40", "3000", "Disabled", "[(50, 330), (1450, 330), (1450, 720), (50, 720)]")
|
||||
]
|
||||
|
||||
for r_offset, r_data in enumerate(cam_details, 1):
|
||||
curr_row = cam_cfg_start + r_offset
|
||||
for col_i, val in enumerate(r_data, 1):
|
||||
cell = ws_cfg.cell(row=curr_row, column=col_i, value=val)
|
||||
cell.font = Font(name=font_family, size=10)
|
||||
cell.border = thin_border
|
||||
if col_i in [1, 2, 3, 4]:
|
||||
cell.alignment = Alignment(horizontal='center')
|
||||
|
||||
# Auto-adjust column widths across all sheets
|
||||
for ws in wb.worksheets:
|
||||
for col in ws.columns:
|
||||
max_len = 0
|
||||
col_letter = get_column_letter(col[0].column)
|
||||
for cell in col:
|
||||
# Avoid large width from title/merged cells
|
||||
if cell.row < 3 and ws.title == "Daily Summary":
|
||||
continue
|
||||
val_str = str(cell.value or '')
|
||||
if len(val_str) > max_len:
|
||||
max_len = len(val_str)
|
||||
ws.column_dimensions[col_letter].width = max(max_len + 4, 12)
|
||||
|
||||
ws_summary.column_dimensions['A'].width = 16
|
||||
ws_summary.column_dimensions['B'].width = 14
|
||||
ws_summary.column_dimensions['C'].width = 14
|
||||
ws_summary.column_dimensions['D'].width = 14
|
||||
ws_summary.column_dimensions['E'].width = 14
|
||||
ws_summary.column_dimensions['F'].width = 16
|
||||
|
||||
wb.save(output_excel_path)
|
||||
print(f"Excel report successfully generated: {output_excel_path}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pathlib import Path
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
db_file = str(BASE_DIR / "db" / "chicken_counts.db")
|
||||
out_file = str(BASE_DIR / "db" / "chicken_counts_report.xlsx")
|
||||
generate_excel_report(db_file, out_file)
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# Portable Systemd Service Installer for Chicken Counter Dashboard
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CURRENT_USER="$(id -un)"
|
||||
SERVICE_NAME="chicken-dashboard.service"
|
||||
|
||||
echo "=== Chicken Dashboard Service Installer ==="
|
||||
echo "Target User: $CURRENT_USER"
|
||||
echo "Project Dir: $SCRIPT_DIR"
|
||||
|
||||
# Check if user wants user-level systemd (no root required) or system-level systemd
|
||||
USER_SERVICE_DIR="$HOME/.config/systemd/user"
|
||||
mkdir -p "$USER_SERVICE_DIR"
|
||||
|
||||
cat <<EOF > "$USER_SERVICE_DIR/$SERVICE_NAME"
|
||||
[Unit]
|
||||
Description=Chicken Counter Live Dashboard
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=$SCRIPT_DIR
|
||||
ExecStart=$SCRIPT_DIR/start_dashboard.sh
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PORT=8080
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "✅ Installed user service to: $USER_SERVICE_DIR/$SERVICE_NAME"
|
||||
echo ""
|
||||
echo "To enable and start the dashboard service (runs on boot without sudo):"
|
||||
echo " systemctl --user daemon-reload"
|
||||
echo " systemctl --user enable --now $SERVICE_NAME"
|
||||
echo " loginctl enable-linger $CURRENT_USER # Ensures service runs even if logged out"
|
||||
echo ""
|
||||
echo "To check status:"
|
||||
echo " systemctl --user status $SERVICE_NAME"
|
||||
echo ""
|
||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable → Regular
BIN
Binary file not shown.
Executable → Regular
File renamed without changes.
Executable → Regular
File renamed without changes.
Binary file not shown.
Executable → Regular
File renamed without changes.
Binary file not shown.
Executable → Regular
File mode changed.
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
# Master runner for 5-coop / 10-floor daily processing
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export PYTHONPATH="$SCRIPT_DIR/src"
|
||||
|
||||
if [ -f "$SCRIPT_DIR/venv/bin/python" ]; then
|
||||
PYTHON="$SCRIPT_DIR/venv/bin/python"
|
||||
else
|
||||
PYTHON="python3"
|
||||
fi
|
||||
|
||||
DATE="${1:-$(date +%Y-%m-%d)}"
|
||||
FILTER="${2:-}" # Optional filter like 'K1', 'K2', 'K1-L3', or leave empty for all
|
||||
MODE="${3:-parallel_processes}"
|
||||
|
||||
# Auto-discover all configured floors in configs/floor_config/ (sorted naturally)
|
||||
CONFIGS=()
|
||||
for cfg in $(ls -1 "$SCRIPT_DIR/configs/floor_config/"*.yaml 2>/dev/null | sort -V); do
|
||||
base_name="$(basename "$cfg" .yaml)"
|
||||
if [ -n "$FILTER" ] && [[ "$base_name" != *"$FILTER"* ]]; then
|
||||
continue
|
||||
fi
|
||||
CONFIGS+=("configs/floor_config/$(basename "$cfg")")
|
||||
done
|
||||
|
||||
# Startup check: Ensure sibling VIDEOS directory exists
|
||||
VIDEOS_BASE="$SCRIPT_DIR/../VIDEOS"
|
||||
if [ ! -d "$VIDEOS_BASE" ]; then
|
||||
echo "[setup] 📁 Sibling directory '$VIDEOS_BASE' not found; creating directory skeleton..."
|
||||
mkdir -p "$VIDEOS_BASE/cycle7"
|
||||
fi
|
||||
|
||||
echo "================================================================="
|
||||
echo " Starting Multi-Floor Batch Processing for Date: $DATE"
|
||||
if [ -n "$FILTER" ]; then
|
||||
echo " Filter Applied: $FILTER"
|
||||
fi
|
||||
echo " Discovered Floors (${#CONFIGS[@]} total): ${CONFIGS[*]}"
|
||||
echo " Execution Mode: $MODE"
|
||||
echo "================================================================="
|
||||
|
||||
SUCCESS_COUNT=0
|
||||
SKIPPED_COUNT=0
|
||||
|
||||
for config in "${CONFIGS[@]}"; do
|
||||
if [ -f "$SCRIPT_DIR/$config" ]; then
|
||||
floor_name="$(basename "$config" .yaml)"
|
||||
video_day_dir="$SCRIPT_DIR/../VIDEOS/cycle7/$floor_name/$DATE"
|
||||
|
||||
# If videos don't exist for this floor/date on this machine, skip cleanly
|
||||
if [ ! -d "$video_day_dir" ] && [ ! -d "$SCRIPT_DIR/../VIDEOS/cycle7/$floor_name" ]; then
|
||||
echo "⏭️ [SKIPPED] $floor_name: No video directory found at ../VIDEOS/cycle7/$floor_name/$DATE"
|
||||
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo ">>> [PROCESSING] Location Config: $config <<<"
|
||||
if $PYTHON -m chicken_counter.cli batch --config "$config" --date "$DATE" --mode "$MODE"; then
|
||||
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
|
||||
else
|
||||
echo "⚠️ [SKIPPED / ERROR] $config for date $DATE (video not found or error occurred)."
|
||||
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "================================================================="
|
||||
echo " Multi-Floor Batch Summary: $SUCCESS_COUNT completed, $SKIPPED_COUNT skipped."
|
||||
echo " Database: db/chicken_counts.db"
|
||||
echo "================================================================="
|
||||
@@ -1,411 +0,0 @@
|
||||
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.
|
||||
@@ -1,24 +0,0 @@
|
||||
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
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[console_scripts]
|
||||
chicken-counter = chicken_counter.cli:main
|
||||
@@ -1,4 +0,0 @@
|
||||
numpy>=1.26
|
||||
opencv-python>=4.10
|
||||
PyYAML>=6.0.2
|
||||
ultralytics>=8.4.38
|
||||
@@ -1 +0,0 @@
|
||||
chicken_counter
|
||||
Executable → Regular
File mode changed.
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.
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user