feat: add engine auto-recompilation, mortality detection, multi-execution batching, and dashboard updates
- Add engine_utils for TensorRT compatibility verification, auto-recompilation from .pt models, and YAML auto-updates - Add mortality detection pipeline (mortality.py, test_run_mortality.sh, mortality_config.yaml) - Add multi-execution batch modes (parallel_processes, tensor_batching, hybrid) in batch_runner.py - Add daily test run automation scripts and video processing runners - Add dashboard REST API, live stream endpoints, and web UI templates - Clean up git tracking by ignoring __pycache__, .pyc, and build artifacts
This commit is contained in:
1 parent
c05f3d98d0
commit
010f6e1493
97 files changed
+3316
-598
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*
|
||||
@@ -2,7 +2,22 @@
|
||||
|
||||
Base URL: `http://<jetson-ip>:8080`
|
||||
|
||||
## Live Dashboard
|
||||
## 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.
|
||||
@@ -39,6 +54,20 @@ Live JPEG frame from the active pipeline.
|
||||
|
||||
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.
|
||||
|
||||
@@ -147,3 +176,131 @@ Data is inserted automatically by the batch runner when `location` and `db_path`
|
||||
```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
|
||||
```
|
||||
|
||||
|
||||
@@ -47,12 +47,16 @@ src/chicken_counter/
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python -m pip install -e .
|
||||
python3 -m venv venv
|
||||
venv/bin/pip install --upgrade pip
|
||||
venv/bin/pip install -r requirements.txt
|
||||
```
|
||||
|
||||
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 +203,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 +223,39 @@ for tuning.
|
||||
- The optical-flow trigger is vision-first, though the config structure leaves room for
|
||||
a future controller/encoder integration path
|
||||
|
||||
## 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.
|
||||
- **2-Pass Detect & Refine**: runs the YOLO segmentation model once to find candidate regions, then uses `cv2.matchTemplate` to do a similarity search over each candidate before confirming it as a detection. This reduces false positives significantly.
|
||||
- **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.
|
||||
- Uses the **segmentation model** (`models/chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt`) which provides higher boundary precision than the standard detection model.
|
||||
|
||||
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 +294,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 +333,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 +350,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 +386,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 +394,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 +443,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 +482,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,240 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
> **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/
|
||||
│ ├── cycle7_batch_optimized.yaml ← Main 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.onnx
|
||||
│ └── chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt ← Used by mortality
|
||||
├── src/chicken_counter/ ← Main Python package
|
||||
├── templates/ ← Dashboard HTML
|
||||
├── dashboard.py ← Live API + Dashboard server
|
||||
├── start_dashboard.sh ← Portable dashboard launcher ← USE THIS
|
||||
├── test_run_mortality.sh ← Run mortality detection
|
||||
├── test_run.sh ← Run batch processing all dates
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Model Setup
|
||||
|
||||
Put your model files in the `models/` directory.
|
||||
|
||||
| Purpose | File |
|
||||
| :--- | :--- |
|
||||
| Batch video counting | `chicken-detection-model-v26n-300e-best-2026-05-02-NEW.onnx` (speed) or `.pt` (accuracy) |
|
||||
| Mortality detection | `chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt` |
|
||||
|
||||
Update `configs/mortality_config.yaml` and `configs/cycle7_batch_optimized.yaml` if using different filenames.
|
||||
|
||||
---
|
||||
|
||||
## 5. Running the Systems
|
||||
|
||||
### A — Batch Video Processing (Daily Chicken Count)
|
||||
|
||||
Place input videos under the `VIDEOS` folder adjacent to the project:
|
||||
|
||||
```text
|
||||
../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
|
||||
...
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
# Run all dates (multi-process mode)
|
||||
./test_run.sh
|
||||
|
||||
# Run all dates (Tensor Batching mode)
|
||||
./test_run_tensor_batch.sh
|
||||
|
||||
# Run all dates (Hybrid mode: Threaded CPU + Batched GPU)
|
||||
./test_run_hybrid.sh
|
||||
|
||||
# Run a specific date (4 cameras in parallel)
|
||||
PYTHONPATH=src venv/bin/python -m chicken_counter.cli batch \
|
||||
--config configs/cycle7_batch_optimized.yaml \
|
||||
--date 2026-06-18
|
||||
|
||||
# Run a specific date using Model-Level Tensor Batching
|
||||
PYTHONPATH=src venv/bin/python -m chicken_counter.cli batch \
|
||||
--config configs/cycle7_batch_optimized.yaml \
|
||||
--date 2026-06-18 \
|
||||
--mode tensor_batching
|
||||
|
||||
# Run a specific date using Hybrid Execution Mode
|
||||
PYTHONPATH=src venv/bin/python -m chicken_counter.cli batch \
|
||||
--config configs/cycle7_batch_optimized.yaml \
|
||||
--date 2026-06-18 \
|
||||
--mode hybrid
|
||||
|
||||
# Run with video output enabled
|
||||
./test_run_video_2026-06-18.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` | Enable 2x Detect & Refine pipeline |
|
||||
| `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.
|
||||
@@ -4,12 +4,13 @@ After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=dsutanto
|
||||
WorkingDirectory=/media/jetson/DATA/.Codes/chicken-counting-sukawarna-det
|
||||
ExecStart=/media/jetson/DATA/karung-sukawarna/venv/bin/python dashboard.py --port 8080 --db db/chicken_counts.db
|
||||
User=asus
|
||||
WorkingDirectory=/home/asus/.Codes/chicken-counting-sukawarna-det
|
||||
ExecStart=/home/asus/.Codes/chicken-counting-sukawarna-det/start_dashboard.sh
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PORT=8080
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.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
+15
-5
@@ -1,16 +1,16 @@
|
||||
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: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/db/chicken_counts.db
|
||||
db_path: db/chicken_counts.db
|
||||
|
||||
defaults:
|
||||
detection:
|
||||
model_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/models/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.35
|
||||
@@ -19,15 +19,18 @@ defaults:
|
||||
device: "0"
|
||||
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: 75
|
||||
track_buffer: 90
|
||||
gate:
|
||||
mode: two_line
|
||||
lines_y: [420, 730]
|
||||
@@ -97,6 +100,10 @@ cameras:
|
||||
CC2:
|
||||
camera_num: 2
|
||||
count_anchor: [900, 120]
|
||||
detection:
|
||||
# ~5% double-count from ID flips; tight radius only.
|
||||
dedupe_radius_px: 24
|
||||
dedupe_frames: 12
|
||||
roi:
|
||||
points:
|
||||
- [20, 380]
|
||||
@@ -106,6 +113,9 @@ cameras:
|
||||
CC3:
|
||||
camera_num: 3
|
||||
count_anchor: [900, 120]
|
||||
detection:
|
||||
dedupe_radius_px: 24
|
||||
dedupe_frames: 12
|
||||
roi:
|
||||
points:
|
||||
- [20, 330]
|
||||
|
||||
Executable → Regular
+3
-3
@@ -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:
|
||||
|
||||
Executable → Regular
+3
-3
@@ -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:
|
||||
|
||||
Executable → Regular
+3
-3
@@ -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:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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
|
||||
@@ -7,27 +7,44 @@ batch:
|
||||
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: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine
|
||||
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.55 # ↑ 0.35 → fewer false positives, less tracking CPU
|
||||
conf: 0.40 # mild lift vs 0.35; 0.55 was undercounting
|
||||
iou: 0.55
|
||||
imgsz: 640
|
||||
device: "0"
|
||||
min_box_area_px: 5000 # ↑ 3000 → filter small false positives
|
||||
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: /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: 75
|
||||
track_buffer: 90
|
||||
gate:
|
||||
mode: two_line
|
||||
lines_y: [420, 730]
|
||||
@@ -41,7 +58,7 @@ defaults:
|
||||
reverse_exit_threshold: -0.5
|
||||
debounce_frames: 12
|
||||
min_features: 40 # ↓ 60 → less optical flow computation
|
||||
max_corners: 50 # ↓ 80 → fewer corner features
|
||||
max_corners: 50 # ↓ 80 → fewer corner featuresis
|
||||
stride_frames: 3 # ↑ 2 → motion detection every 3rd frame
|
||||
flow_scale: 0.5
|
||||
quality_level: 0.01
|
||||
@@ -83,12 +100,15 @@ defaults:
|
||||
inset_right_px: 60
|
||||
inset_top_px: 0
|
||||
inset_bottom_px: 0
|
||||
min_overlap_ratio: 0.35 # ↑ 0.30 → stricter counting validation
|
||||
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]
|
||||
@@ -98,8 +118,10 @@ cameras:
|
||||
CC2:
|
||||
camera_num: 2
|
||||
count_anchor: [900, 120]
|
||||
detection: # per-camera override: wider angle = smaller bboxes
|
||||
min_box_area_px: 3000
|
||||
detection:
|
||||
conf: 0.5 # From 0.45
|
||||
dedupe_radius_px: 32
|
||||
dedupe_frames: 24 # From 18
|
||||
roi:
|
||||
points:
|
||||
- [20, 380]
|
||||
@@ -109,6 +131,9 @@ cameras:
|
||||
CC3:
|
||||
camera_num: 3
|
||||
count_anchor: [900, 120]
|
||||
detection:
|
||||
dedupe_radius_px: 24
|
||||
dedupe_frames: 16 # From 12
|
||||
roi:
|
||||
points:
|
||||
- [20, 330]
|
||||
|
||||
@@ -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
+2
-2
@@ -1,8 +1,8 @@
|
||||
tracker_type: botsort
|
||||
track_high_thresh: 0.5
|
||||
track_low_thresh: 0.1
|
||||
new_track_thresh: 0.6
|
||||
track_buffer: 75
|
||||
new_track_thresh: 0.65
|
||||
track_buffer: 90
|
||||
match_thresh: 0.8
|
||||
fuse_score: true
|
||||
gmc_method: none
|
||||
|
||||
Executable → Regular
+206
-4
@@ -5,9 +5,11 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import mimetypes
|
||||
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
|
||||
@@ -25,6 +27,22 @@ TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
|
||||
_db_conn = None
|
||||
_db_lock = threading.Lock()
|
||||
_db_path = ""
|
||||
_mortality_dirs: list[Path] = []
|
||||
_cycle_start_date: str = "2026-05-22"
|
||||
|
||||
|
||||
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():
|
||||
@@ -93,6 +111,10 @@ class DashboardHandler(SimpleHTTPRequestHandler):
|
||||
self._handle_stream(path)
|
||||
return
|
||||
|
||||
if path in ("/api/status", "/api/system/status"):
|
||||
self._handle_status()
|
||||
return
|
||||
|
||||
if path == "/api/cameras":
|
||||
self._send_json({"cameras": _discover_cameras(self.shm_dir)})
|
||||
return
|
||||
@@ -101,12 +123,45 @@ class DashboardHandler(SimpleHTTPRequestHandler):
|
||||
self._handle_db(path)
|
||||
return
|
||||
|
||||
if path.startswith("/api/mortality"):
|
||||
self._handle_mortality(path)
|
||||
return
|
||||
|
||||
if path.startswith("/shm/"):
|
||||
self._handle_shm(path)
|
||||
return
|
||||
|
||||
self._send_error(404)
|
||||
|
||||
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"
|
||||
@@ -170,12 +225,19 @@ class DashboardHandler(SimpleHTTPRequestHandler):
|
||||
|
||||
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()
|
||||
self._send_json(dict(row))
|
||||
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()
|
||||
self._send_json([dict(r) for r in rows])
|
||||
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>
|
||||
@@ -184,7 +246,9 @@ class DashboardHandler(SimpleHTTPRequestHandler):
|
||||
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()
|
||||
self._send_json({"date": date, "total": dict(total), "cameras": [dict(r) for r in cameras]})
|
||||
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>
|
||||
@@ -206,6 +270,128 @@ class DashboardHandler(SimpleHTTPRequestHandler):
|
||||
|
||||
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() -> list[Path]:
|
||||
found = []
|
||||
for mdir in _mortality_dirs:
|
||||
p = Path(mdir)
|
||||
if p.is_dir():
|
||||
found.extend(list(p.rglob("mortality_report.json")))
|
||||
return found
|
||||
|
||||
# --- /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
|
||||
for mdir in _mortality_dirs:
|
||||
p = Path(mdir)
|
||||
if p.is_dir():
|
||||
for img_path in (p.rglob(filename) if filename else []):
|
||||
if 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")
|
||||
@@ -217,6 +403,7 @@ class DashboardHandler(SimpleHTTPRequestHandler):
|
||||
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)
|
||||
|
||||
@@ -224,6 +411,7 @@ class DashboardHandler(SimpleHTTPRequestHandler):
|
||||
data = json.dumps(obj).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
@@ -235,7 +423,7 @@ class DashboardHandler(SimpleHTTPRequestHandler):
|
||||
|
||||
|
||||
def main():
|
||||
global _db_path
|
||||
global _db_path, _mortality_dirs
|
||||
|
||||
parser = argparse.ArgumentParser(description="Chicken Counter live dashboard")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
|
||||
@@ -243,8 +431,22 @@ def main():
|
||||
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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Whitespace-only changes.
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,381 @@
|
||||
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)
|
||||
|
||||
# Styles
|
||||
font_family = "Segoe UI"
|
||||
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)
|
||||
Binary file not shown.
Executable → Regular
BIN
Binary file not shown.
Binary file not shown.
Executable → Regular
BIN
Binary file not shown.
Executable → Regular
File mode changed.
Executable → Regular
File mode changed.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable → Regular
File mode changed.
@@ -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.
Binary file not shown.
Binary file not shown.
Executable → Regular
File mode changed.
Executable → Regular
+531
-12
@@ -1,17 +1,20 @@
|
||||
"""Run CC1–CC4 sequentially, then compress videos and write the JSON report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import cv2
|
||||
import numpy as np
|
||||
from datetime import date as date_type
|
||||
from pathlib import Path
|
||||
|
||||
from chicken_counter.batch_discovery import discover_camera_videos
|
||||
from chicken_counter.batch_discovery import CameraDiscoveryResult, discover_camera_videos
|
||||
from chicken_counter.compress import compress_video_to_target
|
||||
from chicken_counter.config import BatchSettings, build_camera_config_from_batch
|
||||
from chicken_counter.pipeline import run_pipeline
|
||||
from chicken_counter.config import BatchSettings, CameraConfig, CameraPreset, build_camera_config_from_batch
|
||||
from chicken_counter.engine_utils import ensure_compatible_model
|
||||
from chicken_counter.overlay import draw_overlay
|
||||
from chicken_counter.pipeline import PipelineArtifacts, _consume_result, _write_stream_frame, build_pipeline, run_pipeline
|
||||
from chicken_counter.report import build_batch_report, persist_batch_reports
|
||||
from chicken_counter.types import CameraBatchResult
|
||||
from chicken_counter.tracking import DetectionTracker
|
||||
from chicken_counter.types import CameraBatchResult, FrameResult, PipelineResult, TrackObservation
|
||||
|
||||
|
||||
def _store_to_db(report_path: Path, location: str, db_path: str) -> None:
|
||||
@@ -26,7 +29,7 @@ def _store_to_db(report_path: Path, location: str, db_path: str) -> None:
|
||||
return
|
||||
|
||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn = sqlite3.connect(db_path, timeout=60.0)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS batch_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -67,13 +70,524 @@ def _store_to_db(report_path: Path, location: str, db_path: str) -> None:
|
||||
print(f"[db] stored {date} ({location}) → {db_path}")
|
||||
|
||||
|
||||
def run_daily_batch(settings: BatchSettings, date: str | None = None, *, verbose: bool = False, no_video: bool = False, show_progress: bool = False) -> Path:
|
||||
def run_tensor_batched_daily_batch(
|
||||
settings: BatchSettings,
|
||||
date: str | None = None,
|
||||
*,
|
||||
verbose: bool = False,
|
||||
no_video: bool = False,
|
||||
show_progress: bool = False,
|
||||
cycle_start_date: str | None = None,
|
||||
) -> Path:
|
||||
if cycle_start_date:
|
||||
settings.batch.cycle_start_date = cycle_start_date
|
||||
|
||||
run_date = date or date_type.today().isoformat()
|
||||
day_dir = Path(settings.batch.root_dir) / run_date
|
||||
output_dir = day_dir / settings.batch.output_subdir
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"[batch] starting daily run for {run_date}")
|
||||
print(f"[batch-tensor] starting tensor-batched daily run for {run_date}")
|
||||
print(f"[batch-tensor] input folder: {day_dir}")
|
||||
print(f"[batch-tensor] output folder: {output_dir}")
|
||||
|
||||
shm_dir = Path("/dev/shm")
|
||||
for d in shm_dir.glob("chicken_counter_*"):
|
||||
if d.is_dir():
|
||||
try:
|
||||
shutil.rmtree(str(d))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
discovery = discover_camera_videos(day_dir, settings)
|
||||
camera_order = sorted(settings.cameras.items(), key=lambda item: item[1].camera_num)
|
||||
|
||||
active_cams: list[tuple[str, CameraConfig, PipelineArtifacts]] = []
|
||||
camera_results: list[CameraBatchResult] = []
|
||||
report_path = output_dir / f"counts_{run_date}.json"
|
||||
|
||||
first_config = None
|
||||
for camera_id, _preset in camera_order:
|
||||
if camera_id in discovery.skipped:
|
||||
skip_reason = discovery.skipped[camera_id]
|
||||
print(f"[batch-tensor] skipping {camera_id}: {skip_reason}")
|
||||
camera_results.append(
|
||||
CameraBatchResult(
|
||||
camera_id=camera_id,
|
||||
skipped=True,
|
||||
skip_reason=skip_reason,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
source_path = discovery.found[camera_id]
|
||||
vis_path = output_dir / f"{camera_id}_vis.mp4" if not no_video else None
|
||||
checkpoint_dir = output_dir / "checkpoints" / camera_id
|
||||
|
||||
camera_config = build_camera_config_from_batch(
|
||||
settings,
|
||||
camera_id,
|
||||
source=source_path,
|
||||
output_path=vis_path,
|
||||
checkpoint_dir=checkpoint_dir,
|
||||
date=run_date,
|
||||
)
|
||||
camera_config.performance.verbose = verbose
|
||||
if first_config is None:
|
||||
first_config = camera_config
|
||||
|
||||
if not first_config:
|
||||
persist_batch_reports(run_date, camera_results, output_dir)
|
||||
_store_to_db(report_path, settings.batch.location, settings.batch.db_path)
|
||||
return report_path
|
||||
|
||||
shared_tracker = DetectionTracker(first_config)
|
||||
|
||||
for camera_id, _preset in camera_order:
|
||||
if camera_id in discovery.skipped:
|
||||
continue
|
||||
source_path = discovery.found[camera_id]
|
||||
vis_path = output_dir / f"{camera_id}_vis.mp4" if not no_video else None
|
||||
checkpoint_dir = output_dir / "checkpoints" / camera_id
|
||||
|
||||
camera_config = build_camera_config_from_batch(
|
||||
settings,
|
||||
camera_id,
|
||||
source=source_path,
|
||||
output_path=vis_path,
|
||||
checkpoint_dir=checkpoint_dir,
|
||||
date=run_date,
|
||||
)
|
||||
camera_config.performance.verbose = verbose
|
||||
artifacts = build_pipeline(camera_config, tracker=shared_tracker, run_date=run_date)
|
||||
active_cams.append((camera_id, camera_config, artifacts))
|
||||
|
||||
inference_stride = max(1, first_config.performance.inference_stride)
|
||||
frame_index = 0
|
||||
active_indices = list(range(len(active_cams)))
|
||||
last_batch_tracks: dict[str, list[TrackObservation]] = {cam_id: [] for cam_id, _, _ in active_cams}
|
||||
stopped_reasons: dict[str, str] = {cam_id: "eof" for cam_id, _, _ in active_cams}
|
||||
frame_counts: dict[str, int] = {cam_id: 0 for cam_id, _, _ in active_cams}
|
||||
|
||||
print(f"[batch-tensor] running synchronized batch tracking across {len(active_cams)} active cameras...")
|
||||
|
||||
while active_indices:
|
||||
frame_index += 1
|
||||
current_frames = []
|
||||
current_crops = []
|
||||
current_active = []
|
||||
|
||||
for idx in list(active_indices):
|
||||
cam_id, config, artifacts = active_cams[idx]
|
||||
ok, frame = artifacts.capture.read()
|
||||
if not ok:
|
||||
stopped_reasons[cam_id] = "eof"
|
||||
active_indices.remove(idx)
|
||||
continue
|
||||
frame_counts[cam_id] += 1
|
||||
current_frames.append(frame)
|
||||
current_crops.append(artifacts.detection_zone_rect)
|
||||
current_active.append(idx)
|
||||
|
||||
if not current_active:
|
||||
break
|
||||
|
||||
if frame_index % inference_stride == 0 or any(not last_batch_tracks[active_cams[i][0]] for i in current_active):
|
||||
batch_tracks_list = shared_tracker.infer_batch(current_frames, crop_rects=current_crops)
|
||||
for i, idx in enumerate(current_active):
|
||||
cam_id = active_cams[idx][0]
|
||||
last_batch_tracks[cam_id] = batch_tracks_list[i]
|
||||
|
||||
for i, idx in enumerate(current_active):
|
||||
cam_id, config, artifacts = active_cams[idx]
|
||||
frame = current_frames[i]
|
||||
tracks = last_batch_tracks[cam_id]
|
||||
|
||||
motion_state = artifacts.motion_detector.update(frame, tracks, frame_counts[cam_id])
|
||||
count_events = artifacts.counting_zone.update(
|
||||
tracks, frame_counts[cam_id], counting_paused=motion_state.backward_active
|
||||
)
|
||||
|
||||
needs_overlay = config.display.show_window or artifacts.writer is not None or config.stream.enabled
|
||||
annotated = draw_overlay(
|
||||
frame, config, artifacts.counting_zone, tracks, motion_state,
|
||||
frame_index=frame_counts[cam_id], buffer=artifacts.overlay_buffer
|
||||
) if needs_overlay else frame
|
||||
|
||||
result = FrameResult(
|
||||
frame_index=frame_counts[cam_id],
|
||||
tracks=tracks,
|
||||
inside_box_count=artifacts.counting_zone.inside_box_count,
|
||||
total_entered_count=artifacts.counting_zone.total_entered_count,
|
||||
motion_state=motion_state,
|
||||
count_events=count_events,
|
||||
)
|
||||
_consume_result(config, artifacts, annotated, result, None)
|
||||
|
||||
if config.stream.enabled and frame_counts[cam_id] % max(1, config.stream.interval_frames) == 0:
|
||||
_write_stream_frame(config.stream.shm_dir, config.camera_id, annotated, result, run_date=artifacts.run_date)
|
||||
|
||||
if motion_state.backward_active:
|
||||
stopped_reasons[cam_id] = "backward"
|
||||
print(f"[batch-tensor] backward motion confirmed for {cam_id} at frame={frame_counts[cam_id]}")
|
||||
active_indices.remove(idx)
|
||||
|
||||
for cam_id, config, artifacts in active_cams:
|
||||
elapsed_seconds = time.monotonic() - artifacts.run_start_time
|
||||
artifacts.capture.release()
|
||||
if artifacts.writer is not None:
|
||||
artifacts.writer.release()
|
||||
p_res = PipelineResult(
|
||||
camera_id=cam_id,
|
||||
total_entered_count=artifacts.counting_zone.total_entered_count,
|
||||
frames_processed=frame_counts[cam_id],
|
||||
stopped_reason=stopped_reasons[cam_id],
|
||||
vis_video_path=config.display.output_path,
|
||||
source_video=str(config.source),
|
||||
elapsed_seconds=elapsed_seconds,
|
||||
)
|
||||
camera_results.append(CameraBatchResult(camera_id=cam_id, pipeline=p_res))
|
||||
print(
|
||||
f"[batch-tensor] finished {cam_id}: total_entered={p_res.total_entered_count} "
|
||||
f"frames={p_res.frames_processed} reason={p_res.stopped_reason}"
|
||||
)
|
||||
|
||||
persist_batch_reports(run_date, camera_results, output_dir)
|
||||
_store_to_db(report_path, settings.batch.location, settings.batch.db_path)
|
||||
|
||||
if not no_video:
|
||||
print("[batch-tensor] all cameras complete; starting compression")
|
||||
for item in camera_results:
|
||||
if item.skipped or item.pipeline is None:
|
||||
continue
|
||||
vis_path = item.pipeline.vis_video_path
|
||||
if not vis_path:
|
||||
continue
|
||||
compressed_path = output_dir / f"{item.camera_id}_compressed.mp4"
|
||||
size_mb = compress_video_to_target(
|
||||
vis_path,
|
||||
compressed_path,
|
||||
max_mb=settings.batch.compress_max_mb,
|
||||
)
|
||||
item.compressed_video_path = str(compressed_path)
|
||||
item.compressed_size_mb = size_mb
|
||||
|
||||
if settings.batch.delete_intermediate:
|
||||
Path(vis_path).unlink(missing_ok=True)
|
||||
|
||||
persist_batch_reports(run_date, camera_results, output_dir)
|
||||
|
||||
report = build_batch_report(run_date, camera_results, output_dir=output_dir)
|
||||
print(
|
||||
f"[batch-tensor] complete for {run_date}: total_entered_sum={report.total_entered_sum} "
|
||||
f"report={report_path}"
|
||||
)
|
||||
_store_to_db(report_path, settings.batch.location, settings.batch.db_path)
|
||||
return report_path
|
||||
|
||||
|
||||
def run_hybrid_daily_batch(
|
||||
settings: BatchSettings,
|
||||
date: str | None = None,
|
||||
*,
|
||||
verbose: bool = False,
|
||||
no_video: bool = False,
|
||||
show_progress: bool = False,
|
||||
cycle_start_date: str | None = None,
|
||||
) -> Path:
|
||||
if cycle_start_date:
|
||||
settings.batch.cycle_start_date = cycle_start_date
|
||||
|
||||
run_date = date or date_type.today().isoformat()
|
||||
day_dir = Path(settings.batch.root_dir) / run_date
|
||||
output_dir = day_dir / settings.batch.output_subdir
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"[batch-hybrid] starting hybrid (threaded CPU + batched GPU) daily run for {run_date}")
|
||||
print(f"[batch-hybrid] input folder: {day_dir}")
|
||||
print(f"[batch-hybrid] output folder: {output_dir}")
|
||||
|
||||
shm_dir = Path("/dev/shm")
|
||||
for d in shm_dir.glob("chicken_counter_*"):
|
||||
if d.is_dir():
|
||||
try:
|
||||
shutil.rmtree(str(d))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
discovery = discover_camera_videos(day_dir, settings)
|
||||
camera_order = sorted(settings.cameras.items(), key=lambda item: item[1].camera_num)
|
||||
|
||||
active_cams: list[tuple[str, CameraConfig, PipelineArtifacts]] = []
|
||||
camera_results: list[CameraBatchResult] = []
|
||||
report_path = output_dir / f"counts_{run_date}.json"
|
||||
|
||||
first_config = None
|
||||
for camera_id, _preset in camera_order:
|
||||
if camera_id in discovery.skipped:
|
||||
skip_reason = discovery.skipped[camera_id]
|
||||
print(f"[batch-hybrid] skipping {camera_id}: {skip_reason}")
|
||||
camera_results.append(
|
||||
CameraBatchResult(
|
||||
camera_id=camera_id,
|
||||
skipped=True,
|
||||
skip_reason=skip_reason,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
source_path = discovery.found[camera_id]
|
||||
vis_path = output_dir / f"{camera_id}_vis.mp4" if not no_video else None
|
||||
checkpoint_dir = output_dir / "checkpoints" / camera_id
|
||||
|
||||
camera_config = build_camera_config_from_batch(
|
||||
settings,
|
||||
camera_id,
|
||||
source=source_path,
|
||||
output_path=vis_path,
|
||||
checkpoint_dir=checkpoint_dir,
|
||||
date=run_date,
|
||||
)
|
||||
camera_config.performance.verbose = verbose
|
||||
if first_config is None:
|
||||
first_config = camera_config
|
||||
|
||||
if not first_config:
|
||||
persist_batch_reports(run_date, camera_results, output_dir)
|
||||
_store_to_db(report_path, settings.batch.location, settings.batch.db_path)
|
||||
return report_path
|
||||
|
||||
shared_tracker = DetectionTracker(first_config)
|
||||
|
||||
for camera_id, _preset in camera_order:
|
||||
if camera_id in discovery.skipped:
|
||||
continue
|
||||
source_path = discovery.found[camera_id]
|
||||
vis_path = output_dir / f"{camera_id}_vis.mp4" if not no_video else None
|
||||
checkpoint_dir = output_dir / "checkpoints" / camera_id
|
||||
|
||||
camera_config = build_camera_config_from_batch(
|
||||
settings,
|
||||
camera_id,
|
||||
source=source_path,
|
||||
output_path=vis_path,
|
||||
checkpoint_dir=checkpoint_dir,
|
||||
date=run_date,
|
||||
)
|
||||
camera_config.performance.verbose = verbose
|
||||
artifacts = build_pipeline(camera_config, tracker=shared_tracker, run_date=run_date)
|
||||
active_cams.append((camera_id, camera_config, artifacts))
|
||||
|
||||
inference_stride = max(1, first_config.performance.inference_stride)
|
||||
frame_index = 0
|
||||
active_indices = list(range(len(active_cams)))
|
||||
last_batch_tracks: dict[str, list[TrackObservation]] = {cam_id: [] for cam_id, _, _ in active_cams}
|
||||
stopped_reasons: dict[str, str] = {cam_id: "eof" for cam_id, _, _ in active_cams}
|
||||
frame_counts: dict[str, int] = {cam_id: 0 for cam_id, _, _ in active_cams}
|
||||
elapsed_times: dict[str, float] = {}
|
||||
|
||||
print(f"[batch-hybrid] running hybrid pipeline across {len(active_cams)} active cameras...")
|
||||
|
||||
def read_camera_frame(idx: int):
|
||||
cam_id, config, artifacts = active_cams[idx]
|
||||
ok, frame = artifacts.capture.read()
|
||||
if not ok:
|
||||
return idx, False, None, None
|
||||
return idx, True, frame, artifacts.detection_zone_rect
|
||||
|
||||
def process_camera_post(idx: int, frame, tracks):
|
||||
cam_id, config, artifacts = active_cams[idx]
|
||||
count = frame_counts[cam_id]
|
||||
motion_state = artifacts.motion_detector.update(frame, tracks, count)
|
||||
count_events = artifacts.counting_zone.update(
|
||||
tracks, count, counting_paused=motion_state.backward_active
|
||||
)
|
||||
needs_overlay = config.display.show_window or artifacts.writer is not None or config.stream.enabled
|
||||
annotated = draw_overlay(
|
||||
frame, config, artifacts.counting_zone, tracks, motion_state,
|
||||
frame_index=count, buffer=artifacts.overlay_buffer
|
||||
) if needs_overlay else frame
|
||||
|
||||
result = FrameResult(
|
||||
frame_index=count,
|
||||
tracks=tracks,
|
||||
inside_box_count=artifacts.counting_zone.inside_box_count,
|
||||
total_entered_count=artifacts.counting_zone.total_entered_count,
|
||||
motion_state=motion_state,
|
||||
count_events=count_events,
|
||||
)
|
||||
_consume_result(config, artifacts, annotated, result, None)
|
||||
|
||||
if config.stream.enabled and count % max(1, config.stream.interval_frames) == 0:
|
||||
_write_stream_frame(config.stream.shm_dir, config.camera_id, annotated, result, run_date=artifacts.run_date)
|
||||
|
||||
return idx, motion_state
|
||||
|
||||
max_threads = max(1, len(active_cams))
|
||||
with ThreadPoolExecutor(max_workers=max_threads) as pool:
|
||||
while active_indices:
|
||||
frame_index += 1
|
||||
current_active = list(active_indices)
|
||||
|
||||
# 1. Parallel frame capture across active cameras (threaded CPU)
|
||||
read_futures = [pool.submit(read_camera_frame, idx) for idx in current_active]
|
||||
|
||||
captured = []
|
||||
for fut in read_futures:
|
||||
idx, ok, frame, crop_rect = fut.result()
|
||||
cam_id, _, artifacts = active_cams[idx]
|
||||
if not ok:
|
||||
stopped_reasons[cam_id] = "eof"
|
||||
if cam_id not in elapsed_times:
|
||||
elapsed_times[cam_id] = time.monotonic() - artifacts.run_start_time
|
||||
if idx in active_indices:
|
||||
active_indices.remove(idx)
|
||||
else:
|
||||
frame_counts[cam_id] += 1
|
||||
captured.append((idx, frame, crop_rect))
|
||||
|
||||
if not captured:
|
||||
break
|
||||
|
||||
current_active_now = [item[0] for item in captured]
|
||||
current_frames = [item[1] for item in captured]
|
||||
current_crops = [item[2] for item in captured]
|
||||
|
||||
# 2. Batched GPU inference (Synchronous on main thread)
|
||||
if frame_index % inference_stride == 0 or any(not last_batch_tracks[active_cams[i][0]] for i in current_active_now):
|
||||
batch_tracks_list = shared_tracker.infer_batch(current_frames, crop_rects=current_crops)
|
||||
for i, idx in enumerate(current_active_now):
|
||||
cam_id = active_cams[idx][0]
|
||||
last_batch_tracks[cam_id] = batch_tracks_list[i]
|
||||
|
||||
# 3. Parallel Post-processing across active cameras (threaded CPU)
|
||||
post_futures = [
|
||||
pool.submit(
|
||||
process_camera_post,
|
||||
idx,
|
||||
current_frames[i],
|
||||
last_batch_tracks[active_cams[idx][0]],
|
||||
)
|
||||
for i, idx in enumerate(current_active_now)
|
||||
]
|
||||
|
||||
for fut in post_futures:
|
||||
idx, motion_state = fut.result()
|
||||
if motion_state.backward_active:
|
||||
cam_id, _, artifacts = active_cams[idx]
|
||||
stopped_reasons[cam_id] = "backward"
|
||||
if cam_id not in elapsed_times:
|
||||
elapsed_times[cam_id] = time.monotonic() - artifacts.run_start_time
|
||||
print(f"[batch-hybrid] backward motion confirmed for {cam_id} at frame={frame_counts[cam_id]}")
|
||||
if idx in active_indices:
|
||||
active_indices.remove(idx)
|
||||
|
||||
for cam_id, config, artifacts in active_cams:
|
||||
elapsed_seconds = elapsed_times.get(cam_id, time.monotonic() - artifacts.run_start_time)
|
||||
artifacts.capture.release()
|
||||
if artifacts.writer is not None:
|
||||
artifacts.writer.release()
|
||||
p_res = PipelineResult(
|
||||
camera_id=cam_id,
|
||||
total_entered_count=artifacts.counting_zone.total_entered_count,
|
||||
frames_processed=frame_counts[cam_id],
|
||||
stopped_reason=stopped_reasons[cam_id],
|
||||
vis_video_path=config.display.output_path,
|
||||
source_video=str(config.source),
|
||||
elapsed_seconds=elapsed_seconds,
|
||||
)
|
||||
camera_results.append(CameraBatchResult(camera_id=cam_id, pipeline=p_res))
|
||||
print(
|
||||
f"[batch-hybrid] finished {cam_id}: total_entered={p_res.total_entered_count} "
|
||||
f"frames={p_res.frames_processed} reason={p_res.stopped_reason}"
|
||||
)
|
||||
|
||||
persist_batch_reports(run_date, camera_results, output_dir)
|
||||
_store_to_db(report_path, settings.batch.location, settings.batch.db_path)
|
||||
|
||||
if not no_video:
|
||||
print("[batch-hybrid] all cameras complete; starting compression")
|
||||
for item in camera_results:
|
||||
if item.skipped or item.pipeline is None:
|
||||
continue
|
||||
vis_path = item.pipeline.vis_video_path
|
||||
if not vis_path:
|
||||
continue
|
||||
compressed_path = output_dir / f"{item.camera_id}_compressed.mp4"
|
||||
size_mb = compress_video_to_target(
|
||||
vis_path,
|
||||
compressed_path,
|
||||
max_mb=settings.batch.compress_max_mb,
|
||||
)
|
||||
item.compressed_video_path = str(compressed_path)
|
||||
item.compressed_size_mb = size_mb
|
||||
|
||||
if settings.batch.delete_intermediate:
|
||||
Path(vis_path).unlink(missing_ok=True)
|
||||
|
||||
persist_batch_reports(run_date, camera_results, output_dir)
|
||||
|
||||
report = build_batch_report(run_date, camera_results, output_dir=output_dir)
|
||||
print(
|
||||
f"[batch-hybrid] complete for {run_date}: total_entered_sum={report.total_entered_sum} "
|
||||
f"report={report_path}"
|
||||
)
|
||||
_store_to_db(report_path, settings.batch.location, settings.batch.db_path)
|
||||
return report_path
|
||||
|
||||
|
||||
def run_daily_batch(
|
||||
settings: BatchSettings,
|
||||
date: str | None = None,
|
||||
*,
|
||||
camera_id: str | None = None,
|
||||
verbose: bool = False,
|
||||
no_video: bool = False,
|
||||
show_progress: bool = False,
|
||||
cycle_start_date: str | None = None,
|
||||
) -> Path:
|
||||
if cycle_start_date:
|
||||
settings.batch.cycle_start_date = cycle_start_date
|
||||
|
||||
# Pre-validate and ensure compatible model in batch settings
|
||||
default_model = settings.defaults.get("detection", {}).get("model_path")
|
||||
if default_model:
|
||||
device = settings.defaults.get("detection", {}).get("device", "0")
|
||||
imgsz = settings.defaults.get("detection", {}).get("imgsz", 640)
|
||||
validated_model = ensure_compatible_model(
|
||||
default_model,
|
||||
device=device,
|
||||
imgsz=imgsz,
|
||||
config_file_path=getattr(settings, "config_path", None),
|
||||
)
|
||||
settings.defaults["detection"]["model_path"] = validated_model
|
||||
|
||||
execution_mode = settings.batch.execution_mode
|
||||
if execution_mode == "hybrid" and not camera_id:
|
||||
return run_hybrid_daily_batch(
|
||||
settings,
|
||||
date=date,
|
||||
verbose=verbose,
|
||||
no_video=no_video,
|
||||
show_progress=show_progress,
|
||||
cycle_start_date=cycle_start_date,
|
||||
)
|
||||
if execution_mode == "tensor_batching" and not camera_id:
|
||||
return run_tensor_batched_daily_batch(
|
||||
settings,
|
||||
date=date,
|
||||
verbose=verbose,
|
||||
no_video=no_video,
|
||||
show_progress=show_progress,
|
||||
cycle_start_date=cycle_start_date,
|
||||
)
|
||||
|
||||
run_date = date or date_type.today().isoformat()
|
||||
day_dir = Path(settings.batch.root_dir) / run_date
|
||||
output_dir = day_dir / settings.batch.output_subdir
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"[batch] starting daily run for {run_date} (mode: {execution_mode})" + (f" (camera: {camera_id})" if camera_id else ""))
|
||||
print(f"[batch] input folder: {day_dir}")
|
||||
print(f"[batch] output folder: {output_dir}")
|
||||
|
||||
@@ -81,13 +595,17 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None, *, verbose
|
||||
shm_dir = Path("/dev/shm")
|
||||
for d in shm_dir.glob("chicken_counter_*"):
|
||||
if d.is_dir():
|
||||
try:
|
||||
shutil.rmtree(str(d))
|
||||
print(f"[batch] cleaned {d}")
|
||||
except Exception:
|
||||
pass
|
||||
if no_video:
|
||||
print("[batch] --no-video: skipping video output, overlay, and compression")
|
||||
|
||||
discovery = discover_camera_videos(day_dir, settings)
|
||||
camera_order = sorted(settings.cameras.items(), key=lambda item: item[1].camera_num)
|
||||
if camera_id:
|
||||
camera_order = [item for item in camera_order if item[0] == camera_id]
|
||||
|
||||
camera_results: list[CameraBatchResult] = []
|
||||
report_path = output_dir / f"counts_{run_date}.json"
|
||||
@@ -118,6 +636,7 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None, *, verbose
|
||||
source=source_path,
|
||||
output_path=vis_path,
|
||||
checkpoint_dir=checkpoint_dir,
|
||||
date=run_date,
|
||||
)
|
||||
camera_config.performance.verbose = verbose
|
||||
pipeline_result = run_pipeline(camera_config, show_progress=show_progress, run_date=run_date)
|
||||
|
||||
Executable → Regular
File mode changed.
Executable → Regular
+54
-1
@@ -6,6 +6,7 @@ import argparse
|
||||
|
||||
from chicken_counter.batch_runner import run_daily_batch
|
||||
from chicken_counter.config import load_batch_config, load_camera_config
|
||||
from chicken_counter.mortality import DEFAULT_CONFIG_PATH, DEFAULT_MORTALITY_DIR, DEFAULT_MODEL_PATH, run_mortality_count
|
||||
from chicken_counter.pipeline import run_pipeline
|
||||
|
||||
|
||||
@@ -25,10 +26,33 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"--date",
|
||||
help="Processing date folder in YYYY-MM-DD format. Defaults to today.",
|
||||
)
|
||||
batch_parser.add_argument("--camera", "--camera-id", help="Filter to run only a specific camera ID (e.g. CC1).")
|
||||
batch_parser.add_argument("--verbose", action="store_true", help="Enable debug-level logging.")
|
||||
batch_parser.add_argument("--no-video", action="store_true", help="Skip video output and compression for speed.")
|
||||
batch_parser.add_argument("--output-subdir", help="Override output subdirectory name.")
|
||||
batch_parser.add_argument("--cycle-start-date", help="Override start date of cycle (Day 0) in YYYY-MM-DD format.")
|
||||
batch_parser.add_argument(
|
||||
"--mode",
|
||||
"--execution-mode",
|
||||
dest="mode",
|
||||
choices=["parallel_processes", "tensor_batching", "hybrid"],
|
||||
help="Batch execution mode: 'parallel_processes' (default), 'tensor_batching', or 'hybrid'.",
|
||||
)
|
||||
batch_parser.add_argument("--progress-bar", action="store_true", help="Show a terminal progress bar.")
|
||||
|
||||
mortality_parser = subparsers.add_parser("mortality", help="Run whole-image mortality chicken detection on photo(s).")
|
||||
mortality_parser.add_argument("--config", default=DEFAULT_CONFIG_PATH, help="Path to mortality YAML config file.")
|
||||
mortality_parser.add_argument("--input", "-i", default=None, help="Path to image file or directory containing images.")
|
||||
mortality_parser.add_argument("--output-dir", "-o", default=None, help="Directory to save annotated images and report.")
|
||||
mortality_parser.add_argument("--model-path", "-m", default=None, help="Path to YOLO model (.onnx/.pt/.engine).")
|
||||
mortality_parser.add_argument("--conf", type=float, default=None, help="Confidence threshold (overrides YAML config).")
|
||||
mortality_parser.add_argument("--iou", type=float, default=None, help="NMS IoU threshold (overrides YAML config).")
|
||||
mortality_parser.add_argument("--min-area", type=int, default=None, help="Minimum box area in pixels (overrides YAML config).")
|
||||
mortality_parser.add_argument("--dedupe-radius", type=float, default=None, help="Deduplication radius in pixels (overrides YAML config).")
|
||||
mortality_parser.add_argument("--date", default=None, help="Date for the mortality run in YYYY-MM-DD format.")
|
||||
mortality_parser.add_argument("--two-pass", action="store_true", default=None, help="Enable 2x Detect & Refine crop pipeline.")
|
||||
mortality_parser.add_argument("--no-two-pass", action="store_false", dest="two_pass", help="Disable 2x Detect & Refine crop pipeline.")
|
||||
|
||||
parser.add_argument("--config", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--camera-id", help=argparse.SUPPRESS)
|
||||
return parser
|
||||
@@ -38,9 +62,38 @@ def main() -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "mortality":
|
||||
run_mortality_count(
|
||||
input_path=args.input,
|
||||
output_dir=args.output_dir,
|
||||
model_path=args.model_path,
|
||||
conf_threshold=args.conf,
|
||||
iou_threshold=args.iou,
|
||||
min_box_area_px=args.min_area,
|
||||
dedupe_radius_px=args.dedupe_radius,
|
||||
two_pass=args.two_pass,
|
||||
config_path=args.config,
|
||||
date=getattr(args, "date", None),
|
||||
)
|
||||
return
|
||||
|
||||
if args.command == "batch":
|
||||
settings = load_batch_config(args.config)
|
||||
run_daily_batch(settings, date=args.date, verbose=args.verbose, no_video=args.no_video, show_progress=args.progress_bar)
|
||||
if getattr(args, "output_subdir", None):
|
||||
settings.batch.output_subdir = args.output_subdir
|
||||
if getattr(args, "cycle_start_date", None):
|
||||
settings.batch.cycle_start_date = args.cycle_start_date
|
||||
if getattr(args, "mode", None):
|
||||
settings.batch.execution_mode = args.mode
|
||||
run_daily_batch(
|
||||
settings,
|
||||
date=args.date,
|
||||
camera_id=getattr(args, "camera", None),
|
||||
verbose=args.verbose,
|
||||
no_video=args.no_video,
|
||||
show_progress=args.progress_bar,
|
||||
cycle_start_date=getattr(args, "cycle_start_date", None),
|
||||
)
|
||||
return
|
||||
|
||||
if args.command == "run":
|
||||
|
||||
Executable → Regular
+2
@@ -59,6 +59,8 @@ def compress_video_to_target(
|
||||
output_file.unlink()
|
||||
|
||||
codec_attempts = [
|
||||
["-c:v", "h264_nvenc", "-preset", "p4", "-b:v", f"{attempt_kbps}k", "-maxrate", f"{attempt_kbps}k", "-bufsize", f"{attempt_kbps * 2}k"],
|
||||
["-c:v", "hevc_nvenc", "-preset", "p4", "-b:v", f"{attempt_kbps}k", "-maxrate", f"{attempt_kbps}k", "-bufsize", f"{attempt_kbps * 2}k"],
|
||||
["-c:v", "h264_nvmpi", "-b:v", f"{attempt_kbps}k", "-maxrate", f"{attempt_kbps}k", "-bufsize", f"{attempt_kbps * 2}k"],
|
||||
["-c:v", "libx264", "-preset", "fast", "-b:v", f"{attempt_kbps}k", "-maxrate", f"{attempt_kbps}k", "-bufsize", f"{attempt_kbps * 2}k"],
|
||||
]
|
||||
|
||||
Executable → Regular
+100
-7
@@ -13,6 +13,19 @@ import yaml
|
||||
|
||||
Point = tuple[int, int]
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def resolve_project_path(p: str | Path | None, base_dir: Path | None = None) -> str:
|
||||
"""Resolve relative path against PROJECT_ROOT (or base_dir) so configs work anywhere."""
|
||||
if p is None or p == "":
|
||||
return ""
|
||||
path_obj = Path(p)
|
||||
if path_obj.is_absolute():
|
||||
return str(path_obj)
|
||||
root = base_dir or PROJECT_ROOT
|
||||
return str((root / path_obj).resolve())
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectionConfig:
|
||||
@@ -25,6 +38,9 @@ class DetectionConfig:
|
||||
device: str | int | None = None
|
||||
min_box_area_px: int = 0
|
||||
validate_while_inside: bool = True
|
||||
# Off by default; enable per-camera (e.g. CC2/CC3) for ID-flip double counts.
|
||||
dedupe_radius_px: int = 0
|
||||
dedupe_frames: int = 12
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -53,7 +69,7 @@ class DetectionZoneConfig:
|
||||
class TrackerConfig:
|
||||
tracker_config_path: str
|
||||
persist: bool = True
|
||||
track_buffer: int = 75
|
||||
track_buffer: int = 90
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -145,6 +161,7 @@ class OverlayConfig:
|
||||
show_track_ring: bool = False
|
||||
count_anchor: Point = (900, 120)
|
||||
inside_box_only: bool = True
|
||||
validated_only: bool = True
|
||||
pending_blink: bool = True
|
||||
pending_colors: list[Color] = field(
|
||||
default_factory=lambda: [(255, 255, 0), (0, 255, 255)]
|
||||
@@ -202,6 +219,7 @@ class CameraConfig:
|
||||
feedback: FeedbackConfig
|
||||
detection_zone: DetectionZoneConfig = field(default_factory=DetectionZoneConfig)
|
||||
stream: StreamConfig = field(default_factory=StreamConfig)
|
||||
config_path: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -214,6 +232,8 @@ class BatchConfig:
|
||||
checkpoint_every_n_frames: int = 3000
|
||||
location: str = ""
|
||||
db_path: str = ""
|
||||
cycle_start_date: str | None = None
|
||||
execution_mode: str = "parallel_processes"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -224,6 +244,8 @@ class CameraPreset:
|
||||
count_anchor: Point | None = None
|
||||
gate: GateConfig | None = None
|
||||
motion: MotionConfig | None = None
|
||||
# Deep-merged over batch defaults (e.g. detection.dedupe_* for CC2/CC3).
|
||||
overrides: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -231,6 +253,8 @@ class BatchSettings:
|
||||
batch: BatchConfig
|
||||
defaults: dict[str, Any]
|
||||
cameras: dict[str, CameraPreset]
|
||||
stages: dict[str, Any] = field(default_factory=dict)
|
||||
config_path: str | None = None
|
||||
|
||||
|
||||
def _load_data(path: Path) -> dict[str, Any]:
|
||||
@@ -252,7 +276,9 @@ def _build_overlay_config(overlay_raw: dict[str, Any]) -> OverlayConfig:
|
||||
overlay_kwargs["pending_colors"] = [
|
||||
tuple(map(int, color)) for color in overlay_raw["pending_colors"]
|
||||
]
|
||||
return OverlayConfig(**overlay_kwargs)
|
||||
valid_fields = OverlayConfig.__dataclass_fields__.keys()
|
||||
filtered_kwargs = {k: v for k, v in overlay_kwargs.items() if k in valid_fields}
|
||||
return OverlayConfig(**filtered_kwargs)
|
||||
|
||||
|
||||
def _build_roi_config(roi_raw: dict[str, Any]) -> RoiConfig:
|
||||
@@ -267,6 +293,13 @@ def _build_roi_config(roi_raw: dict[str, Any]) -> RoiConfig:
|
||||
|
||||
|
||||
def _build_camera_config(raw: dict[str, Any]) -> CameraConfig:
|
||||
raw = copy.deepcopy(raw)
|
||||
if "detection" in raw and "model_path" in raw["detection"]:
|
||||
raw["detection"]["model_path"] = resolve_project_path(raw["detection"]["model_path"])
|
||||
if "tracker" in raw and "tracker_config_path" in raw["tracker"]:
|
||||
raw["tracker"]["tracker_config_path"] = resolve_project_path(raw["tracker"]["tracker_config_path"])
|
||||
if "source" in raw:
|
||||
raw["source"] = resolve_project_path(raw["source"])
|
||||
return CameraConfig(
|
||||
camera_id=raw["camera_id"],
|
||||
source=raw["source"],
|
||||
@@ -281,6 +314,7 @@ def _build_camera_config(raw: dict[str, Any]) -> CameraConfig:
|
||||
feedback=FeedbackConfig(**raw.get("feedback", {})),
|
||||
detection_zone=DetectionZoneConfig(**raw.get("detection_zone", {})),
|
||||
stream=StreamConfig(**raw.get("stream", {})),
|
||||
config_path=raw.get("config_path"),
|
||||
)
|
||||
|
||||
|
||||
@@ -295,7 +329,7 @@ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any
|
||||
|
||||
|
||||
def load_camera_config(path: str | Path, camera_id: str | None = None) -> CameraConfig:
|
||||
config_path = Path(path)
|
||||
config_path = Path(path).resolve()
|
||||
raw = _load_data(config_path)
|
||||
|
||||
if "batch" in raw:
|
||||
@@ -308,18 +342,26 @@ def load_camera_config(path: str | Path, camera_id: str | None = None) -> Camera
|
||||
raise ValueError("camera_id is required when config contains multiple cameras")
|
||||
raw = raw["cameras"][camera_id]
|
||||
|
||||
raw["config_path"] = str(config_path)
|
||||
return _build_camera_config(raw)
|
||||
|
||||
|
||||
def load_batch_config(path: str | Path) -> BatchSettings:
|
||||
config_path = Path(path)
|
||||
config_path = Path(path).resolve()
|
||||
raw = _load_data(config_path)
|
||||
|
||||
if "batch" not in raw:
|
||||
raise ValueError("Batch config must contain a top-level 'batch' section")
|
||||
|
||||
batch = BatchConfig(**raw["batch"])
|
||||
raw_batch = copy.deepcopy(raw["batch"])
|
||||
if "root_dir" in raw_batch:
|
||||
raw_batch["root_dir"] = resolve_project_path(raw_batch["root_dir"])
|
||||
if "db_path" in raw_batch:
|
||||
raw_batch["db_path"] = resolve_project_path(raw_batch["db_path"])
|
||||
|
||||
batch = BatchConfig(**raw_batch)
|
||||
defaults = raw.get("defaults", {})
|
||||
stages = raw.get("stages", {})
|
||||
cameras: dict[str, CameraPreset] = {}
|
||||
|
||||
for camera_id, camera_raw in raw.get("cameras", {}).items():
|
||||
@@ -333,6 +375,17 @@ def load_batch_config(path: str | Path) -> BatchSettings:
|
||||
gate = GateConfig(**camera_raw["gate"]) if "gate" in camera_raw else None
|
||||
motion = MotionConfig(**camera_raw["motion"]) if "motion" in camera_raw else None
|
||||
|
||||
# Preserve per-camera section overrides so they merge into defaults at build time.
|
||||
reserved = {"camera_num", "roi", "count_anchor", "gate", "motion"}
|
||||
overrides = {
|
||||
key: value
|
||||
for key, value in camera_raw.items()
|
||||
if key not in reserved and isinstance(value, dict)
|
||||
}
|
||||
# count_anchor is also applied via preset; keep overlay-only dict overrides.
|
||||
if "overlay" in camera_raw and isinstance(camera_raw["overlay"], dict):
|
||||
overrides["overlay"] = camera_raw["overlay"]
|
||||
|
||||
cameras[camera_id] = CameraPreset(
|
||||
camera_id=camera_id,
|
||||
camera_num=int(camera_raw["camera_num"]),
|
||||
@@ -340,9 +393,16 @@ def load_batch_config(path: str | Path) -> BatchSettings:
|
||||
count_anchor=count_anchor,
|
||||
gate=gate,
|
||||
motion=motion,
|
||||
overrides=overrides,
|
||||
)
|
||||
|
||||
return BatchSettings(batch=batch, defaults=defaults, cameras=cameras)
|
||||
return BatchSettings(
|
||||
batch=batch,
|
||||
defaults=defaults,
|
||||
cameras=cameras,
|
||||
stages=stages,
|
||||
config_path=str(config_path),
|
||||
)
|
||||
|
||||
|
||||
def build_camera_config_from_batch(
|
||||
@@ -352,12 +412,45 @@ def build_camera_config_from_batch(
|
||||
source: str | Path,
|
||||
output_path: str | Path | None,
|
||||
checkpoint_dir: str | Path,
|
||||
date: str | None = None,
|
||||
) -> CameraConfig:
|
||||
if camera_id not in settings.cameras:
|
||||
raise KeyError(f"Unknown camera_id in batch config: {camera_id}")
|
||||
|
||||
preset = settings.cameras[camera_id]
|
||||
raw = _deep_merge(settings.defaults, {"camera_id": camera_id, "source": str(source)})
|
||||
raw = _deep_merge(
|
||||
settings.defaults,
|
||||
{
|
||||
"camera_id": camera_id,
|
||||
"source": str(source),
|
||||
"config_path": getattr(settings, "config_path", None),
|
||||
},
|
||||
)
|
||||
if preset.overrides:
|
||||
raw = _deep_merge(raw, preset.overrides)
|
||||
|
||||
# Resolve cycle stage if cycle_start_date and date are present
|
||||
if settings.batch.cycle_start_date and date:
|
||||
try:
|
||||
from datetime import date as date_type
|
||||
start_d = date_type.fromisoformat(str(settings.batch.cycle_start_date))
|
||||
run_d = date_type.fromisoformat(str(date))
|
||||
cycle_day = (run_d - start_d).days
|
||||
if cycle_day >= 0 and settings.stages:
|
||||
active_stage_name = None
|
||||
stage_overrides = {}
|
||||
for stage_name, stage_cfg in settings.stages.items():
|
||||
day_min = stage_cfg.get("day_min", 0)
|
||||
day_max = stage_cfg.get("day_max", 999)
|
||||
if day_min <= cycle_day <= day_max:
|
||||
active_stage_name = stage_name
|
||||
stage_overrides = {k: v for k, v in stage_cfg.items() if k not in ("day_min", "day_max")}
|
||||
break
|
||||
if stage_overrides:
|
||||
print(f"[batch] Date {date} -> Cycle Day {cycle_day} (Stage: {active_stage_name})")
|
||||
raw = _deep_merge(raw, stage_overrides)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raw.setdefault("roi", {})
|
||||
raw["roi"]["points"] = [list(point) for point in preset.roi.points]
|
||||
|
||||
Executable → Regular
+49
-1
@@ -20,6 +20,8 @@ class CountingZone:
|
||||
track_buffer: int,
|
||||
min_box_area_px: int = 0,
|
||||
validate_while_inside: bool = True,
|
||||
dedupe_radius_px: int = 0,
|
||||
dedupe_frames: int = 12,
|
||||
*,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
@@ -30,6 +32,8 @@ class CountingZone:
|
||||
self.min_box_area_px = min_box_area_px
|
||||
self.min_overlap_ratio = roi.min_overlap_ratio
|
||||
self.validate_while_inside = validate_while_inside
|
||||
self.dedupe_radius_px = max(0, dedupe_radius_px)
|
||||
self.dedupe_frames = max(0, dedupe_frames)
|
||||
self.verbose = verbose
|
||||
self.inside_box_count = 0
|
||||
self.total_entered_count = 0
|
||||
@@ -40,6 +44,8 @@ class CountingZone:
|
||||
self.current_inside_ids: set[int] = set()
|
||||
self.sequence_numbers_by_track_id: dict[int, int] = {}
|
||||
self.latest_validated_track_id: int | None = None
|
||||
# Recent validated centroids used to suppress ID-switch double counts.
|
||||
self._recent_counts: deque[tuple[int, tuple[int, int], int]] = deque()
|
||||
self._counting_polygon = np.array(roi.counting_polygon(), dtype=np.int32)
|
||||
self._counting_rect = roi.counting_rect()
|
||||
|
||||
@@ -93,11 +99,27 @@ class CountingZone:
|
||||
)
|
||||
should_validate = just_entered_box and self._meets_validation_thresholds(track)
|
||||
|
||||
if should_validate:
|
||||
if not should_validate:
|
||||
continue
|
||||
|
||||
duplicate_of = self._find_recent_duplicate(track.centroid, frame_index)
|
||||
if duplicate_of is not None:
|
||||
# Same bird after a track-ID flip: absorb into prior sequence, do not increment.
|
||||
prior_sequence = duplicate_of
|
||||
self.counted_ids.add(track.track_id)
|
||||
self.sequence_numbers_by_track_id[track.track_id] = prior_sequence
|
||||
if self.verbose:
|
||||
print(
|
||||
f"[count-dedupe] track={track.track_id} reused seq=#{prior_sequence} "
|
||||
f"frame={frame_index} centroid={track.centroid}"
|
||||
)
|
||||
continue
|
||||
|
||||
self.counted_ids.add(track.track_id)
|
||||
self.total_entered_count += 1
|
||||
self.sequence_numbers_by_track_id[track.track_id] = self.total_entered_count
|
||||
self.latest_validated_track_id = track.track_id
|
||||
self._remember_count(frame_index, track.centroid, self.total_entered_count)
|
||||
events.append(
|
||||
CountEvent(
|
||||
track_id=track.track_id,
|
||||
@@ -168,6 +190,31 @@ class CountingZone:
|
||||
def _meets_validation_thresholds(self, track: TrackObservation) -> bool:
|
||||
return self._meets_size_threshold(track) and self._meets_overlap_threshold(track)
|
||||
|
||||
def _remember_count(self, frame_index: int, centroid: tuple[int, int], sequence: int) -> None:
|
||||
if self.dedupe_radius_px <= 0 or self.dedupe_frames <= 0:
|
||||
return
|
||||
self._recent_counts.append((frame_index, centroid, sequence))
|
||||
self._prune_recent_counts(frame_index)
|
||||
|
||||
def _prune_recent_counts(self, frame_index: int) -> None:
|
||||
while self._recent_counts and frame_index - self._recent_counts[0][0] > self.dedupe_frames:
|
||||
self._recent_counts.popleft()
|
||||
|
||||
def _find_recent_duplicate(self, centroid: tuple[int, int], frame_index: int) -> int | None:
|
||||
"""Return prior sequence number if centroid is near a recent count, else None."""
|
||||
if self.dedupe_radius_px <= 0 or self.dedupe_frames <= 0:
|
||||
return None
|
||||
|
||||
self._prune_recent_counts(frame_index)
|
||||
radius_sq = self.dedupe_radius_px * self.dedupe_radius_px
|
||||
cx, cy = centroid
|
||||
for _, (px, py), sequence in reversed(self._recent_counts):
|
||||
dx = cx - px
|
||||
dy = cy - py
|
||||
if dx * dx + dy * dy <= radius_sq:
|
||||
return sequence
|
||||
return None
|
||||
|
||||
def _purge_stale(self, frame_index: int, active_ids: set[int]) -> None:
|
||||
stale_ids = [
|
||||
track_id
|
||||
@@ -179,3 +226,4 @@ class CountingZone:
|
||||
self.histories.pop(track_id, None)
|
||||
self.prev_inside_ids.discard(track_id)
|
||||
self.current_inside_ids.discard(track_id)
|
||||
self._prune_recent_counts(frame_index)
|
||||
@@ -0,0 +1,229 @@
|
||||
"""TensorRT engine compatibility verification, auto-recompilation, and config updates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def verify_engine_compatibility(
|
||||
model_path: str | Path,
|
||||
*,
|
||||
device: str | int | None = "0",
|
||||
imgsz: int = 640,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Verify if a TensorRT .engine file can be loaded and executed on the current system/GPU.
|
||||
|
||||
Returns (True, None) if compatible, or (False, error_reason) if incompatible.
|
||||
"""
|
||||
model_path = Path(model_path)
|
||||
if not model_path.exists():
|
||||
return False, f"Model file not found: {model_path}"
|
||||
|
||||
if model_path.suffix.lower() != ".engine":
|
||||
return True, None
|
||||
|
||||
try:
|
||||
from ultralytics import YOLO
|
||||
|
||||
target_device = str(device) if device is not None else "0"
|
||||
model = YOLO(str(model_path), task="detect")
|
||||
dummy_frame = np.zeros((imgsz, imgsz, 3), dtype=np.uint8)
|
||||
model.predict(dummy_frame, device=target_device, verbose=False)
|
||||
return True, None
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def find_matching_pt_model(
|
||||
engine_path: str | Path,
|
||||
search_dirs: list[str | Path] | None = None,
|
||||
) -> Path | None:
|
||||
"""Find the matching .pt weights file for a given .engine file."""
|
||||
engine_path = Path(engine_path)
|
||||
|
||||
# 1. Look in the same directory and standard models/ directories
|
||||
default_dirs = [
|
||||
engine_path.parent,
|
||||
PROJECT_ROOT / "models",
|
||||
PROJECT_ROOT,
|
||||
]
|
||||
dirs_to_search = [Path(d).resolve() for d in (search_dirs or default_dirs) if Path(d).exists()]
|
||||
|
||||
# 2. Check direct stem match (e.g. model.engine -> model.pt)
|
||||
direct_pt = engine_path.with_suffix(".pt")
|
||||
if direct_pt.exists():
|
||||
return direct_pt.resolve()
|
||||
|
||||
for directory in dirs_to_search:
|
||||
direct = directory / f"{engine_path.stem}.pt"
|
||||
if direct.exists():
|
||||
return direct.resolve()
|
||||
|
||||
# 3. Check stripped prefix match (e.g. NUC5070_model.engine or jetson_model.engine -> model.pt)
|
||||
cleaned_stem = re.sub(r"^(NUC\w*|jetson\w*|orin\w*|xavier\w*|nano\w*|arm\w*|x86\w*|gpu\w*)_", "", engine_path.stem, flags=re.IGNORECASE)
|
||||
for directory in dirs_to_search:
|
||||
candidate = directory / f"{cleaned_stem}.pt"
|
||||
if candidate.exists():
|
||||
return candidate.resolve()
|
||||
|
||||
# 4. Search all .pt files in search dirs and find closest substring/stem match
|
||||
all_pts: list[Path] = []
|
||||
for directory in dirs_to_search:
|
||||
all_pts.extend(directory.glob("*.pt"))
|
||||
|
||||
if not all_pts:
|
||||
return None
|
||||
|
||||
# Try matching chicken detection models specifically
|
||||
for pt in all_pts:
|
||||
if "seg" not in pt.stem.lower() and "pose" not in pt.stem.lower() and "chicken" in pt.stem.lower():
|
||||
if cleaned_stem.lower() in pt.stem.lower() or pt.stem.lower() in cleaned_stem.lower():
|
||||
return pt.resolve()
|
||||
|
||||
# Fallback to any chicken detection .pt model
|
||||
for pt in all_pts:
|
||||
if "seg" not in pt.stem.lower() and "pose" not in pt.stem.lower() and "chicken" in pt.stem.lower():
|
||||
return pt.resolve()
|
||||
|
||||
return all_pts[0].resolve() if all_pts else None
|
||||
|
||||
|
||||
def recompile_engine_from_pt(
|
||||
pt_path: str | Path,
|
||||
*,
|
||||
imgsz: int = 640,
|
||||
device: str | int | None = "0",
|
||||
half: bool = True,
|
||||
workspace: int = 4,
|
||||
verbose: bool = True,
|
||||
) -> Path:
|
||||
"""Compile a new TensorRT .engine from a .pt file on the current machine."""
|
||||
pt_path = Path(pt_path).resolve()
|
||||
if not pt_path.exists():
|
||||
raise FileNotFoundError(f"Source PyTorch model not found: {pt_path}")
|
||||
|
||||
from ultralytics import YOLO
|
||||
|
||||
target_device = str(device) if device is not None else "0"
|
||||
print(f"[engine_utils] ⚙️ Compiling TensorRT .engine from: {pt_path.name} (device={target_device}, imgsz={imgsz}, half={half})...")
|
||||
|
||||
model = YOLO(str(pt_path), task="detect")
|
||||
exported_engine = model.export(
|
||||
format="engine",
|
||||
imgsz=imgsz,
|
||||
half=half,
|
||||
workspace=workspace,
|
||||
device=target_device,
|
||||
verbose=verbose,
|
||||
)
|
||||
|
||||
exported_path = Path(exported_engine).resolve()
|
||||
print(f"[engine_utils] ✅ Successfully compiled TensorRT engine: {exported_path}")
|
||||
return exported_path
|
||||
|
||||
|
||||
def update_config_yaml_model_path(
|
||||
config_file_path: str | Path,
|
||||
new_model_path: str | Path,
|
||||
) -> bool:
|
||||
"""Update defaults.detection.model_path in a YAML configuration file while preserving formatting."""
|
||||
config_path = Path(config_file_path).resolve()
|
||||
if not config_path.exists():
|
||||
return False
|
||||
|
||||
# Format model path relative to project root if applicable
|
||||
new_model_path = Path(new_model_path).resolve()
|
||||
try:
|
||||
rel_path = new_model_path.relative_to(PROJECT_ROOT)
|
||||
formatted_path = str(rel_path)
|
||||
except ValueError:
|
||||
formatted_path = str(new_model_path)
|
||||
|
||||
content = config_path.read_text(encoding="utf-8")
|
||||
|
||||
# Match 'model_path: <something>' under detection section
|
||||
pattern = r"^([ \t]*model_path:[ \t]*)(?:['\"]?)([^'\"\r\n#]+)(?:['\"]?)([ \t]*(?:#.*)?)$"
|
||||
|
||||
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}{formatted_path}{comment}"
|
||||
|
||||
new_content, count = re.subn(pattern, replacer, content, count=1, flags=re.MULTILINE)
|
||||
if count > 0:
|
||||
config_path.write_text(new_content, encoding="utf-8")
|
||||
print(f"[engine_utils] 💾 Updated YAML config '{config_path.name}' -> model_path: {formatted_path}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def ensure_compatible_model(
|
||||
model_path: str | Path,
|
||||
*,
|
||||
device: str | int | None = "0",
|
||||
imgsz: int = 640,
|
||||
config_file_path: str | Path | None = None,
|
||||
) -> str:
|
||||
"""Ensure the model at model_path is compatible with the current hardware.
|
||||
|
||||
If an incompatible .engine is detected:
|
||||
1. Automatically locates the matching .pt file.
|
||||
2. Recompiles a new .engine optimized for this system.
|
||||
3. Updates config_file_path (e.g. cycle7_batch_optimized.yaml) with the new engine path.
|
||||
4. Returns the path to the compatible model.
|
||||
"""
|
||||
model_path_obj = Path(model_path)
|
||||
if model_path_obj.suffix.lower() != ".engine":
|
||||
return str(model_path)
|
||||
|
||||
is_compatible, error_msg = verify_engine_compatibility(
|
||||
model_path_obj,
|
||||
device=device,
|
||||
imgsz=imgsz,
|
||||
)
|
||||
if is_compatible:
|
||||
return str(model_path_obj)
|
||||
|
||||
print(f"\n[engine_utils] ⚠️ TensorRT engine '{model_path_obj.name}' is incompatible with this system/GPU.")
|
||||
print(f"[engine_utils] Reason: {error_msg}")
|
||||
print("[engine_utils] 🔄 Auto-recompilation triggered: Searching for matching .pt model...")
|
||||
|
||||
pt_model = find_matching_pt_model(model_path_obj)
|
||||
if pt_model is None:
|
||||
raise RuntimeError(
|
||||
f"TensorRT engine '{model_path}' is incompatible with this hardware, "
|
||||
f"and no matching .pt source model was found in {PROJECT_ROOT / 'models'} to recompile from."
|
||||
)
|
||||
|
||||
print(f"[engine_utils] 📦 Found source PyTorch model: {pt_model.name}")
|
||||
try:
|
||||
new_engine_path = recompile_engine_from_pt(
|
||||
pt_model,
|
||||
imgsz=imgsz,
|
||||
device=device,
|
||||
half=True,
|
||||
workspace=4,
|
||||
verbose=True,
|
||||
)
|
||||
except Exception as export_err:
|
||||
print(f"[engine_utils] ❌ Engine recompilation failed: {export_err}")
|
||||
print(f"[engine_utils] ⚠️ Falling back to PyTorch .pt model: {pt_model}")
|
||||
return str(pt_model)
|
||||
|
||||
# If config file is specified, update it
|
||||
if config_file_path:
|
||||
update_config_yaml_model_path(config_file_path, new_engine_path)
|
||||
|
||||
return str(new_engine_path)
|
||||
@@ -0,0 +1,456 @@
|
||||
"""Mortality counter: Whole-image chicken carcass detection and counting without ROI cropping."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import yaml
|
||||
from ultralytics import YOLO
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_MODEL_PATH = str(PROJECT_ROOT / "models" / "chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt")
|
||||
DEFAULT_MORTALITY_DIR = str((PROJECT_ROOT.parent / "VIDEOS" / "cycle7" / "kandang-atas" / "mortality").resolve())
|
||||
DEFAULT_CONFIG_PATH = str(PROJECT_ROOT / "configs" / "mortality_config.yaml")
|
||||
|
||||
|
||||
def resolve_path(p: str | Path | None, base_dir: Path | None = None) -> str | None:
|
||||
"""Resolve relative path against PROJECT_ROOT (or base_dir) so it works regardless of working directory."""
|
||||
if p is None:
|
||||
return None
|
||||
path_obj = Path(p)
|
||||
if path_obj.is_absolute():
|
||||
return str(path_obj)
|
||||
root = base_dir or PROJECT_ROOT
|
||||
return str((root / path_obj).resolve())
|
||||
|
||||
|
||||
def load_mortality_config(config_path: str | Path) -> dict:
|
||||
"""Load mortality YAML config file if present."""
|
||||
p = Path(config_path)
|
||||
if not p.is_file():
|
||||
return {}
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
return data.get("mortality", data)
|
||||
|
||||
|
||||
def deduplicate_boxes(
|
||||
boxes_list: list[dict],
|
||||
dedupe_radius_px: float = 30.0,
|
||||
iou_thresh: float = 0.35,
|
||||
ioa_thresh: float = 0.50,
|
||||
) -> list[dict]:
|
||||
"""Sort detections by confidence descending and suppress duplicate, overlapping, or nested sub-boxes (box inside a box)."""
|
||||
if not boxes_list:
|
||||
return boxes_list
|
||||
|
||||
sorted_boxes = sorted(boxes_list, key=lambda b: b["confidence"], reverse=True)
|
||||
kept: list[dict] = []
|
||||
|
||||
for item in sorted_boxes:
|
||||
x1, y1, x2, y2 = item["box"]
|
||||
cx = (x1 + x2) / 2.0
|
||||
cy = (y1 + y2) / 2.0
|
||||
area_item = (x2 - x1) * (y2 - y1)
|
||||
|
||||
is_duplicate = False
|
||||
for k in kept:
|
||||
kx1, ky1, kx2, ky2 = k["box"]
|
||||
kcx = (kx1 + kx2) / 2.0
|
||||
kcy = (ky1 + ky2) / 2.0
|
||||
area_k = (kx2 - kx1) * (ky2 - ky1)
|
||||
|
||||
# 1. Centroid distance check
|
||||
dist = float(np.hypot(cx - kcx, cy - kcy))
|
||||
if dedupe_radius_px > 0 and dist < dedupe_radius_px:
|
||||
is_duplicate = True
|
||||
break
|
||||
|
||||
# 2. IoU and Nested Containment (IoA) check to eliminate box inside a box
|
||||
ix1, iy1 = max(x1, kx1), max(y1, ky1)
|
||||
ix2, iy2 = min(x2, kx2), min(y2, ky2)
|
||||
inter = max(0, ix2 - ix1) * max(0, iy2 - iy1)
|
||||
if inter > 0:
|
||||
union = area_item + area_k - inter
|
||||
iou = inter / float(union) if union > 0 else 0
|
||||
ioa = inter / float(min(area_item, area_k)) if min(area_item, area_k) > 0 else 0
|
||||
|
||||
if iou > iou_thresh or ioa > ioa_thresh:
|
||||
is_duplicate = True
|
||||
break
|
||||
|
||||
if not is_duplicate:
|
||||
kept.append(item)
|
||||
|
||||
for i, item in enumerate(kept, start=1):
|
||||
item["id"] = i
|
||||
|
||||
return kept
|
||||
|
||||
|
||||
def run_similarity_two_pass_detection(
|
||||
model: YOLO,
|
||||
img: np.ndarray,
|
||||
classes: list[int],
|
||||
conf_threshold: float,
|
||||
iou_threshold: float,
|
||||
min_box_area_px: int,
|
||||
device: str | int = "cpu",
|
||||
imgsz: int = 640,
|
||||
crop_padding_ratio: float = 0.50,
|
||||
) -> list[dict]:
|
||||
"""2x Detection Strategy:
|
||||
Pass 1: Detect primary anchor chickens from the model.
|
||||
Similarity Search: Extract visual feature templates from Pass 1 detections to search for similar carcass patterns.
|
||||
Pass 2 / Dynamic Accuracy: If candidates fall below Pass 1 confidence or initial pass yields 0, automatically adapt confidence threshold.
|
||||
"""
|
||||
img_h, img_w = img.shape[:2]
|
||||
|
||||
# Pass 1: Primary detection pass
|
||||
pass1_results = model.predict(
|
||||
source=img,
|
||||
classes=classes,
|
||||
conf=conf_threshold,
|
||||
iou=iou_threshold,
|
||||
imgsz=imgsz,
|
||||
device=device,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
pass1_boxes = pass1_results[0].boxes
|
||||
raw_detections: list[dict] = []
|
||||
templates: list[np.ndarray] = []
|
||||
|
||||
# Adaptive Step 1: If 0 detections found in Pass 1, automatically adjust accuracy/confidence down
|
||||
if pass1_boxes is None or len(pass1_boxes) == 0:
|
||||
adaptive_conf = max(0.18, conf_threshold * 0.60)
|
||||
print(f"[mortality] Initial scan found 0 detections. Auto-adjusting confidence down to {adaptive_conf:.2f}...")
|
||||
pass1_results = model.predict(
|
||||
source=img,
|
||||
classes=classes,
|
||||
conf=adaptive_conf,
|
||||
iou=iou_threshold,
|
||||
imgsz=imgsz,
|
||||
device=device,
|
||||
verbose=False,
|
||||
)
|
||||
pass1_boxes = pass1_results[0].boxes
|
||||
if pass1_boxes is None or len(pass1_boxes) == 0:
|
||||
return []
|
||||
|
||||
# Collect Pass 1 detections & extract high-confidence chicken templates
|
||||
for p1_box in pass1_boxes:
|
||||
p1_xyxy = p1_box.xyxy[0].cpu().numpy().astype(int)
|
||||
p1_conf = float(p1_box.conf[0].cpu().item())
|
||||
x1, y1, x2, y2 = p1_xyxy
|
||||
area = (x2 - x1) * (y2 - y1)
|
||||
if area >= min_box_area_px:
|
||||
raw_detections.append({
|
||||
"id": 0,
|
||||
"box": [int(x1), int(y1), int(x2), int(y2)],
|
||||
"confidence": round(p1_conf, 4),
|
||||
"area": int(area),
|
||||
"pass": 1,
|
||||
})
|
||||
if p1_conf >= 0.50 and (x2 - x1) >= 30 and (y2 - y1) >= 30:
|
||||
crop_tmpl = img[y1:y2, x1:x2]
|
||||
templates.append(crop_tmpl)
|
||||
|
||||
# Pass 2: Feature Similarity & Targeted Local Refinement
|
||||
pass2_conf = max(0.18, conf_threshold * 0.70)
|
||||
|
||||
# Compute template similarity map if templates are available
|
||||
if templates:
|
||||
gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||
sim_map = np.zeros((img_h, img_w), dtype=np.float32)
|
||||
|
||||
for tmpl in templates[:4]: # Top templates
|
||||
gray_tmpl = cv2.cvtColor(tmpl, cv2.COLOR_BGR2GRAY)
|
||||
th, tw = gray_tmpl.shape
|
||||
if th > 10 and tw > 10 and th <= img_h and tw <= img_w:
|
||||
res_map = cv2.matchTemplate(gray_img, gray_tmpl, cv2.TM_CCOEFF_NORMED)
|
||||
padded_map = np.pad(res_map, ((0, img_h - res_map.shape[0]), (0, img_w - res_map.shape[1])), mode='constant')
|
||||
sim_map = np.maximum(sim_map, padded_map)
|
||||
|
||||
# Re-examine candidate regions with Pass 2 refinement
|
||||
for p1_box in pass1_boxes:
|
||||
p1_xyxy = p1_box.xyxy[0].cpu().numpy().astype(int)
|
||||
x1, y1, x2, y2 = p1_xyxy
|
||||
|
||||
bw = x2 - x1
|
||||
bh = y2 - y1
|
||||
pad_x = int(bw * crop_padding_ratio) + 20
|
||||
pad_y = int(bh * crop_padding_ratio) + 20
|
||||
|
||||
cx1 = max(0, x1 - pad_x)
|
||||
cy1 = max(0, y1 - pad_y)
|
||||
cx2 = min(img_w, x2 + pad_x)
|
||||
cy2 = min(img_h, y2 + pad_y)
|
||||
|
||||
crop = img[cy1:cy2, cx1:cx2]
|
||||
if crop.size == 0 or crop.shape[0] < 20 or crop.shape[1] < 20:
|
||||
continue
|
||||
|
||||
pass2_results = model.predict(
|
||||
source=crop,
|
||||
classes=classes,
|
||||
conf=pass2_conf,
|
||||
iou=iou_threshold,
|
||||
imgsz=imgsz,
|
||||
device=device,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
p2_boxes = pass2_results[0].boxes
|
||||
if p2_boxes is not None and len(p2_boxes) > 0:
|
||||
for p2_box in p2_boxes:
|
||||
rx1, ry1, rx2, ry2 = p2_box.xyxy[0].cpu().numpy().astype(int)
|
||||
rconf = float(p2_box.conf[0].cpu().item())
|
||||
|
||||
gx1 = cx1 + rx1
|
||||
gy1 = cy1 + ry1
|
||||
gx2 = cx1 + rx2
|
||||
gy2 = cy1 + ry2
|
||||
gw = gx2 - gx1
|
||||
gh = gy2 - gy1
|
||||
garea = gw * gh
|
||||
|
||||
aspect_ratio = gw / float(gh) if gh > 0 else 0
|
||||
if 0.35 <= aspect_ratio <= 2.8 and garea >= min_box_area_px:
|
||||
raw_detections.append({
|
||||
"id": 0,
|
||||
"box": [int(gx1), int(gy1), int(gx2), int(gy2)],
|
||||
"confidence": round(rconf, 4),
|
||||
"area": int(garea),
|
||||
"pass": 2,
|
||||
})
|
||||
|
||||
return raw_detections
|
||||
|
||||
|
||||
def run_mortality_count(
|
||||
input_path: str | Path | None = None,
|
||||
output_dir: str | Path | None = None,
|
||||
model_path: str | Path | None = None,
|
||||
conf_threshold: float | None = None,
|
||||
iou_threshold: float | None = None,
|
||||
min_box_area_px: int | None = None,
|
||||
dedupe_radius_px: float | None = None,
|
||||
two_pass: bool | None = None,
|
||||
device: str | int | None = None,
|
||||
classes: list[int] | None = None,
|
||||
imgsz: int | None = None,
|
||||
config_path: str | Path | None = DEFAULT_CONFIG_PATH,
|
||||
date: str | None = None,
|
||||
) -> dict:
|
||||
config_path = resolve_path(config_path) if config_path else DEFAULT_CONFIG_PATH
|
||||
cfg = load_mortality_config(config_path) if config_path else {}
|
||||
|
||||
# 2. CLI / function arguments override YAML config defaults if specified
|
||||
raw_input_path = input_path if input_path is not None else cfg.get("input_dir", DEFAULT_MORTALITY_DIR)
|
||||
raw_output_dir = output_dir if output_dir is not None else cfg.get("output_dir", None)
|
||||
raw_model_path = model_path if model_path is not None else cfg.get("model_path", DEFAULT_MODEL_PATH)
|
||||
|
||||
input_path = resolve_path(raw_input_path)
|
||||
output_dir = resolve_path(raw_output_dir) if raw_output_dir else None
|
||||
model_path = resolve_path(raw_model_path)
|
||||
conf_threshold = conf_threshold if conf_threshold is not None else float(cfg.get("conf", 0.35))
|
||||
iou_threshold = iou_threshold if iou_threshold is not None else float(cfg.get("iou", 0.45))
|
||||
min_box_area_px = min_box_area_px if min_box_area_px is not None else int(cfg.get("min_box_area_px", 2500))
|
||||
dedupe_radius_px = dedupe_radius_px if dedupe_radius_px is not None else float(cfg.get("dedupe_radius_px", 30.0))
|
||||
two_pass = two_pass if two_pass is not None else bool(cfg.get("two_pass", True))
|
||||
device = device if device is not None else cfg.get("device", "0")
|
||||
classes = classes if classes is not None else cfg.get("classes", [0])
|
||||
imgsz = imgsz if imgsz is not None else int(cfg.get("imgsz", 640))
|
||||
|
||||
input_p = Path(input_path)
|
||||
|
||||
# Check for date subfolder if date is provided and input_path is a directory
|
||||
if date and input_p.is_dir() and (input_p / date).is_dir():
|
||||
input_p = input_p / date
|
||||
|
||||
if input_p.is_file():
|
||||
image_files = [input_p]
|
||||
target_out_dir = Path(output_dir) if output_dir else input_p.parent
|
||||
elif input_p.is_dir():
|
||||
target_out_dir = Path(output_dir) if output_dir else (input_p / date if (date and not output_dir and not input_p.name == date) else input_p)
|
||||
valid_exts = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tiff"}
|
||||
# Filter out images already prefixed with output_
|
||||
image_files = sorted([
|
||||
f for f in input_p.iterdir()
|
||||
if f.is_file() and f.suffix.lower() in valid_exts and not f.name.startswith("output_")
|
||||
])
|
||||
else:
|
||||
raise FileNotFoundError(f"Input path not found: {input_path}")
|
||||
|
||||
target_out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not image_files:
|
||||
print(f"[mortality] No input images found at: {input_path}")
|
||||
return {"total_images": 0, "results": []}
|
||||
|
||||
mode_str = "2x Similarity & Adaptive Accuracy Refinement" if two_pass else "Single-Pass Direct"
|
||||
print(f"[mortality] Mode: {mode_str}")
|
||||
print(f"[mortality] Loading model from: {model_path} (device: {device})")
|
||||
print(f"[mortality] Config: classes={classes}, conf={conf_threshold}, iou={iou_threshold}, min_area={min_box_area_px}px, dedupe_radius={dedupe_radius_px}px")
|
||||
|
||||
model = YOLO(str(model_path), task="detect")
|
||||
results_summary = []
|
||||
|
||||
for img_file in image_files:
|
||||
print(f"[mortality] Processing: {img_file.name}...")
|
||||
img = cv2.imread(str(img_file))
|
||||
if img is None:
|
||||
print(f"[mortality] Failed to read image: {img_file}")
|
||||
continue
|
||||
|
||||
try:
|
||||
if two_pass:
|
||||
raw_detections = run_similarity_two_pass_detection(
|
||||
model=model,
|
||||
img=img,
|
||||
classes=classes,
|
||||
conf_threshold=conf_threshold,
|
||||
iou_threshold=iou_threshold,
|
||||
min_box_area_px=min_box_area_px,
|
||||
device=device,
|
||||
imgsz=imgsz,
|
||||
)
|
||||
else:
|
||||
results = model.predict(
|
||||
source=img,
|
||||
classes=classes,
|
||||
conf=conf_threshold,
|
||||
iou=iou_threshold,
|
||||
imgsz=imgsz,
|
||||
device=device,
|
||||
verbose=False,
|
||||
)
|
||||
res = results[0]
|
||||
boxes = res.boxes
|
||||
raw_detections = []
|
||||
if boxes is not None:
|
||||
for box in boxes:
|
||||
xyxy = box.xyxy[0].cpu().numpy().astype(int)
|
||||
conf = float(box.conf[0].cpu().item())
|
||||
x1, y1, x2, y2 = xyxy
|
||||
area = (x2 - x1) * (y2 - y1)
|
||||
if area >= min_box_area_px:
|
||||
raw_detections.append({
|
||||
"id": 0,
|
||||
"box": [int(x1), int(y1), int(x2), int(y2)],
|
||||
"confidence": round(conf, 4),
|
||||
"area": int(area),
|
||||
"pass": 1,
|
||||
})
|
||||
except Exception as exc:
|
||||
if "CUDA" in str(exc) or "out of memory" in str(exc):
|
||||
print(f"[mortality] CUDA OOM encountered. Falling back to CPU for {img_file.name}...")
|
||||
if two_pass:
|
||||
raw_detections = run_similarity_two_pass_detection(
|
||||
model=model,
|
||||
img=img,
|
||||
classes=classes,
|
||||
conf_threshold=conf_threshold,
|
||||
iou_threshold=iou_threshold,
|
||||
min_box_area_px=min_box_area_px,
|
||||
device="cpu",
|
||||
imgsz=imgsz,
|
||||
)
|
||||
else:
|
||||
results = model.predict(
|
||||
source=img,
|
||||
classes=classes,
|
||||
conf=conf_threshold,
|
||||
iou=iou_threshold,
|
||||
imgsz=imgsz,
|
||||
device="cpu",
|
||||
verbose=False,
|
||||
)
|
||||
res = results[0]
|
||||
boxes = res.boxes
|
||||
raw_detections = []
|
||||
if boxes is not None:
|
||||
for box in boxes:
|
||||
xyxy = box.xyxy[0].cpu().numpy().astype(int)
|
||||
conf = float(box.conf[0].cpu().item())
|
||||
x1, y1, x2, y2 = xyxy
|
||||
area = (x2 - x1) * (y2 - y1)
|
||||
if area >= min_box_area_px:
|
||||
raw_detections.append({
|
||||
"id": 0,
|
||||
"box": [int(x1), int(y1), int(x2), int(y2)],
|
||||
"confidence": round(conf, 4),
|
||||
"area": int(area),
|
||||
"pass": 1,
|
||||
})
|
||||
else:
|
||||
raise exc
|
||||
|
||||
# Apply spatial deduplication to suppress duplicate boxes on a single chicken
|
||||
filtered_detections = deduplicate_boxes(raw_detections, dedupe_radius_px=dedupe_radius_px)
|
||||
count = len(filtered_detections)
|
||||
|
||||
# Draw detections
|
||||
for det in filtered_detections:
|
||||
idx = det["id"]
|
||||
conf = det["confidence"]
|
||||
x1, y1, x2, y2 = det["box"]
|
||||
|
||||
# Draw bright green bounding box around chicken carcass
|
||||
cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2)
|
||||
|
||||
# Draw label badge (#1, #2...) with confidence
|
||||
label = f"#{idx} ({conf:.2f})"
|
||||
(tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1)
|
||||
cv2.rectangle(img, (x1, max(0, y1 - th - 6)), (x1 + tw + 4, y1), (0, 200, 0), -1)
|
||||
cv2.putText(img, label, (x1 + 2, max(th, y1 - 4)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1)
|
||||
|
||||
# Draw banner at top of image displaying total carcass count
|
||||
banner_height = 60
|
||||
h, w, _ = img.shape
|
||||
cv2.rectangle(img, (0, 0), (w, banner_height), (0, 0, 0), -1)
|
||||
|
||||
banner_text = f"TOTAL CHICKEN CARCASSES: {count}"
|
||||
cv2.putText(img, banner_text, (20, 42), cv2.FONT_HERSHEY_SIMPLEX, 1.1, (0, 255, 255), 3)
|
||||
|
||||
out_image_path = target_out_dir / f"output_{img_file.name}"
|
||||
cv2.imwrite(str(out_image_path), img)
|
||||
|
||||
print(f"[mortality] -> {img_file.name}: Count = {count} (Saved: {out_image_path})")
|
||||
|
||||
results_summary.append({
|
||||
"input_image": img_file.name,
|
||||
"output_image": out_image_path.name,
|
||||
"count": count,
|
||||
"output_path": str(out_image_path),
|
||||
"detections": filtered_detections,
|
||||
})
|
||||
|
||||
total_mortality_count = sum(item["count"] for item in results_summary)
|
||||
report_path = target_out_dir / "mortality_report.json"
|
||||
report_data = {
|
||||
"date": date or datetime.now().strftime("%Y-%m-%d"),
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"mode": "similarity_two_pass" if two_pass else "single_pass",
|
||||
"config_file": str(config_path) if config_path else None,
|
||||
"model_path": str(model_path),
|
||||
"device": str(device),
|
||||
"classes": classes,
|
||||
"conf_threshold": conf_threshold,
|
||||
"iou_threshold": iou_threshold,
|
||||
"min_box_area_px": min_box_area_px,
|
||||
"dedupe_radius_px": dedupe_radius_px,
|
||||
"total_images": len(image_files),
|
||||
"total_mortality_count": total_mortality_count,
|
||||
"results": results_summary,
|
||||
}
|
||||
with open(report_path, "w") as f:
|
||||
json.dump(report_data, f, indent=2)
|
||||
|
||||
print(f"[mortality] Summary report saved to: {report_path} (Total Carcasses: {total_mortality_count} across {len(image_files)} images)")
|
||||
return report_data
|
||||
Executable → Regular
File mode changed.
Executable → Regular
File mode changed.
Executable → Regular
+5
@@ -110,6 +110,8 @@ def build_pipeline(
|
||||
track_buffer=config.tracker.track_buffer,
|
||||
min_box_area_px=config.detection.min_box_area_px,
|
||||
validate_while_inside=config.detection.validate_while_inside,
|
||||
dedupe_radius_px=config.detection.dedupe_radius_px,
|
||||
dedupe_frames=config.detection.dedupe_frames,
|
||||
verbose=config.performance.verbose,
|
||||
)
|
||||
motion_detector = BackwardMotionDetector(config.motion, config.roi, verbose=config.performance.verbose)
|
||||
@@ -372,6 +374,7 @@ def _consume_result(
|
||||
|
||||
|
||||
def _write_stream_frame(shm_dir: str, camera_id: str, frame: np.ndarray, result: FrameResult, *, run_date: str = "") -> None:
|
||||
try:
|
||||
cam_dir = Path(shm_dir) / f"chicken_counter_{camera_id}"
|
||||
cam_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -394,6 +397,8 @@ def _write_stream_frame(shm_dir: str, camera_id: str, frame: np.ndarray, result:
|
||||
stats_tmp = cam_dir / ".stats_tmp.json"
|
||||
stats_tmp.write_text(json.dumps(stats), encoding="utf-8")
|
||||
stats_tmp.replace(stats_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _should_emit_feedback(config: CameraConfig, frame_index: int) -> bool:
|
||||
|
||||
Executable → Regular
+23
-1
@@ -97,10 +97,32 @@ def persist_batch_reports(
|
||||
output_dir: str | Path,
|
||||
) -> Path:
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
if results:
|
||||
latest = results[-1]
|
||||
write_camera_report(date, latest, output_path)
|
||||
|
||||
aggregate_path = output_path / f"counts_{date}.json"
|
||||
report = build_batch_report(date, results, output_dir=output_path)
|
||||
cameras_dict: dict[str, dict] = {}
|
||||
total_sum = 0
|
||||
for camera_json in sorted(output_path.glob(f"*_counts_{date}.json")):
|
||||
try:
|
||||
data = json.loads(camera_json.read_text(encoding="utf-8"))
|
||||
cam_id = data.get("camera_id")
|
||||
if cam_id:
|
||||
entry = {k: v for k, v in data.items() if k not in ("date", "camera_id", "generated_at")}
|
||||
cameras_dict[cam_id] = entry
|
||||
if not entry.get("skipped"):
|
||||
total_sum += entry.get("total_entered", 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
report = BatchReport(
|
||||
date=date,
|
||||
generated_at=datetime.now(timezone.utc).isoformat(),
|
||||
cameras=cameras_dict,
|
||||
total_entered_sum=total_sum,
|
||||
)
|
||||
write_batch_report(report, aggregate_path)
|
||||
return aggregate_path
|
||||
|
||||
|
||||
Executable → Regular
+121
-2
@@ -9,15 +9,26 @@ import numpy as np
|
||||
from ultralytics import YOLO
|
||||
|
||||
from chicken_counter.config import CameraConfig
|
||||
from chicken_counter.engine_utils import ensure_compatible_model
|
||||
from chicken_counter.types import TrackObservation
|
||||
|
||||
|
||||
class DetectionTracker:
|
||||
def __init__(self, config: CameraConfig) -> None:
|
||||
self.config = config
|
||||
model_path = Path(config.detection.model_path)
|
||||
|
||||
# Ensure model is compatible on this machine / GPU, recompiling .engine from .pt if needed
|
||||
validated_model_path = ensure_compatible_model(
|
||||
config.detection.model_path,
|
||||
device=config.detection.device,
|
||||
imgsz=config.detection.imgsz,
|
||||
config_file_path=getattr(config, "config_path", None),
|
||||
)
|
||||
config.detection.model_path = validated_model_path
|
||||
|
||||
model_path = Path(validated_model_path)
|
||||
self.model_kind = model_path.suffix.lower().lstrip(".") or "unknown"
|
||||
self.model = YOLO(config.detection.model_path, task="detect")
|
||||
self.model = YOLO(validated_model_path, task="detect")
|
||||
self.tracker_config_path = str(Path(config.tracker.tracker_config_path))
|
||||
self.verbose = config.performance.verbose
|
||||
self._infer_count = 0
|
||||
@@ -140,6 +151,114 @@ class DetectionTracker:
|
||||
|
||||
return tracks
|
||||
|
||||
def infer_batch(
|
||||
self,
|
||||
frames: list[np.ndarray],
|
||||
*,
|
||||
crop_rects: list[tuple[int, int, int, int] | None] | None = None,
|
||||
) -> list[list[TrackObservation]]:
|
||||
if not frames:
|
||||
return []
|
||||
|
||||
sources = []
|
||||
offsets = []
|
||||
for i, frame in enumerate(frames):
|
||||
crop = crop_rects[i] if crop_rects and i < len(crop_rects) else None
|
||||
if crop is not None:
|
||||
x1, y1, x2, y2 = crop
|
||||
sources.append(frame[y1:y2, x1:x2])
|
||||
offsets.append((x1, y1))
|
||||
else:
|
||||
sources.append(frame)
|
||||
offsets.append((0, 0))
|
||||
|
||||
track_kwargs: dict = {
|
||||
"source": sources,
|
||||
"persist": self.config.tracker.persist,
|
||||
"tracker": self.tracker_config_path,
|
||||
"conf": self.config.detection.conf,
|
||||
"iou": self.config.detection.iou,
|
||||
"classes": self.config.detection.classes,
|
||||
"imgsz": self.config.detection.imgsz,
|
||||
"verbose": False,
|
||||
"device": self.config.detection.device,
|
||||
}
|
||||
if self.model_kind != "engine" and self.config.performance.half:
|
||||
track_kwargs["half"] = True
|
||||
|
||||
if self.verbose:
|
||||
t_start = time.monotonic()
|
||||
|
||||
try:
|
||||
results = self.model.track(**track_kwargs)
|
||||
except Exception:
|
||||
results = []
|
||||
for src in sources:
|
||||
kw = dict(track_kwargs)
|
||||
kw["source"] = src
|
||||
res = self.model.track(**kw)
|
||||
if res:
|
||||
results.append(res[0])
|
||||
|
||||
if self.verbose:
|
||||
t_track = time.monotonic()
|
||||
self._infer_count += 1
|
||||
|
||||
batch_tracks: list[list[TrackObservation]] = []
|
||||
for idx, result in enumerate(results):
|
||||
offset_x, offset_y = offsets[idx]
|
||||
crop_rect = crop_rects[idx] if crop_rects and idx < len(crop_rects) else None
|
||||
boxes = result.boxes
|
||||
if boxes is None or boxes.id is None:
|
||||
batch_tracks.append([])
|
||||
continue
|
||||
|
||||
ids = boxes.id.int().cpu().numpy()
|
||||
classes = boxes.cls.int().cpu().numpy()
|
||||
confidences = boxes.conf.cpu().numpy()
|
||||
xyxy = boxes.xyxy.int().cpu().numpy()
|
||||
|
||||
mask_polygons = None
|
||||
if result.masks is not None and result.masks.xy is not None:
|
||||
mask_polygons = result.masks.xy
|
||||
|
||||
tracks: list[TrackObservation] = []
|
||||
for index in range(len(boxes)):
|
||||
track_id = int(ids[index])
|
||||
class_id = int(classes[index])
|
||||
confidence = float(confidences[index])
|
||||
bbox = xyxy[index]
|
||||
x1 = int(bbox[0]) + offset_x
|
||||
y1 = int(bbox[1]) + offset_y
|
||||
x2 = int(bbox[2]) + offset_x
|
||||
y2 = int(bbox[3]) + offset_y
|
||||
centroid = ((x1 + x2) // 2, (y1 + y2) // 2)
|
||||
|
||||
if crop_rect is not None and not self._centroid_in_rect(centroid, crop_rect):
|
||||
continue
|
||||
|
||||
polygon = None
|
||||
if mask_polygons is not None:
|
||||
poly = np.asarray(mask_polygons[index], dtype=np.float64).copy()
|
||||
if poly.ndim == 2 and poly.shape[0] >= 3:
|
||||
poly[:, 0] += offset_x
|
||||
poly[:, 1] += offset_y
|
||||
polygon = poly
|
||||
|
||||
tracks.append(
|
||||
TrackObservation(
|
||||
track_id=track_id,
|
||||
class_id=class_id,
|
||||
confidence=confidence,
|
||||
bbox_xyxy=(x1, y1, x2, y2),
|
||||
centroid=centroid,
|
||||
mask_polygon_xy=polygon,
|
||||
)
|
||||
)
|
||||
batch_tracks.append(tracks)
|
||||
|
||||
return batch_tracks
|
||||
|
||||
@staticmethod
|
||||
def _centroid_in_rect(
|
||||
centroid: tuple[int, int],
|
||||
|
||||
Executable → Regular
File mode changed.
Executable → Regular
+10
-3
@@ -51,14 +51,21 @@ def _try_gstreamer_writer(
|
||||
bitrate_bps: int,
|
||||
) -> cv2.VideoWriter | None:
|
||||
fps_int = max(1, int(round(fps)))
|
||||
pipeline = (
|
||||
pipelines = [
|
||||
# Desktop NVIDIA NVENC hardware encoder
|
||||
f"appsrc ! video/x-raw, format=BGR ! "
|
||||
f"video/x-raw,width={width},height={height},framerate={fps_int}/1 ! "
|
||||
f"videoconvert ! nvh264enc bitrate={bitrate_bps // 1000} ! "
|
||||
f"h264parse ! mp4mux ! filesink location={path}",
|
||||
# Jetson hardware encoder
|
||||
f"appsrc ! video/x-raw, format=BGR ! "
|
||||
f"video/x-raw,width={width},height={height},framerate={fps_int}/1 ! "
|
||||
f"videoconvert ! nvvidconv ! "
|
||||
f"video/x-raw(memory:NVMM),format=NV12 ! "
|
||||
f"nvv4l2h264enc bitrate={bitrate_bps} insert-sps-pps=true ! "
|
||||
f"h264parse ! mp4mux ! filesink location={path}"
|
||||
)
|
||||
f"h264parse ! mp4mux ! filesink location={path}",
|
||||
]
|
||||
for pipeline in pipelines:
|
||||
writer = cv2.VideoWriter(pipeline, cv2.CAP_GSTREAMER, 0, fps, (width, height), True)
|
||||
if writer.isOpened():
|
||||
return writer
|
||||
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
# Portable dashboard launcher — resolves all paths relative to this script's directory.
|
||||
# Works regardless of where the project folder is located or its name.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export PYTHONPATH="$SCRIPT_DIR/src"
|
||||
|
||||
# Use project-local venv if available, otherwise fall back to system python3
|
||||
if [ -f "$SCRIPT_DIR/venv/bin/python" ]; then
|
||||
PYTHON="$SCRIPT_DIR/venv/bin/python"
|
||||
else
|
||||
PYTHON="python3"
|
||||
fi
|
||||
|
||||
DB_PATH="${DB_PATH:-$SCRIPT_DIR/db/chicken_counts.db}"
|
||||
PORT="${PORT:-8080}"
|
||||
DATE_ARG="${DATE_ARG:-}"
|
||||
|
||||
CMD=("$PYTHON" "$SCRIPT_DIR/dashboard.py" --port "$PORT" --db "$DB_PATH")
|
||||
|
||||
# Auto-discover mortality directories (any folder containing mortality_report.json)
|
||||
# Override with MORTALITY_DIRS="dir1,dir2" env variable or let it auto-discover
|
||||
if [ -n "${MORTALITY_DIRS:-}" ]; then
|
||||
IFS=',' read -ra MDIRS <<< "$MORTALITY_DIRS"
|
||||
for mdir in "${MDIRS[@]}"; do
|
||||
CMD+=(--mortality-dir "$mdir")
|
||||
done
|
||||
else
|
||||
# Default: check adjacent VIDEOS folder for any mortality subdirectory
|
||||
DEFAULT_MORTALITY="$SCRIPT_DIR/../VIDEOS/cycle7/kandang-atas/mortality"
|
||||
if [ -d "$DEFAULT_MORTALITY" ]; then
|
||||
CMD+=(--mortality-dir "$(realpath "$DEFAULT_MORTALITY")")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$DATE_ARG" ]; then
|
||||
CMD+=(--date "$DATE_ARG")
|
||||
fi
|
||||
|
||||
echo "[dashboard] Starting at http://0.0.0.0:${PORT} db=${DB_PATH}"
|
||||
exec "${CMD[@]}"
|
||||
+99
-18
@@ -12,7 +12,7 @@ body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:h
|
||||
#top-bar .dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin:0 6px}
|
||||
#top-bar .dot.online{background:#80dc5a;box-shadow:0 0 4px #80dc5a}
|
||||
#top-bar .dot.offline{background:#555}
|
||||
#main{flex:1;display:flex}
|
||||
#main{flex:1;display:flex;overflow:hidden}
|
||||
#frame-area{flex:1;background:#000;display:flex;align-items:center;justify-content:center;position:relative}
|
||||
#frame-area img{max-width:100%;max-height:100%;object-fit:contain}
|
||||
#frame-area .overlay{position:absolute;top:12px;left:12px;pointer-events:none}
|
||||
@@ -20,7 +20,7 @@ body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:h
|
||||
#frame-area .overlay .tag.live{background:#1a3a2a;color:#80dc5a}
|
||||
#frame-area .overlay .tag.off{background:#333;color:#888}
|
||||
#frame-area .overlay .count{font-size:36px;font-weight:900;color:#fff;text-shadow:0 0 16px rgba(0,0,0,.8)}
|
||||
#sidebar{width:280px;background:#12121a;padding:16px;overflow-y:auto;flex-shrink:0;display:flex;flex-direction:column;gap:12px}
|
||||
#sidebar{width:290px;background:#12121a;padding:16px;overflow-y:auto;flex-shrink:0;display:flex;flex-direction:column;gap:12px}
|
||||
#sidebar .stat{padding:12px;background:#16161e;border-radius:8px}
|
||||
#sidebar .stat label{display:block;font-size:9px;color:#555;text-transform:uppercase;letter-spacing:1px;margin-bottom:2px}
|
||||
#sidebar .stat .val{font-size:22px;font-weight:700}
|
||||
@@ -32,10 +32,27 @@ body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:h
|
||||
#sidebar .cam-list .cam-row.act{background:#1a3a2a;color:#80dc5a;font-weight:700}
|
||||
#sidebar .cam-list .cam-row .cam-total{font-size:11px;color:#666}
|
||||
#sidebar .cam-list .cam-row.act .cam-total{color:#5a9a4a}
|
||||
#db-panel{padding:12px;background:#16161e;border-radius:8px;font-size:11px;max-height:200px;overflow-y:auto}
|
||||
#db-panel h3{font-size:10px;color:#555;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px}
|
||||
#db-panel, #mort-panel{padding:12px;background:#16161e;border-radius:8px;font-size:11px}
|
||||
#db-panel h3, #mort-panel h3{font-size:10px;color:#555;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;display:flex;justify-content:space-between;align-items:center}
|
||||
#db-panel .db-row{display:flex;justify-content:space-between;padding:2px 0;color:#888}
|
||||
#db-panel .db-row .db-total{color:#aaa;font-weight:600}
|
||||
|
||||
/* Mortality UI */
|
||||
.mort-badge{background:#2a1518;color:#ff6b81;border:1px solid #ff4757;font-weight:700;padding:2px 6px;border-radius:4px;font-size:10px}
|
||||
.mort-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:6px}
|
||||
.mort-card{background:#0e0e14;border-radius:6px;overflow:hidden;cursor:pointer;border:1px solid #222;transition:all .15s;text-align:left}
|
||||
.mort-card:hover{border-color:#ff4757;transform:translateY(-1px)}
|
||||
.mort-card img{width:100%;height:65px;object-fit:cover;display:block;background:#000}
|
||||
.mort-card .info{padding:4px 6px;display:flex;justify-content:space-between;align-items:center;font-size:10px;color:#888}
|
||||
.mort-card .info .cnt{color:#ff9f43;font-weight:700}
|
||||
|
||||
/* Fullscreen Image Modal */
|
||||
#mort-modal{display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,.88);z-index:999;align-items:center;justify-content:center;flex-direction:column;backdrop-filter:blur(4px)}
|
||||
#mort-modal.active{display:flex}
|
||||
#mort-modal img{max-width:90vw;max-height:85vh;border-radius:8px;border:1px solid #333;box-shadow:0 0 32px rgba(0,0,0,.9);object-fit:contain}
|
||||
#mort-modal .close-btn{position:absolute;top:20px;right:28px;font-size:32px;color:#aaa;cursor:pointer;background:none;border:none;line-height:1;transition:color .15s}
|
||||
#mort-modal .close-btn:hover{color:#fff}
|
||||
#mort-modal .caption{margin-top:12px;font-size:13px;color:#eee;background:#16161e;padding:6px 14px;border-radius:6px;border:1px solid #333}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -60,11 +77,30 @@ body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:h
|
||||
<div class="stat"><label>Frame</label><div class="val" id="s-frame">--</div></div>
|
||||
<div class="stat"><label>Motion</label><div class="val" id="s-speed">--</div></div>
|
||||
<div class="cam-list" id="cam-list"></div>
|
||||
|
||||
<!-- Mortality / Carcass Detection Panel -->
|
||||
<div class="mort-panel" id="mort-panel" style="display:none">
|
||||
<h3>
|
||||
<span>☠️ Mortality Scans</span>
|
||||
<span class="mort-badge" id="mort-header-badge">0</span>
|
||||
</h3>
|
||||
<div id="mort-summary-text" style="color:#777;font-size:10px;margin-bottom:4px"></div>
|
||||
<div class="mort-grid" id="mort-grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="db-panel" id="db-panel">
|
||||
<h3>📊 History</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for high-res mortality preview -->
|
||||
<div id="mort-modal" onclick="closeMortModal(event)">
|
||||
<button class="close-btn" onclick="closeMortModal(event)">×</button>
|
||||
<img id="mort-modal-img" src="" alt="Mortality detection preview" onclick="event.stopPropagation()">
|
||||
<div class="caption" id="mort-modal-caption" onclick="event.stopPropagation()"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var POLL_MS = {{ poll_ms }};
|
||||
var SHM = "{{ shm_dir }}";
|
||||
@@ -80,6 +116,8 @@ function boot() {
|
||||
setInterval(updateClock, 1000);
|
||||
loadHistory();
|
||||
setInterval(loadHistory, 60000);
|
||||
loadMortality();
|
||||
setInterval(loadMortality, 30000);
|
||||
updateClock();
|
||||
}
|
||||
|
||||
@@ -90,7 +128,7 @@ function loadCameras() {
|
||||
if (cameras.length && !activeCam) selectCam(cameras[0]);
|
||||
detectActive();
|
||||
if (!cameras.length) showWaiting("no cameras detected");
|
||||
});
|
||||
}).catch(function() {});
|
||||
updateSidebar();
|
||||
}
|
||||
|
||||
@@ -106,7 +144,7 @@ function detectActive() {
|
||||
lastFramePerCam[cam] = s.frame_index;
|
||||
if (activeCam !== cam) selectCam(cam);
|
||||
}
|
||||
}).finally(function() { if (pending === 0) renderCamList(); });
|
||||
}).catch(function() {}).finally(function() { if (pending === 0) renderCamList(); });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -132,13 +170,13 @@ function poll() {
|
||||
document.getElementById("s-inside").textContent = s.inside_box_count;
|
||||
document.getElementById("s-tracks").textContent = s.track_count;
|
||||
document.getElementById("s-frame").textContent = s.frame_index;
|
||||
document.getElementById("s-speed").textContent = s.smoothed_speed.toFixed(1);
|
||||
document.getElementById("s-speed").textContent = Number(s.smoothed_speed).toFixed(1);
|
||||
var tag = document.getElementById("cam-tag");
|
||||
var status = s.backward_active ? "BACKWARD STOP" : "RUNNING";
|
||||
tag.textContent = activeCam + " \u2022 " + status;
|
||||
tag.className = "tag " + (s.backward_active ? "off" : "live");
|
||||
document.getElementById("cam-count").textContent = s.total_entered_count;
|
||||
});
|
||||
}).catch(function() { setOffline(); });
|
||||
}
|
||||
|
||||
function refreshNow() {
|
||||
@@ -153,8 +191,11 @@ function showWaiting(msg) {
|
||||
}
|
||||
|
||||
function setOffline() {
|
||||
document.getElementById("cam-tag").textContent = activeCam + " \u2022 OFFLINE";
|
||||
document.getElementById("cam-tag").className = "tag off";
|
||||
var tag = document.getElementById("cam-tag");
|
||||
if (tag && activeCam) {
|
||||
tag.textContent = activeCam + " \u2022 OFFLINE";
|
||||
tag.className = "tag off";
|
||||
}
|
||||
}
|
||||
|
||||
function updateSidebar() {}
|
||||
@@ -179,19 +220,17 @@ function loadHistory() {
|
||||
var panel = document.getElementById("db-panel");
|
||||
panel.style.display = "block";
|
||||
|
||||
// per-day totals
|
||||
fetch("/api/db/history").then(r => r.json()).then(rows => {
|
||||
var html = "<h3>📊 History</h3>";
|
||||
if (rows.length) {
|
||||
if (rows && rows.length) {
|
||||
html += rows.map(function(r) {
|
||||
return '<div class="db-row"><span>' + r.date + ' ' + r.location + '</span>' +
|
||||
'<span class="db-total">' + r.total.toLocaleString() + '</span></div>';
|
||||
return '<div class="db-row"><span>' + r.date + ' ' + (r.location || '') + '</span>' +
|
||||
'<span class="db-total">' + Number(r.total || 0).toLocaleString() + '</span></div>';
|
||||
}).join("");
|
||||
}
|
||||
panel.innerHTML = html;
|
||||
});
|
||||
}).catch(function() {});
|
||||
|
||||
// per-camera breakdown for current date
|
||||
if (RUN_DATE && RUN_DATE !== "today") {
|
||||
fetch("/api/db/date/" + RUN_DATE).then(r => r.json()).then(data => {
|
||||
if (!data.cameras || !data.cameras.length) return;
|
||||
@@ -199,13 +238,55 @@ function loadHistory() {
|
||||
html += '<h3 style="margin-top:12px">📹 ' + RUN_DATE + ' (' + (data.total.total || 0).toLocaleString() + ')</h3>';
|
||||
data.cameras.forEach(function(c) {
|
||||
html += '<div class="db-row"><span>' + c.camera_id + '</span>' +
|
||||
'<span class="db-total">' + c.total_entered.toLocaleString() + '</span></div>';
|
||||
'<span class="db-total">' + Number(c.total_entered || 0).toLocaleString() + '</span></div>';
|
||||
});
|
||||
panel.innerHTML = html;
|
||||
});
|
||||
}).catch(function() {});
|
||||
}
|
||||
}
|
||||
|
||||
function loadMortality() {
|
||||
fetch("/api/mortality/latest").then(r => r.ok ? r.json() : null).then(data => {
|
||||
var panel = document.getElementById("mort-panel");
|
||||
if (!data || data.error || !data.results || !data.results.length) {
|
||||
panel.style.display = "none";
|
||||
return;
|
||||
}
|
||||
panel.style.display = "block";
|
||||
var totalCarcasses = data.total_mortality_count !== undefined ? data.total_mortality_count : data.results.reduce((acc, x) => acc + (x.count || 0), 0);
|
||||
document.getElementById("mort-header-badge").textContent = totalCarcasses + " detected";
|
||||
document.getElementById("mort-summary-text").textContent = (data.date ? data.date + " \u2022 " : "") + data.results.length + " photo(s) scanned";
|
||||
|
||||
var grid = document.getElementById("mort-grid");
|
||||
grid.innerHTML = data.results.map(function(item) {
|
||||
var imgSrc = "/api/mortality/image/" + encodeURIComponent(item.output_image || item.input_image);
|
||||
var title = (item.output_image || item.input_image || "scan").replace(/^output_/, "");
|
||||
var count = item.count !== undefined ? item.count : (item.detections ? item.detections.length : 0);
|
||||
return '<div class="mort-card" onclick="openMortModal(\'' + imgSrc + '\', \'' + title + ' (' + count + ' carcasses)\')">' +
|
||||
'<img src="' + imgSrc + '" alt="' + title + '" loading="lazy" onerror="this.style.opacity=0.3">' +
|
||||
'<div class="info">' +
|
||||
'<span style="max-width:70px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + title + '">' + title + '</span>' +
|
||||
'<span class="cnt">' + count + ' 💀</span>' +
|
||||
'</div></div>';
|
||||
}).join("");
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
||||
function openMortModal(src, caption) {
|
||||
var modal = document.getElementById("mort-modal");
|
||||
document.getElementById("mort-modal-img").src = src;
|
||||
document.getElementById("mort-modal-caption").textContent = caption || "";
|
||||
modal.classList.add("active");
|
||||
}
|
||||
|
||||
function closeMortModal(event) {
|
||||
document.getElementById("mort-modal").classList.remove("active");
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", function(e) {
|
||||
if (e.key === "Escape") closeMortModal();
|
||||
});
|
||||
|
||||
setTimeout(function() { if (activeCam) refreshNow(); }, 500);
|
||||
boot();
|
||||
</script>
|
||||
|
||||
Executable → Regular
+1
-1
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
alias chicken-counter='PYTHONPATH=/media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/src /media/jetson/DATA/karung-sukawarna/venv/bin/python -m chicken_counter.cli'
|
||||
alias chicken-counter='PYTHONPATH=/home/asus/.Codes/chicken-counting-sukawarna-det/src python3 -m chicken_counter.cli'
|
||||
|
||||
# Declare the array
|
||||
my_array=("2026-06-10" "2026-06-11" "2026-06-12" "2026-06-13" "2026-06-14" "2026-06-15" "2026-06-16" "2026-06-17" "2026-06-18" "2026-06-19")
|
||||
|
||||
+23
-5
@@ -1,15 +1,33 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
set -u
|
||||
|
||||
export PYTHONPATH=/media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/src
|
||||
PYTHON=/media/jetson/DATA/karung-sukawarna/venv/bin/python
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export PYTHONPATH="$SCRIPT_DIR/src"
|
||||
# Use project-local venv if available
|
||||
if [ -f "$SCRIPT_DIR/venv/bin/python" ]; then
|
||||
PYTHON="$SCRIPT_DIR/venv/bin/python"
|
||||
else
|
||||
PYTHON="python3"
|
||||
fi
|
||||
|
||||
dates=(
|
||||
"2026-06-10" "2026-06-11" "2026-06-12" "2026-06-13" "2026-06-14"
|
||||
"2026-06-15" "2026-06-16" "2026-06-17" "2026-06-18" "2026-06-19"
|
||||
)
|
||||
cameras=("CC1" "CC2" "CC3" "CC4")
|
||||
|
||||
# Maximum parallel video jobs (default: 4)
|
||||
MAX_JOBS="${MAX_JOBS:-4}"
|
||||
echo "=== Starting per-video parallel processing with MAX_JOBS=$MAX_JOBS ==="
|
||||
|
||||
for date in "${dates[@]}"; do
|
||||
echo "=== Processing $date ==="
|
||||
$PYTHON -m chicken_counter.cli batch --config configs/cycle7_batch.yaml --date "$date" --no-video
|
||||
echo "=== Starting batch for date: $date ==="
|
||||
for cam in "${cameras[@]}"; do
|
||||
echo "=== Launching $date ($cam) ==="
|
||||
$PYTHON -m chicken_counter.cli batch --config configs/cycle7_batch_optimized.yaml --date "$date" --camera "$cam" --no-video &
|
||||
done
|
||||
wait || true
|
||||
echo "=== Date $date completed ==="
|
||||
done
|
||||
|
||||
echo "=== All video processing jobs completed ==="
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export PYTHONPATH="$SCRIPT_DIR/src"
|
||||
|
||||
# Use project-local venv if available
|
||||
if [ -f "$SCRIPT_DIR/venv/bin/python" ]; then
|
||||
PYTHON="$SCRIPT_DIR/venv/bin/python"
|
||||
else
|
||||
PYTHON="python3"
|
||||
fi
|
||||
|
||||
# Allow passing a specific date as $1 (e.g. ./test_run_hybrid.sh 2026-06-18)
|
||||
# Or default to all dates 2026-06-10 through 2026-06-19
|
||||
if [ -n "${1:-}" ]; then
|
||||
dates=("$1")
|
||||
else
|
||||
dates=(
|
||||
"2026-06-10" "2026-06-11" "2026-06-12" "2026-06-13" "2026-06-14"
|
||||
"2026-06-15" "2026-06-16" "2026-06-17" "2026-06-18" "2026-06-19"
|
||||
)
|
||||
fi
|
||||
|
||||
echo "=== Starting Hybrid Execution Mode (Threaded CPU + Batched GPU) ==="
|
||||
|
||||
for date in "${dates[@]}"; do
|
||||
echo "=== Running hybrid batch for date: $date ==="
|
||||
$PYTHON -m chicken_counter.cli batch \
|
||||
--config configs/cycle7_batch_optimized.yaml \
|
||||
--date "$date" \
|
||||
--mode hybrid \
|
||||
--no-video
|
||||
done
|
||||
|
||||
echo "=== All hybrid batch processing completed ==="
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
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
|
||||
|
||||
CONFIG_FILE="${CONFIG_FILE:-configs/mortality_config.yaml}"
|
||||
INPUT_PATH="${1:-}"
|
||||
OUTPUT_DIR="${2:-}"
|
||||
|
||||
echo "=== Starting Whole-Image Mortality Chicken Detection ==="
|
||||
echo "Config File: $CONFIG_FILE"
|
||||
if [ -n "$INPUT_PATH" ]; then
|
||||
echo "Input Path Override: $INPUT_PATH"
|
||||
fi
|
||||
if [ -n "$OUTPUT_DIR" ]; then
|
||||
echo "Output Dir Override: $OUTPUT_DIR"
|
||||
fi
|
||||
|
||||
CMD=($PYTHON -m chicken_counter.cli mortality --config "$CONFIG_FILE")
|
||||
|
||||
if [ -n "$INPUT_PATH" ]; then
|
||||
CMD+=(--input "$INPUT_PATH")
|
||||
fi
|
||||
if [ -n "$OUTPUT_DIR" ]; then
|
||||
CMD+=(--output-dir "$OUTPUT_DIR")
|
||||
fi
|
||||
if [ -n "${CONF:-}" ]; then
|
||||
CMD+=(--conf "$CONF")
|
||||
fi
|
||||
if [ -n "${IOU:-}" ]; then
|
||||
CMD+=(--iou "$IOU")
|
||||
fi
|
||||
if [ -n "${MIN_AREA:-}" ]; then
|
||||
CMD+=(--min-area "$MIN_AREA")
|
||||
fi
|
||||
if [ -n "${DEDUPE_RADIUS:-}" ]; then
|
||||
CMD+=(--dedupe-radius "$DEDUPE_RADIUS")
|
||||
fi
|
||||
|
||||
"${CMD[@]}"
|
||||
|
||||
echo "=== Mortality Detection Completed ==="
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export PYTHONPATH="$SCRIPT_DIR/src"
|
||||
|
||||
# Use project-local venv if available
|
||||
if [ -f "$SCRIPT_DIR/venv/bin/python" ]; then
|
||||
PYTHON="$SCRIPT_DIR/venv/bin/python"
|
||||
else
|
||||
PYTHON="python3"
|
||||
fi
|
||||
|
||||
# Allow passing a specific date as $1 (e.g. ./test_run_tensor_batch.sh 2026-06-18)
|
||||
# Or default to all dates 2026-06-10 through 2026-06-19
|
||||
if [ -n "${1:-}" ]; then
|
||||
dates=("$1")
|
||||
else
|
||||
dates=(
|
||||
"2026-06-10" "2026-06-11" "2026-06-12" "2026-06-13" "2026-06-14"
|
||||
"2026-06-15" "2026-06-16" "2026-06-17" "2026-06-18" "2026-06-19"
|
||||
)
|
||||
fi
|
||||
|
||||
echo "=== Starting Tensor Batching processing ==="
|
||||
|
||||
for date in "${dates[@]}"; do
|
||||
echo "=== Running tensor batch for date: $date ==="
|
||||
$PYTHON -m chicken_counter.cli batch \
|
||||
--config configs/cycle7_batch_optimized.yaml \
|
||||
--date "$date" \
|
||||
--mode tensor_batching \
|
||||
--no-video
|
||||
done
|
||||
|
||||
echo "=== All tensor batch processing completed ==="
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export PYTHONPATH="$SCRIPT_DIR/src"
|
||||
|
||||
# Use project-local venv if available
|
||||
if [ -f "$SCRIPT_DIR/venv/bin/python" ]; then
|
||||
PYTHON="$SCRIPT_DIR/venv/bin/python"
|
||||
else
|
||||
PYTHON="python3"
|
||||
fi
|
||||
|
||||
dates=(
|
||||
"2026-05-23"
|
||||
)
|
||||
cameras=("CC1" "CC2" "CC3" "CC4")
|
||||
|
||||
# Maximum parallel video jobs (default: 4)
|
||||
MAX_JOBS="${MAX_JOBS:-4}"
|
||||
echo "=== Starting per-video parallel processing with video output for 2026-05-23 (MAX_JOBS=$MAX_JOBS) ==="
|
||||
|
||||
for date in "${dates[@]}"; do
|
||||
echo "=== Starting batch for date: $date ==="
|
||||
for cam in "${cameras[@]}"; do
|
||||
echo "=== Launching $date ($cam) ==="
|
||||
$PYTHON -m chicken_counter.cli batch --config configs/cycle7_batch_optimized.yaml --date "$date" --camera "$cam" --output-subdir output_video &
|
||||
done
|
||||
wait || true
|
||||
echo "=== Date $date completed ==="
|
||||
done
|
||||
|
||||
echo "=== All video processing jobs completed ==="
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export PYTHONPATH="$SCRIPT_DIR/src"
|
||||
PYTHON="python3"
|
||||
|
||||
dates=(
|
||||
"2026-05-24"
|
||||
)
|
||||
cameras=("CC1" "CC2" "CC3" "CC4")
|
||||
|
||||
# Maximum parallel video jobs (default: 4)
|
||||
MAX_JOBS="${MAX_JOBS:-4}"
|
||||
echo "=== Starting per-video parallel processing with video output for 2026-05-24 (MAX_JOBS=$MAX_JOBS) ==="
|
||||
|
||||
for date in "${dates[@]}"; do
|
||||
for cam in "${cameras[@]}"; do
|
||||
echo "=== Launching $date ($cam) ==="
|
||||
$PYTHON -m chicken_counter.cli batch --config configs/cycle7_batch_optimized.yaml --date "$date" --camera "$cam" --output-subdir output_video &
|
||||
|
||||
# Throttle active background jobs to MAX_JOBS
|
||||
while [ "$(jobs -r -p | wc -l)" -ge "$MAX_JOBS" ]; do
|
||||
sleep 1
|
||||
done
|
||||
done
|
||||
done
|
||||
|
||||
wait
|
||||
echo "=== All video processing jobs completed ==="
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export PYTHONPATH="$SCRIPT_DIR/src"
|
||||
|
||||
# Use project-local venv if available
|
||||
if [ -f "$SCRIPT_DIR/venv/bin/python" ]; then
|
||||
PYTHON="$SCRIPT_DIR/venv/bin/python"
|
||||
else
|
||||
PYTHON="python3"
|
||||
fi
|
||||
|
||||
dates=(
|
||||
"2026-06-06"
|
||||
)
|
||||
cameras=("CC1" "CC2" "CC3" "CC4")
|
||||
|
||||
# Maximum parallel video jobs (default: 4)
|
||||
MAX_JOBS="${MAX_JOBS:-4}"
|
||||
echo "=== Starting per-video parallel processing with video output for 2026-06-06 (MAX_JOBS=$MAX_JOBS) ==="
|
||||
|
||||
for date in "${dates[@]}"; do
|
||||
for cam in "${cameras[@]}"; do
|
||||
echo "=== Launching $date ($cam) ==="
|
||||
$PYTHON -m chicken_counter.cli batch --config configs/cycle7_batch_optimized.yaml --date "$date" --camera "$cam" --output-subdir output_video &
|
||||
|
||||
# Throttle active background jobs to MAX_JOBS
|
||||
while [ "$(jobs -r -p | wc -l)" -ge "$MAX_JOBS" ]; do
|
||||
sleep 1
|
||||
done
|
||||
done
|
||||
done
|
||||
|
||||
wait
|
||||
echo "=== All video processing jobs completed ==="
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export PYTHONPATH="$SCRIPT_DIR/src"
|
||||
PYTHON="python3"
|
||||
|
||||
dates=(
|
||||
"2026-06-16"
|
||||
)
|
||||
cameras=("CC1" "CC2" "CC3" "CC4")
|
||||
|
||||
# Maximum parallel video jobs (default: 4)
|
||||
MAX_JOBS="${MAX_JOBS:-4}"
|
||||
echo "=== Starting per-video parallel processing with video output for 2026-06-16 (MAX_JOBS=$MAX_JOBS) ==="
|
||||
|
||||
for date in "${dates[@]}"; do
|
||||
for cam in "${cameras[@]}"; do
|
||||
echo "=== Launching $date ($cam) ==="
|
||||
$PYTHON -m chicken_counter.cli batch --config configs/cycle7_batch_optimized.yaml --date "$date" --camera "$cam" --output-subdir output_video &
|
||||
|
||||
# Throttle active background jobs to MAX_JOBS
|
||||
while [ "$(jobs -r -p | wc -l)" -ge "$MAX_JOBS" ]; do
|
||||
sleep 1
|
||||
done
|
||||
done
|
||||
done
|
||||
|
||||
wait
|
||||
echo "=== All video processing jobs completed ==="
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export PYTHONPATH="$SCRIPT_DIR/src"
|
||||
PYTHON="python3"
|
||||
|
||||
dates=(
|
||||
"2026-06-18"
|
||||
)
|
||||
cameras=("CC1" "CC2" "CC3" "CC4")
|
||||
|
||||
# Maximum parallel video jobs (default: 4)
|
||||
MAX_JOBS="${MAX_JOBS:-4}"
|
||||
echo "=== Starting per-video parallel processing with video output for 2026-06-18 (MAX_JOBS=$MAX_JOBS) ==="
|
||||
|
||||
for date in "${dates[@]}"; do
|
||||
for cam in "${cameras[@]}"; do
|
||||
echo "=== Launching $date ($cam) ==="
|
||||
$PYTHON -m chicken_counter.cli batch --config configs/cycle7_batch_optimized.yaml --date "$date" --camera "$cam" --output-subdir output_video &
|
||||
|
||||
# Throttle active background jobs to MAX_JOBS
|
||||
while [ "$(jobs -r -p | wc -l)" -ge "$MAX_JOBS" ]; do
|
||||
sleep 1
|
||||
done
|
||||
done
|
||||
done
|
||||
|
||||
wait
|
||||
echo "=== All video processing jobs completed ==="
|
||||
Binary file not shown.
@@ -0,0 +1,112 @@
|
||||
"""Tests for CountingZone validation and double-count suppression."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# counting.py imports cv2; stub it so unit tests run without OpenCV installed.
|
||||
if "cv2" not in sys.modules:
|
||||
cv2_stub = MagicMock()
|
||||
cv2_stub.pointPolygonTest = MagicMock(return_value=1.0)
|
||||
sys.modules["cv2"] = cv2_stub
|
||||
|
||||
import numpy as np
|
||||
|
||||
from chicken_counter.config import GateConfig, RoiConfig
|
||||
from chicken_counter.counting import CountingZone
|
||||
from chicken_counter.types import TrackObservation
|
||||
|
||||
|
||||
def _zone(**kwargs) -> CountingZone:
|
||||
defaults = dict(
|
||||
roi=RoiConfig(
|
||||
points=[(0, 0), (200, 0), (200, 200), (0, 200)],
|
||||
min_overlap_ratio=0.0,
|
||||
),
|
||||
gate=GateConfig(),
|
||||
trail_length=10,
|
||||
track_buffer=75,
|
||||
min_box_area_px=0,
|
||||
validate_while_inside=True,
|
||||
dedupe_radius_px=64,
|
||||
dedupe_frames=40,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return CountingZone(**defaults)
|
||||
|
||||
|
||||
def _track(
|
||||
track_id: int,
|
||||
centroid: tuple[int, int] = (100, 100),
|
||||
bbox: tuple[int, int, int, int] | None = None,
|
||||
) -> TrackObservation:
|
||||
cx, cy = centroid
|
||||
if bbox is None:
|
||||
bbox = (cx - 20, cy - 20, cx + 20, cy + 20)
|
||||
return TrackObservation(
|
||||
track_id=track_id,
|
||||
class_id=0,
|
||||
confidence=0.9,
|
||||
bbox_xyxy=bbox,
|
||||
centroid=centroid,
|
||||
)
|
||||
|
||||
|
||||
class CountingDedupeTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
# Treat every centroid as inside the ROI polygon for these unit tests.
|
||||
sys.modules["cv2"].pointPolygonTest = MagicMock(return_value=1.0)
|
||||
|
||||
def test_counts_unique_track_once(self) -> None:
|
||||
zone = _zone()
|
||||
events = zone.update([_track(1)], frame_index=1)
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertEqual(zone.total_entered_count, 1)
|
||||
|
||||
events = zone.update([_track(1)], frame_index=2)
|
||||
self.assertEqual(len(events), 0)
|
||||
self.assertEqual(zone.total_entered_count, 1)
|
||||
|
||||
def test_suppresses_nearby_id_switch_double_count(self) -> None:
|
||||
zone = _zone(dedupe_radius_px=64, dedupe_frames=40)
|
||||
events = zone.update([_track(1, (100, 100))], frame_index=10)
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertEqual(zone.total_entered_count, 1)
|
||||
|
||||
# New track ID near the prior count should reuse sequence, not increment.
|
||||
events = zone.update([_track(2, (110, 105))], frame_index=20)
|
||||
self.assertEqual(len(events), 0)
|
||||
self.assertEqual(zone.total_entered_count, 1)
|
||||
self.assertTrue(zone.is_validated(2))
|
||||
self.assertEqual(zone.sequence_number_for(2), 1)
|
||||
|
||||
def test_allows_distant_second_bird(self) -> None:
|
||||
zone = _zone(dedupe_radius_px=64, dedupe_frames=40)
|
||||
zone.update([_track(1, (40, 40))], frame_index=10)
|
||||
events = zone.update([_track(2, (160, 160))], frame_index=15)
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertEqual(zone.total_entered_count, 2)
|
||||
|
||||
def test_allows_recount_after_dedupe_window(self) -> None:
|
||||
zone = _zone(dedupe_radius_px=64, dedupe_frames=10)
|
||||
zone.update([_track(1, (100, 100))], frame_index=10)
|
||||
events = zone.update([_track(2, (100, 100))], frame_index=30)
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertEqual(zone.total_entered_count, 2)
|
||||
|
||||
def test_dedupe_disabled_when_radius_zero(self) -> None:
|
||||
zone = _zone(dedupe_radius_px=0, dedupe_frames=40)
|
||||
zone.update([_track(1, (100, 100))], frame_index=10)
|
||||
events = zone.update([_track(2, (100, 100))], frame_index=12)
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertEqual(zone.total_entered_count, 2)
|
||||
|
||||
def test_counting_polygon_is_numpy_array(self) -> None:
|
||||
zone = _zone()
|
||||
self.assertIsInstance(zone._counting_polygon, np.ndarray)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Tests for mask polygon handling in DetectionTracker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from chicken_counter.types import TrackObservation
|
||||
|
||||
|
||||
class TrackObservationMaskTests(unittest.TestCase):
|
||||
def test_track_observation_accepts_mask_polygon(self) -> None:
|
||||
poly = np.array([[10.0, 20.0], [30.0, 20.0], [30.0, 40.0]], dtype=np.float64)
|
||||
track = TrackObservation(
|
||||
track_id=1,
|
||||
class_id=0,
|
||||
confidence=0.9,
|
||||
bbox_xyxy=(10, 20, 30, 40),
|
||||
centroid=(20, 30),
|
||||
mask_polygon_xy=poly,
|
||||
)
|
||||
self.assertIsNotNone(track.mask_polygon_xy)
|
||||
self.assertEqual(track.mask_polygon_xy.shape, (3, 2))
|
||||
|
||||
def test_crop_offset_translation_pattern(self) -> None:
|
||||
"""Mirrors tracking.infer crop offset applied to mask polygons."""
|
||||
offset_x, offset_y = 100, 50
|
||||
local = np.array([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], dtype=np.float64)
|
||||
full = local.copy()
|
||||
full[:, 0] += offset_x
|
||||
full[:, 1] += offset_y
|
||||
self.assertAlmostEqual(float(full[0, 0]), 100.0)
|
||||
self.assertAlmostEqual(float(full[0, 1]), 50.0)
|
||||
self.assertAlmostEqual(float(full[2, 0]), 110.0)
|
||||
self.assertAlmostEqual(float(full[2, 1]), 60.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user