Python optimize [Multi-coop multi-floor discovery, Auto .engine compilation, API, mortality webapp trigger, parallel processing/tensor batching/hybrid, etc.] #3

Merged
zakaria merged 39 commits from andrew/chicken-counting-sukawarna-det:python-optimize into python-optimize 2026-08-20 11:00:31 +07:00
111 changed files with 81045 additions and 651 deletions

No files matched your search

+231
View File
@@ -0,0 +1,231 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
*.lcov
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
# Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
# poetry.lock
# poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
# pdm.lock
# pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
# pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi/*
!.pixi/config.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule*
celerybeat.pid
# Redis
*.rdb
*.aof
*.pid
# RabbitMQ
mnesia/
rabbitmq/
rabbitmq-data/
# ActiveMQ
activemq-data/
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
# .idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Temporary file for partial code execution
tempCodeRunnerFile.py
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Streamlit
.streamlit/secrets.toml
# Project local / runtime outputs
runs/
output/
*.log
*.db-shm
*.db-wal
*.2026*
.vscode/
configs/config_backup_folder/
configs/cycle7_batch.yaml.*
+73611
View File
File diff suppressed because it is too large. Load diff
+406
View File
@@ -0,0 +1,406 @@
# Chicken Counter API
Base URL: `http://<jetson-ip>:8080`
## System Status & Live Monitoring
### `GET /api/status`
Returns real-time pipeline activity status, active streaming cameras, latest processed date, and all-time total counts.
```json
{
"status": "running",
"is_counting_active": true,
"active_cameras": ["CC1", "CC2", "CC3", "CC4"],
"latest_counted_date": "2026-06-18",
"total_chickens_all_time": 146418,
"cycle_start_date": "2026-05-22",
"timestamp": "2026-08-14T08:30:00.000000+00:00"
}
```
### `GET /`
Returns the dashboard HTML page.
### `GET /api/cameras`
List cameras currently writing to `/dev/shm`.
```json
{"cameras": ["CC1", "CC2", "CC3"]}
```
### `GET /shm/<camera_id>/stats.json`
Live stats for the active pipeline.
```json
{
"frame_index": 5120,
"inside_box_count": 42,
"total_entered_count": 1858,
"track_count": 99,
"backward_active": false,
"smoothed_speed": 4.3,
"count_events": 0,
"run_date": "2026-06-10"
}
```
### `GET /shm/<camera_id>/frame.jpg`
Live JPEG frame from the active pipeline.
---
## Database
All endpoints require the dashboard to be started with `--db <path>`. If no DB exists, endpoints return `[]` or `{}`.
### `GET /api/config/cycle_start_date`
Returns or updates the active Day 0 (`cycle_start_date`).
```bash
# Query active Day 0
GET /api/config/cycle_start_date
→ {"cycle_start_date": "2026-05-22"}
# Override Day 0 dynamically via query param or POST payload
GET /api/config/cycle_start_date?set=2026-05-22
POST /api/config/cycle_start_date {"cycle_start_date": "2026-05-22"}
→ {"status": "ok", "cycle_start_date": "2026-05-22"}
```
### `GET /api/db/summary`
Overall totals across all dates and locations.
```json
{
"days": 12,
"locations": 2,
"total_runs": 48,
"total_chickens": 125000,
"total_hours": 8.5
}
```
### `GET /api/db/history`
Per-date summary, newest first (max 50 rows).
```json
[
{
"date": "2026-06-10",
"location": "kandang-atas",
"cams": 4,
"total": 5570,
"minutes": 40.2
}
]
```
### `GET /api/db/date/<date>`
Detail for a specific date. Format: `YYYY-MM-DD`.
```json
{
"date": "2026-06-10",
"total": {"total": 5570, "minutes": 40.2},
"cameras": [
{
"camera_id": "CC1",
"total_entered": 1500,
"frames_processed": 24800,
"elapsed_seconds": 600.5,
"stopped_reason": "backward",
"source_video": "kandang_1_camera_1_2026-06-10_120056.mp4",
"location": "kandang-atas"
}
]
}
```
### `GET /api/db/camera/<camera_id>`
History for a specific camera across all dates (max 50 rows).
```json
[
{
"date": "2026-06-10",
"location": "kandang-atas",
"total_entered": 1500,
"frames_processed": 24800,
"elapsed_seconds": 600.5,
"stopped_reason": "backward"
}
]
```
### `GET /api/db/location/<location>`
Summary and history for a specific location.
```json
{
"location": "kandang-atas",
"summary": {"days": 5, "total": 25000, "hours": 3.2},
"history": [
{
"date": "2026-06-10",
"cameras": "CC1, CC2, CC3, CC4",
"total": 5570,
"minutes": 40.2
}
]
}
```
---
## Database Schema
```sql
CREATE TABLE batch_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
location TEXT NOT NULL,
camera_id TEXT NOT NULL,
total_entered INTEGER NOT NULL DEFAULT 0,
frames_processed INTEGER NOT NULL DEFAULT 0,
elapsed_seconds REAL NOT NULL DEFAULT 0.0,
stopped_reason TEXT NOT NULL DEFAULT '',
source_video TEXT NOT NULL DEFAULT '',
generated_at TEXT NOT NULL DEFAULT '',
UNIQUE(date, location, camera_id)
);
```
Data is inserted automatically by the batch runner when `location` and `db_path` are configured in the batch YAML, or manually via:
```bash
python3 store_results.py output/counts_2026-06-10.json --location kandang-atas --db chicken_counts.db
```
---
## Mortality Detection
Mortality endpoints require the dashboard to be started with `--mortality-dir <path>`. The path must contain a `mortality_report.json` file generated by `./test_run_mortality.sh`. Multiple directories can be registered with repeated `--mortality-dir` flags.
If multiple images are captured in a single day (e.g. morning and afternoon scans), the pipeline processes all images in the input directory, generates marked output JPEGs (`output_<name>.jpg`), and aggregates the daily total carcass count into `total_mortality_count`.
### `GET /api/mortality/coops`
Returns a summary of all detected coops with their covered floors and mortality figures:
```json
[
{
"coop": "K1",
"covered_floors": ["K1-L1", "K1-L2", "K1-L3"],
"total_carcasses": 12,
"total_scans": 3,
"latest_date": "2026-06-18",
"latest_count": 4
},
{
"coop": "K2",
"covered_floors": ["K2-L1", "K2-L2", "K2-L3"],
"total_carcasses": 8,
"total_scans": 2,
"latest_date": "2026-06-18",
"latest_count": 8
}
]
```
### `GET /api/mortality/coop/<coop_id>`
Returns all mortality reports recorded for a specific coop (e.g. `GET /api/mortality/coop/K1`).
### `GET /api/mortality/latest`
Returns the most recent `mortality_report.json` across all coop directories, enriched with `coop` and `covered_floors`:
```json
{
"date": "2026-06-18",
"coop": "K1",
"location": "K1",
"covered_floors": ["K1-L1", "K1-L2", "K1-L3"],
"total_mortality_count": 19,
"total_images": 1,
"_dir": "/path/to/VIDEOS/cycle7/K1/mortality",
"results": [...]
}
```
### `GET /api/mortality/history`
Returns all `mortality_report.json` files from all registered coop directories, sorted newest first. Each report contains `coop`, `covered_floors`, `total_mortality_count`, 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-06-18",
"total_mortality_count": 35,
"total_images": 2,
"reports": [...],
"results": [...]
}
```
### `POST /api/mortality/capture`
Snaps a high-resolution still frame directly from the dedicated stationary inspection camera for a specific coop, runs YOLO carcass detection, saves the image into `VIDEOS/cycle7/<coop>/mortality/<date>/capture_XX_<timestamp>.jpg`, and returns the detection count.
**Content-Type**: `application/json` or `multipart/form-data` or Query Parameters
**Request Parameters**:
* `coop` (string, required): Coop identifier (e.g. `K1`, `K2`, `kandang-atas`).
* `date` (string, optional): Date in `YYYY-MM-DD` format (defaults to today).
* `source` (string, optional): Override camera RTSP stream / device URL. If omitted, uses the configured camera in `configs/mortality_config.yaml`.
* `conf` (float, optional): Confidence threshold override.
**Response (`200 OK`)**:
```json
{
"status": "success",
"message": "Captured photo #2 from K1 camera (12 carcasses)",
"coop": "K1",
"date": "2026-08-20",
"camera_source": "rtsp://admin:admin@192.168.1.101:554/live",
"captured_file": "capture_02_20260820_103000.jpg",
"batch_count": 12,
"total_images": 2,
"total_mortality_count": 30,
"results": [...]
}
```
### `GET /api/mortality/camera/preview?coop=<coop_id>`
Returns a live JPEG snapshot frame from that coop's stationary camera so workers can verify chicken positioning before capturing.
```
GET /api/mortality/camera/preview?coop=K1
→ Content-Type: image/jpeg
```
### `GET /api/mortality/cameras`
Returns the map of all registered stationary cameras per coop.
```json
{
"cameras": {
"K1": "rtsp://admin:admin@192.168.1.101:554/live",
"K2": "rtsp://admin:admin@192.168.1.102:554/live",
"K3": "rtsp://admin:admin@192.168.1.103:554/live",
"K4": "rtsp://admin:admin@192.168.1.104:554/live",
"K5": "rtsp://admin:admin@192.168.1.105:554/live"
}
}
```
### `POST /api/mortality/upload`
Uploads one or more mortality photos from disk for a specific coop, runs YOLO carcass detection immediately, saves results into `VIDEOS/cycle7/<coop>/mortality/<date>/`, and returns detection results.
**Content-Type**: `multipart/form-data`
**Form Fields**:
* `coop` (string, required): Coop identifier (e.g. `K1`, `K2`, `kandang-atas`).
* `date` (string, optional): Date in `YYYY-MM-DD` format (defaults to current date).
* `conf` (float, optional): Confidence threshold override (e.g. `0.7`).
* `two_pass` (boolean, optional): Set `true` for 2-pass refinement.
* `images` (file/binary, repeatable): 1 or more image files (`.jpg`, `.jpeg`, `.png`).
**Response (`200 OK`)**:
```json
{
"status": "success",
"message": "Processed 2 image(s) for K1",
"coop": "K1",
"date": "2026-08-20",
"total_images": 2,
"total_mortality_count": 29,
"results": [
{
"input_image": "batch_1.jpg",
"output_image": "output_batch_1.jpg",
"count": 19,
"output_path": "/path/to/VIDEOS/cycle7/K1/mortality/2026-08-20/output_batch_1.jpg",
"detections": [...]
}
]
}
```
### `POST /api/mortality/scan`
Triggers immediate re-scanning of an existing coop directory:
**Request Body (`application/json`)**:
```json
{
"coop": "K1",
"date": "2026-08-20"
}
```
### `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
```
+64
View File
@@ -0,0 +1,64 @@
# Changelog
All notable changes to the `chicken-counting-sukawarna-det` project are documented in this file.
## [Unreleased] - 2026-08-20
### 🚀 Features & Architectural Improvements
- **Hierarchical Per-Coop & Flexible Cage/Floor Discovery (`discovery.py`)**:
- Implemented dynamic farm discovery engine (`discover_farm_structure`, `get_coop_for_location`, `resolve_floor_video_dir`).
- Added support for hierarchical coop folders (`VIDEOS/cycle7/<Coop>/[L1..Ln, mortality]`) where all floors and mortality sit on the same level per coop.
- Fully dynamic site layout discovery without hardcoding assumptions about numbers of coops (e.g. K1..K5 or custom names) or floors per coop (e.g. 2 floors vs 3 floors).
- **Multi-Coop Mortality Pipeline & Batch Runner (`mortality.py`, `cli.py`, `run_mortality_all.sh`)**:
- Added coop and covered floor metadata resolution in `run_mortality_count(...)` and `mortality_report.json`.
- Created `run_all_coop_mortality(...)` and `./run_mortality_all.sh` to batch-process mortality detection across all discovered coops with summary reports.
- Added CLI flags `--coop <ID>` and `--all-coops` to `chicken-counter mortality`.
- **Dedicated Stationary Inspection Camera Capture & Live API (`mortality.py`, `dashboard.py`, `templates/index.html`)**:
- Added support for dedicated per-coop stationary cameras (RTSP IP cameras, HTTP snapshots, and USB cameras) configured in `configs/mortality_config.yaml`.
- Added `POST /api/mortality/capture` endpoint to grab high-res still frames from a coop's stationary camera, timestamp and save them into `VIDEOS/cycle7/<Coop>/mortality/<YYYY-MM-DD>/`, and run YOLO carcass detection.
- Added `GET /api/mortality/camera/preview` to stream live snapshot previews to workers in the webapp before snapping.
- Added `GET /api/mortality/cameras` to list all registered coop stationary camera sources.
- Web UI Mortality Inspector: Integrated live camera preview, one-click **"📸 Snap & Count Batch from Camera"** button, and support for multi-batch counting per coop per day with cumulative totals.
- Added fallback manual file upload tab for disk-based images.
- **Dynamic Floor Config Synthesis & Sync Tool (`config.py`, `discovery.py`, `cli.py`)**:
- Implemented `load_or_synthesize_floor_config` allowing `chicken-counter batch --floor <ID>` to dynamically synthesize settings from `cycle7_batch_optimized.yaml` in memory without requiring a physical YAML file.
- Implemented `sync_floor_configs` and CLI command `chicken-counter sync-configs` to automatically scan `VIDEOS/` and generate boilerplate floor YAML configs for newly added coops/floors.
- Updated `run_all_coops.sh` to dynamically discover all floors directly from `VIDEOS/` or `configs/floor_config/` and seamlessly run physical YAMLs or virtual in-memory configs.
- **Unit Test Suite (`tests/test_flexible_discovery.py`)**:
- Added automated test cases validating dynamic coop and floor extraction, arbitrary site layout adaptation, path resolution, config synchronization, virtual floor config synthesis, and dated mortality discovery.
## [1.1.0] - 2026-08-19
### 🐛 Bug Fixes
- **Multi-Camera Tracker State Isolation (`tracking.py` & `batch_runner.py`)**:
- Implemented per-camera independent tracker instances keyed by `stream_id` in `DetectionTracker`.
- Fixed tracker state bleed and track ID jumping in `tensor_batching` and multi-camera batch modes when camera frames are processed or when active camera sets shrink.
- Added unit test suite `tests/test_tracking.py` covering multi-stream tracker isolation.
- **Multi-Floor Script Syntax (`run_all_coops.sh`)**:
- Resolved fatal `syntax error: unexpected end of file` caused by missing `done` in floor configuration discovery loop.
- Expanded config discovery pattern from `K*-L*.yaml` to `*.yaml` to support custom named coops (e.g. `kandang-atas.yaml`).
- **Excel Report Empty Result Crash (`export_excel_report.py`)**:
- Added empty DataFrame pre-check to prevent `IndexError` when exporting reports for dates with no batch runs or newly initialized databases.
- **Multi-Threaded SQLite Concurrency (`dashboard.py`)**:
- Replaced global single `sqlite3` connection with thread-local connections (`threading.local()`) to prevent cursor collision and race conditions across concurrent HTTP worker threads.
- Enabled `PRAGMA busy_timeout = 5000` and `PRAGMA journal_mode = WAL` to avoid database lock conflicts during batch processing writes.
- **Editable Package Installation**:
- Configured `pip install -e .` in environment setup to resolve `chicken_counter` imports and enable automatic unit test discovery without manual `PYTHONPATH` prefixes.
### ⚡ Performance & Optimization
- **Secondary Database Indexing (`dashboard.py`, `batch_runner.py`, `store_results.py`)**:
- Added `idx_batch_date`, `idx_batch_camera`, and `idx_batch_location` indexes on `batch_runs` to accelerate single camera, location, and date historical queries.
- **Atomic Config Persistence (`dashboard.py`)**:
- Added `_config_write_lock` and atomic temporary file replacement (`os.replace`) in `_persist_cycle_start_date` to prevent race conditions or half-written YAML reads.
- **Streaming Socket Keepalive (`dashboard.py`)**:
- Added an idle keepalive heartbeat (`b"--frame\r\n\r\n"`) every 3 seconds to immediately detect and terminate disconnected MJPEG streaming threads during pipeline idle periods.
- **Disk Traversal Caching in Live Dashboard (`dashboard.py`)**:
- Replaced repetitive `Path.rglob()` scans on every `/api/mortality/*` endpoint with a thread-safe 15-second in-memory TTL cache (`_MORTALITY_CACHE_TTL = 15.0s`).
- Added $O(1)$ memory index lookup for serving annotated images (`/api/mortality/image/<filename>`), eliminating full filesystem traversals across 30GB+ video datasets.
### 📁 Configuration & Directory Structure
- **Floor Configuration & Skeleton Paths**:
- Created `configs/floor_config/kandang-atas.yaml` extending `cycle7_batch_optimized.yaml`.
- Initialized missing directory skeletons for coops `K1-L1` through `K5-L2` under `../VIDEOS/cycle7/`.
- **Documentation**:
- Updated `README.md` and `RUN.md` with editable install steps and updated project layout.
+131 -17
View File
@@ -24,8 +24,13 @@ visual.
```text
configs/
floor_config/ ← Lightweight floor configs (K1-L1 to K5-L2, kandang-atas)
K1-L1.yaml .. K5-L2.yaml ← Extends cycle7_batch_optimized.yaml
kandang-atas.yaml ← Extends cycle7_batch_optimized.yaml
cameras/example_camera.yaml
cycle7_batch.yaml
cycle7_batch_optimized.yaml ← Base batch processing config
mortality_config.yaml
trackers/botsort_chicken.yaml
src/chicken_counter/
batch_discovery.py
@@ -35,6 +40,8 @@ src/chicken_counter/
compress.py
config.py
counting.py
engine_utils.py
mortality.py
motion.py
overlay.py
pipeline.py
@@ -42,17 +49,28 @@ src/chicken_counter/
tracking.py
types.py
video_writer.py
dashboard.py
export_engine.py
export_excel_report.py
run_all_coops.sh ← Master multi-coop batch runner
start_dashboard.sh ← Live dashboard launcher
test_run_folder/ ← Archive of test run scripts and logs
```
## Install
```bash
python -m pip install -e .
python3 -m venv venv
venv/bin/pip install --upgrade pip
venv/bin/pip install -r requirements.txt
venv/bin/pip install -e .
```
For Jetson deployment you will usually want a Jetson-compatible OpenCV and PyTorch
stack already installed, then install the rest of the package around that environment.
> **Tip:** See `RUN.md` for a full step-by-step quickstart guide for new developers.
## Run
Update `configs/cameras/example_camera.yaml` with:
@@ -199,7 +217,9 @@ detection:
imgsz: 640 # keep 640 while using existing TensorRT .engine
```
`configs/cycle7_batch.yaml` already uses these production defaults.
`configs/cycle7_batch.yaml` and `configs/cycle7_batch_optimized.yaml` already use these production defaults.
`configs/cycle7_batch_optimized.yaml` adds per-camera parallelism, trimmed inference settings, and optimized YAML structure for the Sukawarna enclosure.
**Validation:** run a short clip with stride enabled, then compare `total_entered` against
`inference_stride: 1` and `motion.stride_frames: 1`. Watch checkpoint `fps=` logs for
@@ -217,6 +237,53 @@ for tuning.
- The optical-flow trigger is vision-first, though the config structure leaves room for
a future controller/encoder integration path
## Cross-Machine Portability & Self-Healing Engine Auto-Recompilation
TensorRT `.engine` files are compiled specifically for the host GPU architecture and TensorRT version. When copying the project to a different machine (e.g. from Jetson to NUC or across different RTX GPUs):
- **Automatic Compatibility Check**: `src/chicken_counter/engine_utils.py` runs a fast health check on the specified `.engine` before counting starts.
- **Self-Healing Recompilation**: If an incompatibility (e.g. platform tag mismatch or different compute capability) is detected:
1. The system automatically searches `models/` for the matching base `.pt` model weights (stripping hardware prefixes like `NUC5070_` or `jetson_`).
2. Automatically compiles a new optimized `.engine` on the host machine using FP16 precision.
3. Updates `defaults.detection.model_path` in `configs/cycle7_batch_optimized.yaml` automatically.
- **Manual Export Tool**: You can also compile engines manually anytime using `export_engine.py`:
```bash
./venv/bin/python export_engine.py models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.pt --half --workspace 4
```
## Multi-Stage Growth Cycles & Day 0 Configuration
The pipeline dynamically adjusts detection and ROI entry thresholds based on flock age (Days Old Chick / DOC vs Mid-Cycle):
- **Day 0 (`cycle_start_date`)**: Configured in `configs/cycle7_batch_optimized.yaml` (default: `"2026-05-22"`). Can be overridden via CLI (`--cycle-start-date YYYY-MM-DD`) or REST API (`/api/config/cycle_start_date`).
- **`early_cycle` (Days 0–15)**: Automatically applies high-sensitivity detection thresholds (`conf: 0.12`, `min_box_area_px: 200`, `min_overlap_ratio: 0.25`) for small fast-moving DOC chicks.
- **`mid_cycle` (Days 16+)**: Preserves standard tuned per-camera defaults (`conf: 0.35–0.50`, `min_box_area_px: 2500–3000`).
## Mortality Detection
A separate pipeline detects carcasses (dead birds) from still photos. Supports multi-image daily runs and date subfolders (`mortality/YYYY-MM-DD/`).
```bash
# Run on default mortality directory
./test_run_mortality.sh
# Run on a specific date (auto-creates/routes to mortality/2026-05-23/)
chicken-counter mortality --date 2026-05-23
```
Key features:
- **Multi-Image & Multi-Day Support**: Processes multiple images per day (e.g. morning/afternoon scans), aggregates the grand total carcass count (`total_mortality_count`), and saves outputs into date-isolated directories.
- **Direct High-Precision Segmentation (Default)**: Uses the **segmentation model** (`models/chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt`) directly. 2-pass Detect & Refine (`two_pass: false`) is disabled by default because direct segmentation achieves higher accuracy and avoids false rejection on real farm photos.
- **Optional 2-Pass Refine (`--two-pass`)**: An optional mode combining initial segmentation candidate proposals with `cv2.matchTemplate` similarity refinement.
- **Containment filtering**: boxes where `IoA > 0.50` against a larger box are suppressed.
- **Centroid deduplication**: detections whose centroids are within `dedupe_radius_px` of each other are merged to prevent counting the same carcass twice.
Outputs for each daily run:
- `output_<name>.jpg` — annotated images with bounding boxes and carcass IDs
- `mortality_report.json` — full summary report with `total_mortality_count`, per-image breakdown, and detection coordinates
Config: `configs/mortality_config.yaml`
## Headless Jetson MP4 Example
For a headless run that saves both output video and periodic checkpoint images, use a
@@ -255,7 +322,7 @@ motion:
flow_scale: 0.5
display:
show_window: false
output_path: /media/jetson/DATA/try-sukawarna-vis.mp4
output_path: /home/asus/.Codes/try-sukawarna-vis.mp4
encoder: auto
output_bitrate_kbps: 4000
performance:
@@ -294,14 +361,15 @@ For everyday processing of 4 cameras, use `configs/cycle7_batch.yaml`.
### Input folder layout
Place today's videos under:
Place today's videos in a `VIDEOS` folder **adjacent to the project directory** (i.e. `../VIDEOS` relative to the project root):
```text
/media/jetson/DATA/chicken-sukawarna/cycle7/2026-07-09/
kandang_1_camera_1_2026-07-09_120056.mp4
kandang_1_camera_2_2026-07-09_120456.mp4
kandang_1_camera_3_2026-07-09_121012.mp4
kandang_1_camera_4_2026-07-09_121530.mp4
../VIDEOS/cycle7/kandang-atas/
2026-06-18/
kandang_1_camera_1_2026-06-18_120056.mp4
kandang_1_camera_2_2026-06-18_120456.mp4
kandang_1_camera_3_2026-06-18_121012.mp4
kandang_1_camera_4_2026-06-18_121530.mp4
```
Date folders use `YYYY-MM-DD`. Camera files are matched by `camera_num` using the
@@ -310,13 +378,32 @@ pattern `kandang_*_camera_{num}_*.mp4`.
### Run commands
```bash
# Process today's folder
chicken-counter batch --config configs/cycle7_batch.yaml
# Process today's folder using default parallel process mode
chicken-counter batch --config configs/cycle7_batch_optimized.yaml
# Process a specific date
chicken-counter batch --config configs/cycle7_batch.yaml --date 2026-07-09
# Process a specific date using Model-Level Tensor Batching mode
chicken-counter batch --config configs/cycle7_batch_optimized.yaml --date 2026-06-18 --mode tensor_batching
# Process a specific date using Hybrid mode (Threaded CPU + Batched GPU)
chicken-counter batch --config configs/cycle7_batch_optimized.yaml --date 2026-06-18 --mode hybrid
# Run automated batch script for Tensor Batching
./test_run_tensor_batch.sh # Runs all dates (2026-06-10 to 2026-06-19)
./test_run_tensor_batch.sh 2026-06-18 # Runs a specific date
# Run automated batch script for Hybrid execution mode
./test_run_hybrid.sh # Runs all dates (2026-06-10 to 2026-06-19)
./test_run_hybrid.sh 2026-06-18 # Runs a specific date
```
### Execution Modes (`execution_mode`)
Set `batch.execution_mode` in `configs/cycle7_batch_optimized.yaml` or override via `--mode`:
- `parallel_processes` (Default): Runs cameras in separate OS processes (e.g. via `test_run.sh`).
- `tensor_batching`: Synchronizes camera frame streams and executes a single batched GPU model inference pass across all cameras (`batch_size=N`).
- `hybrid`: Combines multi-threaded CPU frame capture, optical flow, and rendering across CPU cores with a single synchronized batched GPU forward pass (`batch_size=N`).
Single-camera mode still works:
```bash
@@ -327,7 +414,7 @@ chicken-counter run --config configs/cameras/example_camera.yaml
### Output layout
```text
/media/jetson/DATA/chicken-sukawarna/cycle7/2026-07-09/output/
../VIDEOS/cycle7/kandang-atas/2026-06-18/output/
CC1_vis.mp4
CC1_compressed.mp4
CC2_vis.mp4
@@ -335,7 +422,7 @@ chicken-counter run --config configs/cameras/example_camera.yaml
...
checkpoints/CC1/frame_003000.jpg
checkpoints/CC2/frame_006000.jpg
counts_2026-07-09.json
counts_2026-06-18.json
```
After all 4 cameras finish counting, the batch runner compresses each annotated video
@@ -384,11 +471,37 @@ For a ~72k frame run that is about 24 images per camera.
### Cron example
```cron
0 7 * * * cd /media/jetson/DATA/chicken-sukawarna && /usr/bin/chicken-counter batch --config configs/cycle7_batch.yaml >> logs/cycle7-batch.log 2>&1
0 7 * * * cd /path/to/chicken-counting-sukawarna-det && ./test_run.sh >> logs/cycle7-batch.log 2>&1
```
Requires `ffmpeg` on the Jetson PATH for post-run compression.
## Dashboard & API
Start the live dashboard and API server:
```bash
# Portable launcher (recommended) — auto-discovers mortality dir
./start_dashboard.sh
# Or start manually
PYTHONPATH=src venv/bin/python dashboard.py \
--port 8080 \
--db db/chicken_counts.db \
--mortality-dir ../VIDEOS/cycle7/kandang-atas/mortality
```
Key API endpoints (see `API.md` for full schema):
| Endpoint | Description |
| :--- | :--- |
| `GET /api/db/summary` | Lifetime totals across all dates |
| `GET /api/db/history` | Per-date summary, newest first |
| `GET /api/db/date/<YYYY-MM-DD>` | Per-camera breakdown for a date |
| `GET /api/mortality/latest` | Latest carcass detection report |
| `GET /api/mortality/history` | All mortality reports, newest first |
| `GET /api/mortality/image/<name>` | Serve annotated output JPEG |
## Next Jetson-Focused Improvements
1. Add a hardware-aware video ingest path for CSI/GStreamer.
@@ -397,4 +510,5 @@ Requires `ffmpeg` on the Jetson PATH for post-run compression.
Recent work includes daily 4-camera batch processing, JSON count reports, post-run
compression under 200 MB, GStreamer hardware encoding, overlay buffer reuse,
duplicate optical-flow removal (`gmc_method: none`), and long-run ETA logging.
duplicate optical-flow removal (`gmc_method: none`), long-run ETA logging, mortality
2-pass detection with feature similarity search, and a REST API via `dashboard.py`.
+273
View File
@@ -0,0 +1,273 @@
# Quick Start Guide
This guide gets a new developer up and running from scratch.
---
## 1. Prerequisites
- Python 3.10+
- `ffmpeg` on PATH (for video compression)
- NVIDIA GPU + CUDA drivers (optional but recommended for inference speed)
---
## 2. First-Time Setup
```bash
# Clone / copy the project folder, then enter it
cd chicken-counting-sukawarna-det
# Create a virtual environment and install all dependencies
python3 -m venv venv
venv/bin/pip install --upgrade pip
venv/bin/pip install -r requirements.txt
venv/bin/pip install -e .
```
> **Note**: If moving the project from another machine, always recreate the venv.
> Do NOT copy the `venv/` folder — it contains absolute paths baked in from the source machine.
---
## 3. Project Layout
```text
chicken-counting-sukawarna-det/
├── configs/
│ ├── floor_config/ ← Lightweight floor configs (K1-L1 to K5-L2, kandang-atas)
│ │ ├── K1-L1.yaml .. K5-L2.yaml ← Extends cycle7_batch_optimized.yaml
│ │ └── kandang-atas.yaml ← Extends cycle7_batch_optimized.yaml
│ ├── cycle7_batch_optimized.yaml ← Main base batch processing config
│ ├── cycle7_batch.yaml ← Alternate batch config
│ ├── mortality_config.yaml ← Mortality (carcass) detection config
│ ├── cameras/example_camera.yaml ← Single-camera run config template
│ └── trackers/botsort_chicken.yaml ← BoT-SORT tracker settings
├── db/
│ └── chicken_counts.db ← SQLite database (auto-created)
├── models/ ← Place your .pt / .onnx / .engine files here
│ ├── chicken-detection-model-v26n-300e-best-2026-05-02-NEW.pt ← Source PyTorch model
│ ├── chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine ← Hardware-tuned TensorRT
│ └── chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt ← Used by mortality
├── src/chicken_counter/ ← Main Python package (counting, tracking, motion, engine_utils)
├── templates/ ← Dashboard HTML
├── dashboard.py ← Live API + Dashboard server
├── export_engine.py ← Manual TensorRT export utility
├── export_excel_report.py ← Excel reporting & analytics generator
├── run_all_coops.sh ← Master multi-coop batch runner (K1-L1 to K5-L2)
├── start_dashboard.sh ← Portable dashboard launcher ← USE THIS
├── test_run_folder/ ← Archive of test run scripts and logs
└── requirements.txt
```
---
## 4. Model Setup & Auto-Recompilation
Put your model files in the `models/` directory.
| Purpose | File | Notes |
| :--- | :--- | :--- |
| Batch video counting (TensorRT) | `models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine` | Maximum GPU throughput |
| Base PyTorch weights | `models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.pt` | Used for portable runs and auto-recompiling engines |
| Mortality detection | `models/chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt` | Direct segmentation model (2-pass disabled by default for accuracy) |
> **Self-Healing Recompilation on New Machines**: If you move the project to a new machine with a different GPU or OS, the pipeline will detect any incompatible `.engine`, automatically locate the matching `.pt` model, recompile a new `.engine` for the host machine, and update `configs/cycle7_batch_optimized.yaml` automatically.
---
## 5. Running the Systems
### A — Batch Video Processing (Daily Chicken Count)
Place input videos under the `VIDEOS` folder adjacent to the project following the `K{coop}-L{floor}` format:
```text
../VIDEOS/cycle7/
K1-L1/
2026-06-18/
K1-L1_cam1_2026-06-18_120056.mp4
K1-L1_cam2_2026-06-18_120456.mp4
K1-L1_cam3_2026-06-18_121012.mp4
K1-L1_cam4_2026-06-18_121530.mp4
```
Then run:
```bash
# Run all coops & floors dynamically for a specific date
./run_all_coops.sh 2026-06-18
# Run a specific floor using its config file
PYTHONPATH=src venv/bin/python -m chicken_counter.cli batch \
--config configs/floor_config/K1-L1.yaml \
--date 2026-06-18
# Run directly on ANY discovered floor (virtual config — zero YAML required!)
PYTHONPATH=src venv/bin/python -m chicken_counter.cli batch \
--floor K6-L1 \
--date 2026-06-18
# Automatically scan VIDEOS and generate boilerplate YAMLs for any newly added floors
PYTHONPATH=src venv/bin/python -m chicken_counter.cli sync-configs
```
Output is saved in `../VIDEOS/cycle7/<coop>/<floor>/<date>/output/`.
---
### B — Mortality Detection (Per-Coop Carcass Photo Scanning)
#### Method 1: Stationary Inspection Camera Capture (Recommended for Farm Workers)
Each coop (or central mortality table) is equipped with a dedicated stationary camera (RTSP stream or USB camera) mounted above the inspection area:
1. **Open the Webapp**: From any phone, tablet, or terminal on the farm local network, navigate to `http://<JETSON-IP>:8080`.
2. **Open Mortality Inspector**: Under the sidebar **💀 Mortality Scans**, tap **`📷 Upload / Capture`**.
3. **Select Coop & Inspect View**:
- Choose the coop (e.g. `K1`).
- The live preview from that coop's stationary camera displays on screen so the worker can verify the chickens are spread out properly on the table.
4. **Capture Batch 1**:
- Tap **`📸 Snap & Count Batch from Camera`**.
- The system grabs the frame from the camera, runs YOLO carcass detection, and shows the count (e.g. *"Batch 1: 18 carcasses detected"*).
5. **Capture Subsequent Batches (if needed)**:
- Clear the table and place the next batch of chickens.
- Tap **`📸 Snap & Count Next Batch`**.
- The system automatically captures `capture_02_...jpg`, runs detection, and increments the daily total: *"Total for K1 Today: 30 carcasses across 2 batches"*.
6. All high-res captures and output detection overlays are saved permanently in `VIDEOS/cycle7/<Coop>/mortality/<YYYY-MM-DD>/`.
#### Camera Configuration (`configs/mortality_config.yaml`)
Map each coop to its RTSP stream URL, HTTP snapshot URL, or USB device index:
```yaml
mortality:
cameras:
K1: "rtsp://admin:admin@192.168.1.101:554/live"
K2: "rtsp://admin:admin@192.168.1.102:554/live"
K3: "rtsp://admin:admin@192.168.1.103:554/live"
K4: "rtsp://admin:admin@192.168.1.104:554/live"
K5: "rtsp://admin:admin@192.168.1.105:554/live"
```
#### Method 2: Command Line Batch Run
Place input photos inside each coop's mortality directory (e.g. `../VIDEOS/cycle7/K1/mortality/`, `../VIDEOS/cycle7/K2/mortality/`, etc.).
```bash
# Run mortality detection across ALL discovered coops (K1..K5, etc.)
./run_mortality_all.sh
# Run for a specific coop
./run_mortality_all.sh K1
# or via CLI:
PYTHONPATH=src venv/bin/python -m chicken_counter.cli mortality --coop K1
# Run across all coops for a specific date
./run_mortality_all.sh 2026-06-18
# Run directly on a specific image file
PYTHONPATH=src venv/bin/python -m chicken_counter.cli mortality -i /path/to/photo.jpg
```
Output annotated images are saved as `output_<original_name>.jpg` in each coop's mortality directory.
A `mortality_report.json` is generated for each coop, tagging the coop name and all covered floors (e.g., `["K1-L1", "K1-L2", "K1-L3"]`).
#### Key config options in `configs/mortality_config.yaml`
| Setting | Description |
| :--- | :--- |
| `conf` | Detection confidence threshold (0.0–1.0) |
| `iou` | IoU NMS threshold |
| `min_box_area_px` | Minimum bounding box area in pixels |
| `dedupe_radius_px` | Centroid deduplication radius in pixels |
| `two_pass` | 2x Detect & Refine pipeline (`false` by default; single-pass direct achieves higher accuracy on farm footage) |
| `classes` | `[0]` = chicken only; ignores background/text/equipment |
---
### C — Dashboard & API Server
```bash
# Start the dashboard (auto-discovers mortality directory)
./start_dashboard.sh
# Custom port and directories
PORT=9090 ./start_dashboard.sh
# Multiple mortality directories
MORTALITY_DIRS="/path/to/mortality1,/path/to/mortality2" ./start_dashboard.sh
```
Open in browser: **http://localhost:8080**
Available API endpoints:
| Endpoint | Description |
| :--- | :--- |
| `GET /api/status` | Live system status, active counting cameras & latest date |
| `GET /api/cameras` | Live camera list from `/dev/shm` |
| `GET /api/db/summary` | Total chickens, days, hours across all dates |
| `GET /api/db/history` | Per-date summary, newest first |
| `GET /api/db/date/<YYYY-MM-DD>` | Per-camera breakdown for a specific date |
| `GET /api/db/camera/<id>` | History for a specific camera (CC1, CC2...) |
| `GET /api/db/location/<name>` | Summary and history for a location |
| `GET /api/mortality/latest` | Latest mortality detection report (JSON) |
| `GET /api/mortality/history` | All mortality reports, newest first |
| `GET /api/mortality/image/<filename>` | Serve annotated output JPEG by filename |
| `GET /shm/<cam>/stats.json` | Live pipeline stats for a running camera |
| `GET /shm/<cam>/frame.jpg` | Live frame snapshot from a running camera |
See `API.md` for full response schemas.
---
### D — Install as a System Service (Auto-Start)
```bash
# Copy the service file and adjust WorkingDirectory / User if needed
sudo cp chicken-dashboard.service /etc/systemd/system/
# Enable and start
sudo systemctl daemon-reload
sudo systemctl enable chicken-dashboard
sudo systemctl start chicken-dashboard
# Check status
sudo systemctl status chicken-dashboard
```
The service reads `start_dashboard.sh`, so it also auto-discovers the mortality directory.
---
## 6. Database
Results are automatically written to `db/chicken_counts.db` when `db_path` is set in the batch YAML. To store results manually from a JSON report:
```bash
PYTHONPATH=src venv/bin/python store_results.py \
../VIDEOS/cycle7/kandang-atas/2026-06-18/output/counts_2026-06-18.json \
--location kandang-atas \
--db db/chicken_counts.db
```
Export to Excel:
```bash
PYTHONPATH=src venv/bin/python export_excel_report.py
```
---
## 7. Moving the Project to Another Machine
1. Delete the `venv/` folder before copying:
```bash
rm -rf venv/
```
2. Copy the entire project folder to the new machine.
3. Update the `WorkingDirectory` and `ExecStart` in `chicken-dashboard.service` to the new path.
4. Recreate the venv on the new machine:
```bash
python3 -m venv venv
venv/bin/pip install -r requirements.txt
```
5. All configs and Python code use relative paths and will work without any other changes.
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Chicken Counter Live Dashboard
After=network.target
[Service]
Type=simple
# Uses dynamic %h (user home directory) so it works on any user account (asus, jetson, ubuntu, etc.)
WorkingDirectory=%h/.Codes/chicken-counting-sukawarna-det
ExecStart=%h/.Codes/chicken-counting-sukawarna-det/start_dashboard.sh
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1
Environment=PORT=8080
[Install]
WantedBy=default.target
+8 -5
View File
@@ -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
+20 -4
View File
@@ -1,14 +1,16 @@
batch:
root_dir: /home/nvidia-admin/VIDEOS/cycle7/kandang-atas
root_dir: ../VIDEOS/cycle7/kandang-atas
camera_glob: "kandang_*_camera_{num}_*.mp4"
output_subdir: output
compress_max_mb: 200
delete_intermediate: false
checkpoint_every_n_frames: 3000
location: kandang-atas
db_path: db/chicken_counts.db
defaults:
detection:
model_path: /home/nvidia-admin/LABS/try-weight-estimator/try-chicken-sukawarna/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.pt
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
@@ -17,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: /home/nvidia-admin/LABS/try-weight-estimator/try-chicken-sukawarna/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]
@@ -66,6 +71,10 @@ defaults:
half: false
overlay_buffer_reuse: true
inference_stride: 2
stream:
enabled: true
shm_dir: /dev/shm
interval_frames: 5
feedback:
enabled: true
every_n_frames: 3000
@@ -91,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]
@@ -100,6 +113,9 @@ cameras:
CC3:
camera_num: 3
count_anchor: [900, 120]
detection:
dedupe_radius_px: 24
dedupe_frames: 12
roi:
points:
- [20, 330]
+151
View File
@@ -0,0 +1,151 @@
batch:
root_dir: "../VIDEOS/cycle7/kandang-atas"
camera_glob: "kandang_*_camera_{num}_*.mp4"
output_subdir: output
compress_max_mb: 200
delete_intermediate: false
checkpoint_every_n_frames: 3000
location: kandang-atas
db_path: db/chicken_counts.db
cycle_start_date: "2026-05-22" #day 0
execution_mode: parallel_processes # parallel_processes (default) | tensor_batching | hybrid
stages:
early_cycle:
day_min: 0
day_max: 15
detection:
conf: 0.1
min_box_area_px: 100
roi:
min_overlap_ratio: 0.1 # 0.25
mid_cycle:
day_min: 16
day_max: 999
defaults:
detection:
model_path: models/NUC5070_chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine # TensorRT engine (RTX 5070)
classes: [0]
ignored_classes: [1, 2]
conf: 0.40 # mild lift vs 0.35; 0.55 was undercounting
iou: 0.55
imgsz: 640
device: "0"
min_box_area_px: 3000
validate_while_inside: true
dedupe_radius_px: 0 # off for CC1/CC4; CC2/CC3 enable below
dedupe_frames: 12
detection_zone:
enabled: true
buffer_above_px: 250
buffer_below_px: 250
show_in_overlay: true
tracker:
tracker_config_path: configs/trackers/botsort_chicken.yaml
persist: true
track_buffer: 90
gate:
mode: two_line
lines_y: [420, 730]
direction: bottom_to_up
motion:
enabled: true
axis: vertical
forward_sign: 1.0
ema_alpha: 0.2
reverse_enter_threshold: -1.5
reverse_exit_threshold: -0.5
debounce_frames: 12
min_features: 40 # ↓ 60 → less optical flow computation
max_corners: 50 # ↓ 80 → fewer corner featuresis
stride_frames: 3 # ↑ 2 → motion detection every 3rd frame
flow_scale: 0.5
quality_level: 0.01
min_distance: 8
block_radius: 6
overlay:
show_boxes: true
show_track_trails: false
trail_length: 20
show_center_marker: false # ✗ → save 1 circle per chicken
show_track_ring: false
count_anchor: [780, 120]
inside_box_only: true
validated_only: true # NEW → only draw counted chickens
pending_blink: true
pending_colors:
- [255, 255, 0]
- [0, 255, 255]
display:
show_window: false
encoder: auto
output_bitrate_kbps: 2000 # ↓ 4000 → faster encoding
codec_preference: [avc1, mp4v, H264]
performance:
half: false
overlay_buffer_reuse: true
inference_stride: 2
stream:
enabled: true # required for dashboard live feed
shm_dir: /dev/shm
interval_frames: 5
feedback:
enabled: true
every_n_frames: 3000
save_images: false # ✗ → disable checkpoint JPEG I/O spikes
log_to_terminal: true
roi:
inset_left_px: 60
inset_right_px: 60
inset_top_px: 0
inset_bottom_px: 0
min_overlap_ratio: 0.30
cameras:
CC1:
camera_num: 1
count_anchor: [780, 120]
detection:
min_box_area_px: 2500
conf: 0.35
roi:
points:
- [250, 330]
- [1650, 330]
- [1650, 720]
- [250, 720]
CC2:
camera_num: 2
count_anchor: [900, 120]
detection:
conf: 0.5 # From 0.45
dedupe_radius_px: 32
dedupe_frames: 24 # From 18
roi:
points:
- [20, 380]
- [1880, 380]
- [1880, 720]
- [20, 720]
CC3:
camera_num: 3
count_anchor: [900, 120]
detection:
dedupe_radius_px: 24
dedupe_frames: 16 # From 12
roi:
points:
- [20, 330]
- [1880, 330]
- [1880, 720]
- [20, 720]
CC4:
camera_num: 4
count_anchor: [700, 120]
roi:
points:
- [50, 330]
- [1450, 330]
- [1450, 720]
- [50, 720]
+17
View File
@@ -0,0 +1,17 @@
# Floor Configuration for K1-L1
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
extends: ../cycle7_batch_optimized.yaml
batch:
location: "K1-L1"
root_dir: "../VIDEOS/cycle7/K1/L1"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras:
# CC1:
# roi:
# points:
# - [250, 330]
# - [1650, 330]
# - [1650, 720]
# - [250, 720]
+17
View File
@@ -0,0 +1,17 @@
# Floor Configuration for K1-L2
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
extends: ../cycle7_batch_optimized.yaml
batch:
location: "K1-L2"
root_dir: "../VIDEOS/cycle7/K1/L2"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras:
# CC1:
# roi:
# points:
# - [250, 330]
# - [1650, 330]
# - [1650, 720]
# - [250, 720]
+7
View File
@@ -0,0 +1,7 @@
# Floor Configuration for K1-L3
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
extends: ../cycle7_batch_optimized.yaml
batch:
location: "K1-L3"
root_dir: "../VIDEOS/cycle7/K1/L3"
+17
View File
@@ -0,0 +1,17 @@
# Floor Configuration for K2-L1
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
extends: ../cycle7_batch_optimized.yaml
batch:
location: "K2-L1"
root_dir: "../VIDEOS/cycle7/K2/L1"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras:
# CC1:
# roi:
# points:
# - [250, 330]
# - [1650, 330]
# - [1650, 720]
# - [250, 720]
+17
View File
@@ -0,0 +1,17 @@
# Floor Configuration for K2-L2
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
extends: ../cycle7_batch_optimized.yaml
batch:
location: "K2-L2"
root_dir: "../VIDEOS/cycle7/K2/L2"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras:
# CC1:
# roi:
# points:
# - [250, 330]
# - [1650, 330]
# - [1650, 720]
# - [250, 720]
+7
View File
@@ -0,0 +1,7 @@
# Floor Configuration for K2-L3
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
extends: ../cycle7_batch_optimized.yaml
batch:
location: "K2-L3"
root_dir: "../VIDEOS/cycle7/K2/L3"
+17
View File
@@ -0,0 +1,17 @@
# Floor Configuration for K3-L1
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
extends: ../cycle7_batch_optimized.yaml
batch:
location: "K3-L1"
root_dir: "../VIDEOS/cycle7/K3/L1"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras:
# CC1:
# roi:
# points:
# - [250, 330]
# - [1650, 330]
# - [1650, 720]
# - [250, 720]
+17
View File
@@ -0,0 +1,17 @@
# Floor Configuration for K3-L2
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
extends: ../cycle7_batch_optimized.yaml
batch:
location: "K3-L2"
root_dir: "../VIDEOS/cycle7/K3/L2"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras:
# CC1:
# roi:
# points:
# - [250, 330]
# - [1650, 330]
# - [1650, 720]
# - [250, 720]
+7
View File
@@ -0,0 +1,7 @@
# Floor Configuration for K3-L3
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
extends: ../cycle7_batch_optimized.yaml
batch:
location: "K3-L3"
root_dir: "../VIDEOS/cycle7/K3/L3"
+17
View File
@@ -0,0 +1,17 @@
# Floor Configuration for K4-L1
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
extends: ../cycle7_batch_optimized.yaml
batch:
location: "K4-L1"
root_dir: "../VIDEOS/cycle7/K4/L1"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras:
# CC1:
# roi:
# points:
# - [250, 330]
# - [1650, 330]
# - [1650, 720]
# - [250, 720]
+17
View File
@@ -0,0 +1,17 @@
# Floor Configuration for K4-L2
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
extends: ../cycle7_batch_optimized.yaml
batch:
location: "K4-L2"
root_dir: "../VIDEOS/cycle7/K4/L2"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras:
# CC1:
# roi:
# points:
# - [250, 330]
# - [1650, 330]
# - [1650, 720]
# - [250, 720]
+17
View File
@@ -0,0 +1,17 @@
# Floor Configuration for K5-L1
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
extends: ../cycle7_batch_optimized.yaml
batch:
location: "K5-L1"
root_dir: "../VIDEOS/cycle7/K5/L1"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras:
# CC1:
# roi:
# points:
# - [250, 330]
# - [1650, 330]
# - [1650, 720]
# - [250, 720]
+17
View File
@@ -0,0 +1,17 @@
# Floor Configuration for K5-L2
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
extends: ../cycle7_batch_optimized.yaml
batch:
location: "K5-L2"
root_dir: "../VIDEOS/cycle7/K5/L2"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras:
# CC1:
# roi:
# points:
# - [250, 330]
# - [1650, 330]
# - [1650, 720]
# - [250, 720]
+7
View File
@@ -0,0 +1,7 @@
# Floor Configuration for kandang-atas
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
extends: ../cycle7_batch_optimized.yaml
batch:
location: "kandang-atas"
root_dir: "../VIDEOS/cycle7/kandang-atas"
+25
View File
@@ -0,0 +1,25 @@
# Configuration for Mortality Chicken Carcass Detection & Counting
mortality:
# Base directory for multi-coop auto-discovery (K1..K5 and arbitrary coops)
coops_root_dir: "../VIDEOS/cycle7"
# Default single-coop input directory (or override via --coop K1 / --all-coops)
input_dir: "../VIDEOS/cycle7/K1/mortality"
output_dir: null # null = save output_<File Name> in each coop's mortality 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
# Dedicated stationary camera streams per coop (RTSP URL, HTTP snapshot URL, or USB device index)
cameras:
K1: "rtsp://admin:admin@192.168.1.101:554/live"
K2: "rtsp://admin:admin@192.168.1.102:554/live"
K3: "rtsp://admin:admin@192.168.1.103:554/live"
K4: "rtsp://admin:admin@192.168.1.104:554/live"
K5: "rtsp://admin:admin@192.168.1.105:554/live"
kandang-atas: "rtsp://admin:admin@192.168.1.100:554/live"
+3 -3
View File
@@ -1,12 +1,12 @@
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
proximity_thresh: 0.5
appearance_thresh: 0.25
with_reid: false
model: auto
model: auto
+1042
View File
File diff suppressed because it is too large. Load diff
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.
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Export a YOLO .pt model to TensorRT .engine.
Usage:
python3 export_engine.py chicken-detection-model-v26n-300e-best-2026-05-02-NEW.pt
python3 export_engine.py model.pt --imgsz 640 --half --workspace 4
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
def export_engine(
model_path: str | Path,
*,
imgsz: int = 640,
half: bool = True,
int8: bool = False,
batch: int = 1,
workspace: int = 4, # GB
simplify: bool = True,
opset: int = 17,
verbose: bool = True,
) -> str:
from ultralytics import YOLO
model = YOLO(model_path, task="detect")
output = model.export(
format="engine",
imgsz=imgsz,
half=half,
int8=int8,
batch=batch,
workspace=workspace,
simplify=simplify,
opset=opset,
verbose=verbose,
)
print(f"\nExported to: {output}")
return str(output)
def main():
parser = argparse.ArgumentParser(description="Export YOLO .pt → TensorRT .engine")
parser.add_argument("model", help="Path to .pt model file")
parser.add_argument("--imgsz", type=int, default=640, help="Input image size (default: 640)")
parser.add_argument("--half", action="store_true", default=True, help="FP16 precision (default: on)")
parser.add_argument("--no-half", dest="half", action="store_false", help="FP32 precision")
parser.add_argument("--int8", action="store_true", help="INT8 quantization (needs calibration)")
parser.add_argument("--batch", type=int, default=1, help="Batch size (default: 1)")
parser.add_argument("--workspace", type=int, default=4, help="GPU workspace in GB (default: 4)")
parser.add_argument("--opset", type=int, default=17, help="ONNX opset version (default: 17)")
parser.add_argument("--quiet", action="store_true", help="Suppress verbose output")
args = parser.parse_args()
if not Path(args.model).exists():
print(f"error: model file not found: {args.model}", file=sys.stderr)
sys.exit(1)
export_engine(
args.model,
imgsz=args.imgsz,
half=args.half,
int8=args.int8,
batch=args.batch,
workspace=args.workspace,
opset=args.opset,
verbose=not args.quiet,
)
if __name__ == "__main__":
main()
+392
View File
@@ -0,0 +1,392 @@
import sqlite3
import pandas as pd
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
from openpyxl.chart import BarChart, Reference
def generate_excel_report(db_path, output_excel_path, target_date=None):
conn = sqlite3.connect(db_path)
if target_date:
df = pd.read_sql_query("SELECT * FROM batch_runs WHERE date = ? ORDER BY date ASC, camera_id ASC", conn, params=(target_date,))
else:
df = pd.read_sql_query("SELECT * FROM batch_runs ORDER BY date ASC, camera_id ASC", conn)
conn.close()
wb = openpyxl.Workbook()
# Remove default sheet
wb.remove(wb.active)
font_family = "Segoe UI"
if df.empty:
ws_empty = wb.create_sheet(title="Daily Summary")
ws_empty["A1"] = "Chicken Counter - Summary Report"
ws_empty["A1"].font = Font(name=font_family, size=16, bold=True, color="1F4E78")
target_info = f" for date {target_date}" if target_date else ""
ws_empty["A3"] = f"No batch runs found in database{target_info}."
ws_empty["A3"].font = Font(name=font_family, size=11, italic=True)
wb.save(output_excel_path)
print(f"No records found. Empty report saved to: {output_excel_path}")
return
# Styles
header_fill = PatternFill(start_color="1F4E78", end_color="1F4E78", fill_type="solid") # Dark Navy
header_font = Font(name=font_family, size=11, bold=True, color="FFFFFF")
accent_fill = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") # Light Soft Blue
total_fill = PatternFill(start_color="B4C6E7", end_color="B4C6E7", fill_type="solid")
total_font = Font(name=font_family, size=11, bold=True, color="000000")
kpi_title_font = Font(name=font_family, size=9, bold=False, color="595959")
kpi_value_font = Font(name=font_family, size=18, bold=True, color="1F4E78")
thin_border = Border(
left=Side(style='thin', color='D9D9D9'),
right=Side(style='thin', color='D9D9D9'),
top=Side(style='thin', color='D9D9D9'),
bottom=Side(style='thin', color='D9D9D9')
)
top_thick_bottom_double = Border(
top=Side(style='thin', color='000000'),
bottom=Side(style='double', color='000000')
)
# -------------------------------------------------------------
# SHEET 1: Daily Summary
# -------------------------------------------------------------
ws_summary = wb.create_sheet(title="Daily Summary")
ws_summary.views.sheetView[0].showGridLines = True
# Title Block
ws_summary["A1"] = "Chicken Counter - Cycle 7 Summary Report"
ws_summary["A1"].font = Font(name=font_family, size=16, bold=True, color="1F4E78")
ws_summary["A2"] = f"Location: {df['location'].iloc[0]} | Date Range: {df['date'].min()} to {df['date'].max()}"
ws_summary["A2"].font = Font(name=font_family, size=10, italic=True, color="595959")
# KPI Cards Block (Rows 4 to 6)
kpis = [
("TOTAL CHICKENS COUNTED", df['total_entered'].sum(), "#,##0"),
("TOTAL BATCH RUNS", len(df), "#,##0"),
("TOTAL FRAMES PROCESSED", df['frames_processed'].sum(), "#,##0"),
("TOTAL ELAPSED (MINUTES)", round(df['elapsed_seconds'].sum() / 60, 1), "#,##0.0")
]
col_starts = [1, 3, 5, 7]
for (title, val, num_fmt), col_idx in zip(kpis, col_starts):
c1 = ws_summary.cell(row=4, column=col_idx, value=title)
c1.font = kpi_title_font
c1.fill = accent_fill
ws_summary.merge_cells(start_row=4, start_column=col_idx, end_row=4, end_column=col_idx+1)
c2 = ws_summary.cell(row=5, column=col_idx, value=val)
c2.font = kpi_value_font
c2.number_format = num_fmt
c2.alignment = Alignment(horizontal='center', vertical='center')
ws_summary.merge_cells(start_row=5, start_column=col_idx, end_row=6, end_column=col_idx+1)
# Pivot Data: Date vs Camera
pivot = df.pivot_table(index='date', columns='camera_id', values='total_entered', aggfunc='sum', fill_value=0)
cameras = sorted(list(pivot.columns))
start_row = 9
ws_summary.cell(row=start_row, column=1, value="Date").font = header_font
ws_summary.cell(row=start_row, column=1).fill = header_fill
ws_summary.cell(row=start_row, column=1).alignment = Alignment(horizontal='center')
for idx, cam in enumerate(cameras):
cell = ws_summary.cell(row=start_row, column=idx+2, value=cam)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal='center')
tot_header = ws_summary.cell(row=start_row, column=len(cameras)+2, value="Daily Total")
tot_header.font = header_font
tot_header.fill = header_fill
tot_header.alignment = Alignment(horizontal='center')
current_r = start_row + 1
for date_val, row_data in pivot.iterrows():
ws_summary.cell(row=current_r, column=1, value=str(date_val)).font = Font(name=font_family, size=11)
ws_summary.cell(row=current_r, column=1).alignment = Alignment(horizontal='center')
ws_summary.cell(row=current_r, column=1).border = thin_border
for idx, cam in enumerate(cameras):
c = ws_summary.cell(row=current_r, column=idx+2, value=int(row_data[cam]))
c.font = Font(name=font_family, size=11)
c.number_format = "#,##0"
c.alignment = Alignment(horizontal='right')
c.border = thin_border
# Excel SUM Formula for Daily Total
start_col_let = get_column_letter(2)
end_col_let = get_column_letter(len(cameras) + 1)
tot_c = ws_summary.cell(row=current_r, column=len(cameras)+2, value=f"=SUM({start_col_let}{current_r}:{end_col_let}{current_r})")
tot_c.font = Font(name=font_family, size=11, bold=True)
tot_c.number_format = "#,##0"
tot_c.alignment = Alignment(horizontal='right')
tot_c.border = thin_border
current_r += 1
# Total Row at Bottom
ws_summary.cell(row=current_r, column=1, value="Total").font = total_font
ws_summary.cell(row=current_r, column=1).fill = total_fill
ws_summary.cell(row=current_r, column=1).alignment = Alignment(horizontal='center')
ws_summary.cell(row=current_r, column=1).border = top_thick_bottom_double
for idx, cam in enumerate(cameras):
col_let = get_column_letter(idx + 2)
c = ws_summary.cell(row=current_r, column=idx+2, value=f"=SUM({col_let}{start_row+1}:{col_let}{current_r-1})")
c.font = total_font
c.fill = total_fill
c.number_format = "#,##0"
c.alignment = Alignment(horizontal='right')
c.border = top_thick_bottom_double
final_col_let = get_column_letter(len(cameras) + 2)
tot_final = ws_summary.cell(row=current_r, column=len(cameras)+2, value=f"=SUM({final_col_let}{start_row+1}:{final_col_let}{current_r-1})")
tot_final.font = total_font
tot_final.fill = total_fill
tot_final.number_format = "#,##0"
tot_final.alignment = Alignment(horizontal='right')
tot_final.border = top_thick_bottom_double
# Add Chart to Summary Sheet
chart = BarChart()
chart.type = "col"
chart.style = 10
chart.title = "Daily Chicken Counts by Camera"
chart.y_axis.title = "Chicken Count"
chart.x_axis.title = "Date"
chart.width = 16
chart.height = 10
data_ref = Reference(ws_summary, min_col=2, min_row=start_row, max_col=len(cameras)+1, max_row=current_r-1)
cats_ref = Reference(ws_summary, min_col=1, min_row=start_row+1, max_row=current_r-1)
chart.add_data(data_ref, titles_from_data=True)
chart.set_categories(cats_ref)
ws_summary.add_chart(chart, "I9")
# -------------------------------------------------------------
# SHEET 2: Camera Breakdown
# -------------------------------------------------------------
ws_cam = wb.create_sheet(title="Camera Summary")
ws_cam.views.sheetView[0].showGridLines = True
ws_cam["A1"] = "Camera Performance Summary"
ws_cam["A1"].font = Font(name=font_family, size=14, bold=True, color="1F4E78")
cam_pivot = df.groupby('camera_id').agg(
total_entered=('total_entered', 'sum'),
avg_entered=('total_entered', 'mean'),
total_frames=('frames_processed', 'sum'),
total_elapsed_sec=('elapsed_seconds', 'sum'),
total_runs=('id', 'count')
).reset_index()
cam_headers = ["Camera ID", "Total Chicken Count", "Average Count / Run", "Total Frames", "Total Time (Minutes)", "Total Runs"]
for c_idx, h_text in enumerate(cam_headers, 1):
cell = ws_cam.cell(row=3, column=c_idx, value=h_text)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal='center')
for r_idx, row in cam_pivot.iterrows():
row_num = 4 + r_idx
ws_cam.cell(row=row_num, column=1, value=row['camera_id']).alignment = Alignment(horizontal='center')
ws_cam.cell(row=row_num, column=2, value=int(row['total_entered'])).number_format = "#,##0"
ws_cam.cell(row=row_num, column=3, value=round(row['avg_entered'], 1)).number_format = "#,##0.0"
ws_cam.cell(row=row_num, column=4, value=int(row['total_frames'])).number_format = "#,##0"
ws_cam.cell(row=row_num, column=5, value=round(row['total_elapsed_sec'] / 60, 2)).number_format = "#,##0.00"
ws_cam.cell(row=row_num, column=6, value=int(row['total_runs'])).number_format = "#,##0"
for c_idx in range(1, 7):
ws_cam.cell(row=row_num, column=c_idx).font = Font(name=font_family, size=11)
ws_cam.cell(row=row_num, column=c_idx).border = thin_border
# Total Row for Camera Summary
tot_row_cam = 4 + len(cam_pivot)
ws_cam.cell(row=tot_row_cam, column=1, value="Total").font = total_font
ws_cam.cell(row=tot_row_cam, column=1).fill = total_fill
ws_cam.cell(row=tot_row_cam, column=1).alignment = Alignment(horizontal='center')
ws_cam.cell(row=tot_row_cam, column=1).border = top_thick_bottom_double
for c_idx in [2, 4, 6]:
col_let = get_column_letter(c_idx)
c = ws_cam.cell(row=tot_row_cam, column=c_idx, value=f"=SUM({col_let}4:{col_let}{tot_row_cam-1})")
c.font = total_font
c.fill = total_fill
c.number_format = "#,##0"
c.border = top_thick_bottom_double
# Average for Avg column
c_avg = ws_cam.cell(row=tot_row_cam, column=3, value=f"=AVERAGE(C4:C{tot_row_cam-1})")
c_avg.font = total_font
c_avg.fill = total_fill
c_avg.number_format = "#,##0.0"
c_avg.border = top_thick_bottom_double
# Sum for elapsed
c_time = ws_cam.cell(row=tot_row_cam, column=5, value=f"=SUM(E4:E{tot_row_cam-1})")
c_time.font = total_font
c_time.fill = total_fill
c_time.number_format = "#,##0.00"
c_time.border = top_thick_bottom_double
# -------------------------------------------------------------
# SHEET 3: Raw Batch Runs
# -------------------------------------------------------------
ws_raw = wb.create_sheet(title="Raw Batch Runs")
ws_raw.views.sheetView[0].showGridLines = True
raw_headers = ["ID", "Date", "Location", "Camera ID", "Total Entered", "Frames Processed", "Elapsed (s)", "Stopped Reason", "Source Video", "Generated At"]
for c_idx, h_text in enumerate(raw_headers, 1):
cell = ws_raw.cell(row=1, column=c_idx, value=h_text)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal='center')
for r_idx, row in df.iterrows():
row_num = 2 + r_idx
ws_raw.cell(row=row_num, column=1, value=int(row['id'])).alignment = Alignment(horizontal='center')
ws_raw.cell(row=row_num, column=2, value=str(row['date'])).alignment = Alignment(horizontal='center')
ws_raw.cell(row=row_num, column=3, value=str(row['location'])).alignment = Alignment(horizontal='center')
ws_raw.cell(row=row_num, column=4, value=str(row['camera_id'])).alignment = Alignment(horizontal='center')
ws_raw.cell(row=row_num, column=5, value=int(row['total_entered'])).number_format = "#,##0"
ws_raw.cell(row=row_num, column=6, value=int(row['frames_processed'])).number_format = "#,##0"
ws_raw.cell(row=row_num, column=7, value=float(row['elapsed_seconds'])).number_format = "#,##0.0"
ws_raw.cell(row=row_num, column=8, value=str(row['stopped_reason'])).alignment = Alignment(horizontal='center')
ws_raw.cell(row=row_num, column=9, value=str(row['source_video']))
ws_raw.cell(row=row_num, column=10, value=str(row['generated_at']))
for c_idx in range(1, 11):
ws_raw.cell(row=row_num, column=c_idx).font = Font(name=font_family, size=10)
ws_raw.cell(row=row_num, column=c_idx).border = thin_border
# -------------------------------------------------------------
# SHEET 4: Configurations
# -------------------------------------------------------------
ws_cfg = wb.create_sheet(title="Configurations")
ws_cfg.views.sheetView[0].showGridLines = True
ws_cfg["A1"] = "Pipeline & Model Configurations"
ws_cfg["A1"].font = Font(name=font_family, size=14, bold=True, color="1F4E78")
# Global Settings Table
ws_cfg["A3"] = "Global Pipeline & Detection Settings"
ws_cfg["A3"].font = Font(name=font_family, size=11, bold=True, color="1F4E78")
global_configs = [
("Hardware / Target Platform", "ASUS NUC (AMD Ryzen 9 9955HX + NVIDIA GeForce RTX 5070 8GB)"),
("Model Path (Engine)", "models/NUC5070_chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine"),
("Model Architecture", "YOLOv8/v26n TensorRT FP16 compiled engine"),
("Inference Image Size (imgsz)", "640 x 640"),
("IoU Threshold", "0.55"),
("Default Confidence Threshold (conf)", "0.40"),
("Target Classes", "[0] (Ignored: [1, 2])"),
("Default Min Box Area (px)", "3000"),
("Tracker Architecture", "BoT-SORT (persist=True, track_buffer=90)"),
("Gate Counting Mode", "two_line (lines_y: [420, 730], direction: bottom_to_up)"),
("Motion Flow Analysis", "vertical (forward_sign: 1.0, ema_alpha: 0.2, min_features: 40)"),
("Inference Stride", "2 frames (motion.stride_frames: 3)"),
("Execution Mode", "parallel_processes (MAX_JOBS=4)")
]
ws_cfg.cell(row=4, column=1, value="Configuration Parameter").font = header_font
ws_cfg.cell(row=4, column=1).fill = header_fill
ws_cfg.cell(row=4, column=2, value="Setting / Value").font = header_font
ws_cfg.cell(row=4, column=2).fill = header_fill
for idx, (param, val) in enumerate(global_configs, start=5):
c1 = ws_cfg.cell(row=idx, column=1, value=param)
c2 = ws_cfg.cell(row=idx, column=2, value=val)
c1.font = Font(name=font_family, size=10, bold=True)
c2.font = Font(name=font_family, size=10)
c1.border = thin_border
c2.border = thin_border
c1.fill = accent_fill
# Stages Table
stage_start_row = 5 + len(global_configs) + 2
ws_cfg.cell(row=stage_start_row-1, column=1, value="Cycle Stage Adaptation Rules").font = Font(name=font_family, size=11, bold=True, color="1F4E78")
stage_configs = [
("early_cycle (Day 0 - 15)", "conf: 0.10, min_box_area_px: 100, min_overlap_ratio: 0.10 (Chicks adaptation)"),
("mid_cycle (Day 16+)", "conf: 0.40, min_box_area_px: 3000, default filters (Grown chicken standard)")
]
ws_cfg.cell(row=stage_start_row, column=1, value="Cycle Stage").font = header_font
ws_cfg.cell(row=stage_start_row, column=1).fill = header_fill
ws_cfg.cell(row=stage_start_row, column=2, value="Applied Overrides").font = header_font
ws_cfg.cell(row=stage_start_row, column=2).fill = header_fill
for idx, (stg, desc) in enumerate(stage_configs, start=stage_start_row+1):
c1 = ws_cfg.cell(row=idx, column=1, value=stg)
c2 = ws_cfg.cell(row=idx, column=2, value=desc)
c1.font = Font(name=font_family, size=10, bold=True)
c2.font = Font(name=font_family, size=10)
c1.border = thin_border
c2.border = thin_border
c1.fill = accent_fill
# Per Camera Settings Table
cam_cfg_start = stage_start_row + len(stage_configs) + 3
ws_cfg.cell(row=cam_cfg_start-1, column=1, value="Per-Camera Specific Configurations").font = Font(name=font_family, size=11, bold=True, color="1F4E78")
cam_headers_cfg = ["Camera ID", "Count Anchor [X, Y]", "Confidence (conf)", "Min Box Area (px)", "Dedupe Settings", "ROI Polygon Points [X, Y]"]
for c_idx, h in enumerate(cam_headers_cfg, 1):
cell = ws_cfg.cell(row=cam_cfg_start, column=c_idx, value=h)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal='center')
cam_details = [
("CC1", "[780, 120]", "0.35", "2500", "Disabled", "[(250, 330), (1650, 330), (1650, 720), (250, 720)]"),
("CC2", "[900, 120]", "0.50", "3000", "radius=32px, frames=24", "[(20, 380), (1880, 380), (1880, 720), (20, 720)]"),
("CC3", "[900, 120]", "0.40", "3000", "radius=24px, frames=16", "[(20, 330), (1880, 330), (1880, 720), (20, 720)]"),
("CC4", "[700, 120]", "0.40", "3000", "Disabled", "[(50, 330), (1450, 330), (1450, 720), (50, 720)]")
]
for r_offset, r_data in enumerate(cam_details, 1):
curr_row = cam_cfg_start + r_offset
for col_i, val in enumerate(r_data, 1):
cell = ws_cfg.cell(row=curr_row, column=col_i, value=val)
cell.font = Font(name=font_family, size=10)
cell.border = thin_border
if col_i in [1, 2, 3, 4]:
cell.alignment = Alignment(horizontal='center')
# Auto-adjust column widths across all sheets
for ws in wb.worksheets:
for col in ws.columns:
max_len = 0
col_letter = get_column_letter(col[0].column)
for cell in col:
# Avoid large width from title/merged cells
if cell.row < 3 and ws.title == "Daily Summary":
continue
val_str = str(cell.value or '')
if len(val_str) > max_len:
max_len = len(val_str)
ws.column_dimensions[col_letter].width = max(max_len + 4, 12)
ws_summary.column_dimensions['A'].width = 16
ws_summary.column_dimensions['B'].width = 14
ws_summary.column_dimensions['C'].width = 14
ws_summary.column_dimensions['D'].width = 14
ws_summary.column_dimensions['E'].width = 14
ws_summary.column_dimensions['F'].width = 16
wb.save(output_excel_path)
print(f"Excel report successfully generated: {output_excel_path}")
if __name__ == "__main__":
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
db_file = str(BASE_DIR / "db" / "chicken_counts.db")
out_file = str(BASE_DIR / "db" / "chicken_counts_report.xlsx")
generate_excel_report(db_file, out_file)
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# Portable Systemd Service Installer for Chicken Counter Dashboard
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CURRENT_USER="$(id -un)"
SERVICE_NAME="chicken-dashboard.service"
echo "=== Chicken Dashboard Service Installer ==="
echo "Target User: $CURRENT_USER"
echo "Project Dir: $SCRIPT_DIR"
# Check if user wants user-level systemd (no root required) or system-level systemd
USER_SERVICE_DIR="$HOME/.config/systemd/user"
mkdir -p "$USER_SERVICE_DIR"
cat <<EOF > "$USER_SERVICE_DIR/$SERVICE_NAME"
[Unit]
Description=Chicken Counter Live Dashboard
After=network.target
[Service]
Type=simple
WorkingDirectory=$SCRIPT_DIR
ExecStart=$SCRIPT_DIR/start_dashboard.sh
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1
Environment=PORT=8080
[Install]
WantedBy=default.target
EOF
echo ""
echo "✅ Installed user service to: $USER_SERVICE_DIR/$SERVICE_NAME"
echo ""
echo "To enable and start the dashboard service (runs on boot without sudo):"
echo " systemctl --user daemon-reload"
echo " systemctl --user enable --now $SERVICE_NAME"
echo " loginctl enable-linger $CURRENT_USER # Ensures service runs even if logged out"
echo ""
echo "To check status:"
echo " systemctl --user status $SERVICE_NAME"
echo ""
Binary file not shown.
+124
View File
@@ -0,0 +1,124 @@
apturl==0.5.2
bcrypt==3.2.0
beniget==0.4.1
blinker==1.9.0
Brlapi==0.8.3
certifi==2020.6.20
chardet==4.0.0
click==8.4.2
colorama==0.4.4
contourpy==1.3.2
cpuset==1.6
cryptography==3.4.8
cupshelpers==1.0
cycler==0.12.1
dbus-python==1.2.18
decorator==4.4.2
defer==1.0.6
distro==1.7.0
distro-info==1.1+ubuntu0.2
dnspython==2.1.0
duplicity==0.8.21
et_xmlfile==2.0.0
fasteners==0.14.1
filelock==3.29.7
Flask==3.1.3
fonttools==4.63.0
fsspec==2026.6.0
future==0.18.2
gast==0.5.2
gpg==1.16.0
httplib2==0.20.2
idna==3.3
importlib-metadata==4.6.4
itsdangerous==2.2.0
jeepney==0.7.1
jetson-stats==4.3.2
Jetson.GPIO==2.1.7
Jinja2==3.1.6
keyring==23.5.0
kiwisolver==1.5.0
language-selector==0.1
lap==0.5.13
launchpadlib==1.10.16
lazr.restfulclient==0.14.4
lazr.uri==1.0.6
lockfile==0.12.2
louis==3.20.0
lxml==4.8.0
macaroonbakery==1.3.1
Mako==1.1.3
Markdown==3.3.6
MarkupSafe==3.0.3
matplotlib==3.10.9
ml_dtypes==0.5.4
monotonic==1.6
more-itertools==8.10.0
mpmath==1.3.0
networkx==3.4.2
numpy==1.26.4
nvidia-ml-py==13.610.43
oauthlib==3.2.0
olefile==0.46
onboard==1.4.1
onnx==1.22.0
opencv-python==4.11.0.86
openpyxl==3.1.5
packaging==26.2
paho-mqtt==2.1.0
paramiko==2.9.3
pexpect==4.8.0
Pillow==9.0.1
ply==3.11
polars==1.42.1
polars-runtime-32==1.42.1
protobuf==7.35.1
psutil==7.2.2
ptyprocess==0.7.0
pycairo==1.20.1
pycups==2.0.1
Pygments==2.11.2
PyGObject==3.42.1
PyJWT==2.3.0
pymacaroons==0.13.0
PyNaCl==1.5.0
PyOpenGL==3.1.5
pyparsing==3.3.2
pyRFC3339==1.1
pyservicemaker @ file:///opt/nvidia/deepstream/deepstream-7.1/service-maker/python/pyservicemaker-0.0.1-py3-none-linux_aarch64.whl
python-apt==2.4.0+ubuntu4.1
python-dateutil==2.8.1
python-dbusmock==0.27.5
python-debian==0.1.43+ubuntu1.1
python-dotenv==1.2.2
pythran==0.10.0
pytz==2022.1
pyxdg==0.27
PyYAML==6.0.3
ranger-fm==1.9.3
requests==2.25.1
requests-toolbelt==0.9.1
scipy==1.8.0
SecretStorage==3.3.1
six==1.16.0
smbus2==0.5.0
sympy==1.13.1
systemd-python==234
tensorrt==10.3.0
tensorrt_dispatch==10.3.0
tensorrt_lean==10.3.0
torch @ https://developer.download.nvidia.com/compute/redist/jp/v61/pytorch/torch-2.5.0a0+872d972e41.nv24.08.17622132-cp310-cp310-linux_aarch64.whl
torchvision @ file:///home/jetson/Downloads/.torch-2.5.0-nv24.08/torchvision-0.20.1a0%2B3ac97aa-cp310-cp310-linux_aarch64.whl#sha256=06658c32ef66451be301d52f2310c64689af299ea247dc262d5563f2a557ee3d
typing_extensions==4.16.0
ubuntu-drivers-common==0.0.0
ubuntu-pro-client==8001
ufw==0.36.1
ultralytics==8.4.90
ultralytics-thop==2.0.20
urllib3==1.26.5
urwid==2.1.2
wadllib==1.3.6
Werkzeug==3.1.8
xdg==5
xkit==0.0.0
zipp==1.0.0
View File
Whitespace-only changes.
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
# Master runner for batch mortality detection across all coops
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PYTHONPATH="$SCRIPT_DIR/src"
if [ -f "$SCRIPT_DIR/venv/bin/python" ]; then
PYTHON="$SCRIPT_DIR/venv/bin/python"
else
PYTHON="python3"
fi
DATE_OR_COOP="${1:-}" # Optional: date (e.g. 2026-06-18) or coop (e.g. K1) or empty for all
CONFIG="${2:-configs/mortality_config.yaml}"
VIDEOS_BASE="$SCRIPT_DIR/../VIDEOS"
echo "================================================================="
echo " Starting Batch Multi-Coop Mortality Detection"
if [ -n "$DATE_OR_COOP" ]; then
echo " Target Filter: $DATE_OR_COOP"
fi
echo " Base Videos: $VIDEOS_BASE"
echo "================================================================="
if [[ "$DATE_OR_COOP" =~ ^[Kk][0-9]+$ ]] || [[ "$DATE_OR_COOP" =~ ^kandang.* ]]; then
# Run specific coop
echo ">>> Running mortality for coop: $DATE_OR_COOP <<<"
$PYTHON -m chicken_counter.cli mortality --config "$CONFIG" --coop "$DATE_OR_COOP" --videos-root "$VIDEOS_BASE"
elif [[ "$DATE_OR_COOP" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
# Run all coops for specific date
echo ">>> Running all coops for date: $DATE_OR_COOP <<<"
$PYTHON -m chicken_counter.cli mortality --config "$CONFIG" --all-coops --date "$DATE_OR_COOP" --videos-root "$VIDEOS_BASE"
else
# Run all coops for all available mortality folders
echo ">>> Running all discovered coop mortality folders <<<"
$PYTHON -m chicken_counter.cli mortality --config "$CONFIG" --all-coops --videos-root "$VIDEOS_BASE"
fi
echo ""
echo "================================================================="
echo " Batch Mortality Run Completed."
echo "================================================================="
-411
View File
@@ -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.
-24
View File
@@ -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
Binary file not shown.
+55 -4
View File
@@ -6,6 +6,7 @@ from dataclasses import dataclass, field
from pathlib import Path
from chicken_counter.config import BatchSettings
from chicken_counter.discovery import resolve_floor_video_dir
@dataclass
@@ -16,22 +17,72 @@ class CameraDiscoveryResult:
def discover_camera_videos(day_dir: Path, settings: BatchSettings) -> CameraDiscoveryResult:
if not day_dir.is_dir():
raise FileNotFoundError(f"Daily input folder does not exist: {day_dir}")
# Startup check: Auto-create skeleton root_dir if missing
root_p = resolve_floor_video_dir(settings.batch.root_dir, settings.batch.location)
if not root_p.exists():
root_p.mkdir(parents=True, exist_ok=True)
print(f"[batch] 📁 Created missing video root directory: {root_p}")
raise FileNotFoundError(
f"Daily input folder does not exist: {day_dir}\n"
f"👉 Place camera video files (e.g. {settings.batch.location}_cam1_*.mp4 .. cam4_*.mp4) inside: {day_dir}"
)
result = CameraDiscoveryResult()
total_configured = len(settings.cameras)
# Exclude output videos and non-video files
def _is_valid_source_video(path: Path) -> bool:
name = path.name.lower()
if name.endswith(("_vis.mp4", "_compressed.mp4", "_overlay.mp4", "_preview.mp4")):
return False
if "output" in path.parts:
return False
return path.suffix.lower() in (".mp4", ".avi", ".mkv", ".mov")
for camera_id, preset in sorted(
settings.cameras.items(),
key=lambda item: item[1].camera_num,
):
pattern = settings.batch.camera_glob.format(num=preset.camera_num)
matches = sorted(day_dir.glob(pattern))
num = preset.camera_num
primary_pattern = settings.batch.camera_glob.format(num=num)
matches = [p for p in sorted(day_dir.glob(primary_pattern)) if _is_valid_source_video(p)]
# Fallback candidate patterns if primary pattern doesn't match
if not matches:
fallback_patterns = [
f"*cam{num}_*.mp4",
f"*cam_{num}_*.mp4",
f"*camera_{num}_*.mp4",
f"*camera{num}_*.mp4",
f"*_c{num}_*.mp4",
f"*cam{num}.mp4",
f"*camera{num}.mp4",
f"*camera_{num}.mp4",
]
for fb_pat in fallback_patterns:
matches = [p for p in sorted(day_dir.glob(fb_pat)) if _is_valid_source_video(p)]
if matches:
break
# Case-insensitive fallback search
if not matches:
for f in sorted(day_dir.iterdir()):
if f.is_file() and _is_valid_source_video(f):
fname = f.name.lower()
if (
f"cam{num}" in fname
or f"cam_{num}" in fname
or f"camera_{num}" in fname
or f"camera{num}" in fname
or f"_c{num}" in fname
):
matches.append(f)
if not matches:
result.skipped[camera_id] = "video_not_found"
continue
if len(matches) > 1:
result.skipped[camera_id] = "multiple_matches"
result.skipped[camera_id] = f"multiple_matches ({[m.name for m in matches]})"
continue
result.found[camera_id] = matches[0]
+620 -25
View File
@@ -1,44 +1,624 @@
"""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.discovery import resolve_floor_video_dir
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.tracking import DetectionTracker
from chicken_counter.types import CameraBatchResult
from chicken_counter.types import CameraBatchResult, FrameResult, PipelineResult, TrackObservation
def run_daily_batch(settings: BatchSettings, date: str | None = None) -> Path:
def _store_to_db(report_path: Path, location: str, db_path: str) -> None:
if not location or not db_path:
return
import json
import sqlite3
try:
with open(report_path) as f:
report = json.load(f)
except Exception:
return
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
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,
date TEXT NOT NULL, location TEXT NOT NULL, camera_id TEXT NOT NULL,
total_entered INTEGER NOT NULL DEFAULT 0,
frames_processed INTEGER NOT NULL DEFAULT 0,
elapsed_seconds REAL NOT NULL DEFAULT 0.0,
stopped_reason TEXT NOT NULL DEFAULT '',
source_video TEXT NOT NULL DEFAULT '',
generated_at TEXT NOT NULL DEFAULT '',
UNIQUE(date, location, camera_id))""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_batch_date ON batch_runs (date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_batch_camera ON batch_runs (camera_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_batch_location ON batch_runs (location)")
date = report["date"]
for camera_id, entry in report["cameras"].items():
if entry.get("skipped"):
continue
conn.execute("""INSERT INTO batch_runs
(date, location, camera_id, total_entered, frames_processed,
elapsed_seconds, stopped_reason, source_video, generated_at)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT(date, location, camera_id) DO UPDATE SET
total_entered=excluded.total_entered,
frames_processed=excluded.frames_processed,
elapsed_seconds=excluded.elapsed_seconds,
stopped_reason=excluded.stopped_reason,
source_video=excluded.source_video,
generated_at=excluded.generated_at""",
(date, location, camera_id,
entry.get("total_entered", 0),
entry.get("frames_processed", 0),
entry.get("elapsed_seconds", 0),
entry.get("stopped_reason", ""),
entry.get("source_video", ""),
report.get("generated_at", "")))
conn.commit()
conn.close()
print(f"[db] stored {date} ({location}) → {db_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
floor_root = resolve_floor_video_dir(settings.batch.root_dir, settings.batch.location)
day_dir = floor_root / run_date
output_dir = day_dir / settings.batch.output_subdir
output_dir.mkdir(parents=True, exist_ok=True)
print(f"[batch] starting daily run for {run_date}")
print(f"[batch] input folder: {day_dir}")
print(f"[batch] output folder: {output_dir}")
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)
first_camera_id = next(
camera_id for camera_id, _preset in camera_order if camera_id in discovery.found
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):
current_stream_ids = [active_cams[idx][0] for idx in current_active]
batch_tracks_list = shared_tracker.infer_batch(
current_frames, stream_ids=current_stream_ids, 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}"
)
first_source = discovery.found[first_camera_id]
init_config = build_camera_config_from_batch(
settings,
first_camera_id,
source=first_source,
output_path=output_dir / f"{first_camera_id}_vis.mp4",
checkpoint_dir=output_dir / "checkpoints" / first_camera_id,
_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()
floor_root = resolve_floor_video_dir(settings.batch.root_dir, settings.batch.location)
day_dir = floor_root / 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):
current_stream_ids = [active_cams[idx][0] for idx in current_active_now]
batch_tracks_list = shared_tracker.infer_batch(
current_frames, stream_ids=current_stream_ids, 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}"
)
shared_tracker = DetectionTracker(init_config)
_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()
floor_root = resolve_floor_video_dir(settings.batch.root_dir, settings.batch.location)
day_dir = floor_root / 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}")
# Clean all /dev/shm counters from previous runs
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
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"
@@ -55,10 +635,11 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None) -> Path:
)
)
persist_batch_reports(run_date, camera_results, output_dir)
_store_to_db(report_path, settings.batch.location, settings.batch.db_path)
continue
source_path = discovery.found[camera_id]
vis_path = output_dir / f"{camera_id}_vis.mp4"
vis_path = output_dir / f"{camera_id}_vis.mp4" if not no_video else None
checkpoint_dir = output_dir / "checkpoints" / camera_id
print(f"[batch] processing {camera_id} from {source_path.name}")
@@ -68,8 +649,10 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None) -> Path:
source=source_path,
output_path=vis_path,
checkpoint_dir=checkpoint_dir,
date=run_date,
)
pipeline_result = run_pipeline(camera_config, tracker=shared_tracker)
camera_config.performance.verbose = verbose
pipeline_result = run_pipeline(camera_config, show_progress=show_progress, run_date=run_date)
camera_results.append(
CameraBatchResult(
camera_id=camera_id,
@@ -81,6 +664,17 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None) -> Path:
f"frames={pipeline_result.frames_processed} reason={pipeline_result.stopped_reason}"
)
persist_batch_reports(run_date, camera_results, output_dir)
_store_to_db(report_path, settings.batch.location, settings.batch.db_path)
if no_video:
persist_batch_reports(run_date, camera_results, output_dir)
report = build_batch_report(run_date, camera_results, output_dir=output_dir)
print(
f"[batch] complete for {run_date}: total_entered_sum={report.total_entered_sum} "
f"report={report_path}"
)
_store_to_db(report_path, settings.batch.location, settings.batch.db_path)
return report_path
print("[batch] all cameras complete; starting compression")
for item in camera_results:
@@ -108,4 +702,5 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None) -> Path:
f"[batch] 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
+120 -6
View File
@@ -5,7 +5,15 @@ from __future__ import annotations
import argparse
from chicken_counter.batch_runner import run_daily_batch
from chicken_counter.config import load_batch_config, load_camera_config
from chicken_counter.config import load_batch_config, load_camera_config, load_or_synthesize_floor_config
from chicken_counter.discovery import discover_farm_structure, sync_floor_configs
from chicken_counter.mortality import (
DEFAULT_CONFIG_PATH,
DEFAULT_MORTALITY_DIR,
DEFAULT_MODEL_PATH,
run_all_coop_mortality,
run_mortality_count,
)
from chicken_counter.pipeline import run_pipeline
@@ -16,13 +24,57 @@ def build_parser() -> argparse.ArgumentParser:
run_parser = subparsers.add_parser("run", help="Run a single camera pipeline.")
run_parser.add_argument("--config", required=True, help="Path to camera config YAML/JSON.")
run_parser.add_argument("--camera-id", help="Camera ID when using a multi-camera config file.")
run_parser.add_argument("--verbose", action="store_true", help="Enable debug-level logging.")
run_parser.add_argument("--progress-bar", action="store_true", help="Show a terminal progress bar.")
batch_parser = subparsers.add_parser("batch", help="Run the daily Cycle7 multi-camera batch.")
batch_parser.add_argument("--config", required=True, help="Path to batch config YAML/JSON.")
batch_parser.add_argument(
"--config",
"--floor",
"--location",
dest="config",
required=True,
help="Path to batch config YAML/JSON or floor identifier (e.g. K1-L1, K6-L2).",
)
batch_parser.add_argument("--base-config", default=None, help="Base config to extend when using floor identifier.")
batch_parser.add_argument(
"--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.")
sync_parser = subparsers.add_parser("sync-configs", help="Auto-generate missing configs/floor_config/*.yaml files from VIDEOS.")
sync_parser.add_argument("--videos-root", default=None, help="Base VIDEOS directory.")
sync_parser.add_argument("--configs-dir", default=None, help="Destination configs directory.")
sync_parser.add_argument("--dry-run", action="store_true", help="Preview generated configs without writing files.")
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("--coop", default=None, help="Specific coop/cage ID to process (e.g. K1, K2).")
mortality_parser.add_argument("--all-coops", action="store_true", help="Batch-process mortality for ALL discovered coops.")
mortality_parser.add_argument("--videos-root", default=None, help="Base VIDEOS directory (defaults to ../VIDEOS).")
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)
@@ -33,14 +85,75 @@ def main() -> None:
parser = build_parser()
args = parser.parse_args()
if args.command == "sync-configs":
sync_floor_configs(
videos_root=args.videos_root,
configs_dir=args.configs_dir,
dry_run=args.dry_run,
)
return
if args.command == "mortality":
if getattr(args, "all_coops", False):
run_all_coop_mortality(
videos_root=args.videos_root,
date=getattr(args, "date", None),
config_path=args.config,
conf_threshold=args.conf,
iou_threshold=args.iou,
model_path=args.model_path,
two_pass=args.two_pass,
)
return
input_path = args.input
target_coop = getattr(args, "coop", None)
if not input_path and target_coop:
farm_struct = discover_farm_structure(args.videos_root)
coop_info = farm_struct.get_coop(target_coop)
if coop_info and coop_info.mortality_dir and coop_info.mortality_dir.is_dir():
input_path = str(coop_info.mortality_dir)
else:
print(f"[mortality] ⚠️ Mortality directory for coop {target_coop} not found.")
run_mortality_count(
input_path=input_path,
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),
coop=target_coop,
)
return
if args.command == "batch":
settings = load_batch_config(args.config)
run_daily_batch(settings, date=args.date)
settings = load_or_synthesize_floor_config(args.config, base_config_path=getattr(args, "base_config", None))
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":
config = load_camera_config(args.config, args.camera_id)
result = run_pipeline(config)
config.performance.verbose = args.verbose
result = run_pipeline(config, show_progress=args.progress_bar)
print(
f"[done] camera={result.camera_id} total_entered={result.total_entered_count} "
f"frames={result.frames_processed} reason={result.stopped_reason}"
@@ -49,7 +162,8 @@ def main() -> None:
if args.config:
config = load_camera_config(args.config, args.camera_id)
result = run_pipeline(config)
config.performance.verbose = args.verbose
result = run_pipeline(config, show_progress=args.progress_bar)
print(
f"[done] camera={result.camera_id} total_entered={result.total_entered_count} "
f"frames={result.frames_processed} reason={result.stopped_reason}"
+2
View File
@@ -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"],
]
+170 -10
View File
@@ -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)]
@@ -168,6 +185,14 @@ class PerformanceConfig:
half: bool = False
overlay_buffer_reuse: bool = True
inference_stride: int = 1
verbose: bool = False
@dataclass
class StreamConfig:
enabled: bool = False
shm_dir: str = "/dev/shm"
interval_frames: int = 5
@dataclass
@@ -193,6 +218,8 @@ class CameraConfig:
performance: PerformanceConfig
feedback: FeedbackConfig
detection_zone: DetectionZoneConfig = field(default_factory=DetectionZoneConfig)
stream: StreamConfig = field(default_factory=StreamConfig)
config_path: str | None = None
@dataclass
@@ -203,6 +230,10 @@ class BatchConfig:
compress_max_mb: int = 200
delete_intermediate: bool = False
checkpoint_every_n_frames: int = 3000
location: str = ""
db_path: str = ""
cycle_start_date: str | None = None
execution_mode: str = "parallel_processes"
@dataclass
@@ -213,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
@@ -220,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]:
@@ -241,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:
@@ -256,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"],
@@ -269,6 +313,8 @@ def _build_camera_config(raw: dict[str, Any]) -> CameraConfig:
performance=PerformanceConfig(**raw.get("performance", {})),
feedback=FeedbackConfig(**raw.get("feedback", {})),
detection_zone=DetectionZoneConfig(**raw.get("detection_zone", {})),
stream=StreamConfig(**raw.get("stream", {})),
config_path=raw.get("config_path"),
)
@@ -283,9 +329,19 @@ 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)
base_ref = raw.pop("extends", None) or raw.pop("base_config", None)
if base_ref:
base_path = (config_path.parent / base_ref).resolve()
if not base_path.exists():
base_path = resolve_project_path(base_ref)
if not base_path.exists():
raise FileNotFoundError(f"Base config not found: {base_ref} (referenced in {config_path.name})")
base_raw = _load_data(base_path)
raw = _deep_merge(base_raw, raw)
if "batch" in raw:
raise ValueError(
"This is a batch config file. Use 'chicken-counter batch --config ...' instead."
@@ -296,18 +352,36 @@ 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)
base_ref = raw.pop("extends", None) or raw.pop("base_config", None)
if base_ref:
base_path = (config_path.parent / base_ref).resolve()
if not base_path.exists():
base_path = resolve_project_path(base_ref)
if not base_path.exists():
raise FileNotFoundError(f"Base config not found: {base_ref} (referenced in {config_path.name})")
base_raw = _load_data(base_path)
raw = _deep_merge(base_raw, raw)
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():
@@ -321,6 +395,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"]),
@@ -328,9 +413,51 @@ 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 load_or_synthesize_floor_config(
floor_or_path: str | Path,
base_config_path: str | Path | None = None,
) -> BatchSettings:
"""Load an existing floor config YAML or dynamically synthesize one from the base config."""
p = Path(floor_or_path)
if p.is_file():
return load_batch_config(p)
# Check if a matching config exists in configs/floor_config/
floor_str = str(floor_or_path).strip()
candidate_yaml = PROJECT_ROOT / "configs" / "floor_config" / f"{floor_str}.yaml"
if candidate_yaml.is_file():
return load_batch_config(candidate_yaml)
# Synthesize in-memory BatchSettings
base_cfg = Path(base_config_path).resolve() if base_config_path else (PROJECT_ROOT / "configs" / "cycle7_batch_optimized.yaml")
if not base_cfg.is_file():
raise FileNotFoundError(f"Base batch config not found: {base_cfg}")
base_settings = load_batch_config(base_cfg)
# Dynamic floor synthesis
from chicken_counter.discovery import get_coop_for_location
coop = get_coop_for_location(floor_str)
floor_sub = floor_str.replace(f"{coop}-", "").replace(f"{coop}_", "")
synth_root = PROJECT_ROOT.parent / "VIDEOS" / "cycle7" / coop / floor_sub
# Override location and root_dir
base_settings.batch.location = floor_str
base_settings.batch.root_dir = str(synth_root.resolve())
base_settings.config_path = f"[virtual:{floor_str}]"
return base_settings
def build_camera_config_from_batch(
@@ -338,14 +465,47 @@ def build_camera_config_from_batch(
camera_id: str,
*,
source: str | Path,
output_path: 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]
@@ -378,13 +538,13 @@ def build_camera_config_from_batch(
raw["overlay"]["count_anchor"] = list(preset.count_anchor)
raw.setdefault("display", {})
raw["display"]["output_path"] = str(output_path)
raw["display"]["output_path"] = str(output_path) if output_path is not None else None
raw["display"]["show_window"] = False
raw.setdefault("feedback", {})
raw["feedback"]["enabled"] = True
raw["feedback"]["every_n_frames"] = settings.batch.checkpoint_every_n_frames
raw["feedback"]["save_images"] = True
raw["feedback"]["save_images"] = output_path is not None
raw["feedback"]["image_output_dir"] = str(checkpoint_dir)
raw["feedback"]["log_to_terminal"] = True
+92 -14
View File
@@ -20,6 +20,10 @@ 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:
self.roi = roi
self.gate = gate
@@ -28,6 +32,9 @@ 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
self.histories: dict[int, deque[tuple[int, int]]] = defaultdict(lambda: deque(maxlen=trail_length))
@@ -37,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()
@@ -48,12 +57,30 @@ class CountingZone:
counting_paused: bool = False,
) -> list[CountEvent]:
events: list[CountEvent] = []
if not tracks:
self.inside_box_count = 0
self.current_inside_ids.clear()
self.prev_inside_ids.clear()
self._purge_stale(frame_index, set())
return events
active_ids = set()
inside_ids = set()
rx1, ry1, rx2, ry2 = self._counting_rect
for track in tracks:
active_ids.add(track.track_id)
self.last_seen_frame[track.track_id] = frame_index
self.histories[track.track_id].append(track.centroid)
if self.trail_length > 0:
self.histories[track.track_id].append(track.centroid)
# fast-reject: bounding rect check before pointPolygonTest
cx, cy = track.centroid
if not (rx1 <= cx <= rx2 and ry1 <= cy <= ry2):
continue
if self._inside_roi(track.centroid):
inside_ids.add(track.track_id)
@@ -61,10 +88,9 @@ class CountingZone:
if counting_paused:
continue
if track.track_id not in inside_ids or track.track_id in self.counted_ids:
if track.track_id in self.counted_ids:
continue
should_validate = False
if self.validate_while_inside:
should_validate = self._meets_validation_thresholds(track)
else:
@@ -73,24 +99,50 @@ 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.total_entered_count += 1
self.sequence_numbers_by_track_id[track.track_id] = self.total_entered_count
self.latest_validated_track_id = track.track_id
events.append(
CountEvent(
track_id=track.track_id,
frame_index=frame_index,
total_entered_after_event=self.total_entered_count,
sequence_number=self.sequence_numbers_by_track_id[track.track_id],
self.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,
frame_index=frame_index,
total_entered_after_event=self.total_entered_count,
sequence_number=self.sequence_numbers_by_track_id[track.track_id],
)
)
if self.verbose:
x1, y1, x2, y2 = track.bbox_xyxy
bbox_area = max(0, x2 - x1) * max(0, y2 - y1)
overlap = self._bbox_overlap_ratio(track)
print(
f"[count] track={track.track_id} seq=#{self.total_entered_count} "
f"frame={frame_index} area={bbox_area} overlap={overlap:.2f} "
f"conf={track.confidence:.2f} centroid={track.centroid}"
)
self.inside_box_count = len(inside_ids)
self.current_inside_ids = inside_ids
self.prev_inside_ids = inside_ids
self._purge_stale(frame_index, active_ids)
if frame_index % 30 == 0:
self._purge_stale(frame_index, active_ids)
return events
def trail_for(self, track_id: int) -> list[tuple[int, int]]:
@@ -138,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
@@ -149,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)
+284
View File
@@ -0,0 +1,284 @@
"""Discovery engine for flexible, dynamic coop and floor detection across poultry farm sites."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_VIDEOS_ROOT = (PROJECT_ROOT.parent / "VIDEOS").resolve()
DEFAULT_CONFIGS_DIR = (PROJECT_ROOT / "configs" / "floor_config").resolve()
# Patterns for coop & floor extraction
COOP_FLOOR_PATTERN = re.compile(
r"^(?P<coop>[Kk]\d+|kandang[a-zA-Z0-9_-]*?)[-_]?(?P<floor>[Ll]\d+|floor\d+)?$",
re.IGNORECASE,
)
FLOOR_SUBDIR_PATTERN = re.compile(r"^(?:[Ll]|floor)[-_]?(?P<num>\d+)$", re.IGNORECASE)
@dataclass
class CoopInfo:
"""Represents a detected coop/cage (e.g. K1, K2) with its constituent floors and mortality folder."""
coop_id: str
coop_dir: Path | None = None
floors: dict[str, Path] = field(default_factory=dict) # floor_id (e.g. "K1-L1" or "L1") -> path
mortality_dir: Path | None = None
@property
def covered_floors(self) -> list[str]:
"""Return list of canonical floor names (e.g. ['K1-L1', 'K1-L2', 'K1-L3'])."""
if not self.floors:
return [self.coop_id]
names = []
for fid in sorted(self.floors.keys()):
if fid.upper() == self.coop_id.upper() or fid.startswith(f"{self.coop_id}-"):
names.append(fid)
elif fid.upper().startswith("L") or fid.lower().startswith("floor"):
names.append(f"{self.coop_id}-{fid.upper()}")
else:
names.append(f"{self.coop_id}-{fid}")
return sorted(list(set(names)))
@dataclass
class FarmStructure:
"""Complete dynamic farm structure of coops and floors detected on this site."""
coops: dict[str, CoopInfo] = field(default_factory=dict)
videos_root: Path | None = None
def get_coop(self, coop_id: str) -> CoopInfo | None:
"""Get CoopInfo by coop_id (case-insensitive)."""
for cid, info in self.coops.items():
if cid.upper() == coop_id.upper():
return info
return None
def get_covered_floors(self, coop_id: str) -> list[str]:
"""Return all floor IDs associated with the given coop_id."""
coop = self.get_coop(coop_id)
if coop:
return coop.covered_floors
return [coop_id]
def all_mortality_dirs(self) -> dict[str, Path]:
"""Return mapping of coop_id -> mortality directory path for all coops with mortality folders."""
result: dict[str, Path] = {}
for coop_id, coop in self.coops.items():
if coop.mortality_dir and coop.mortality_dir.is_dir():
result[coop_id] = coop.mortality_dir
return result
def get_coop_for_location(location: str) -> str:
"""Extract parent coop ID from a floor or location string.
Examples:
'K1-L1' -> 'K1'
'K2_L3' -> 'K2'
'K3/L2' -> 'K3'
'kandang-atas' -> 'kandang-atas'
'K4' -> 'K4'
"""
loc = location.replace("\\", "/").strip()
if "/" in loc:
parts = loc.split("/")
return parts[0]
m = COOP_FLOOR_PATTERN.match(loc)
if m and m.group("coop"):
coop_name = m.group("coop")
# Normalize K1, k1 -> K1
if re.match(r"^[Kk]\d+$", coop_name):
return coop_name.upper()
return coop_name
if "-" in loc:
return loc.split("-")[0]
if "_" in loc:
return loc.split("_")[0]
return loc
def resolve_floor_video_dir(
root_dir: str | Path,
location: str | None = None,
) -> Path:
"""Resolve the video directory for a floor, checking hierarchical (K1/L1) and flat (K1-L1) paths."""
p = Path(root_dir)
if p.is_dir():
return p
# If not found directly, try smart fallback resolution
loc = location or p.name
coop = get_coop_for_location(loc)
floor_part = loc.replace(f"{coop}-", "").replace(f"{coop}_", "")
candidates = [
p,
p.parent / coop / floor_part,
p.parent / coop / f"{coop}-{floor_part}",
p.parent / f"{coop}-{floor_part}",
]
for cand in candidates:
if cand.is_dir():
return cand
# Default to original path if none exist yet
return p
def discover_farm_structure(
videos_root: Path | str | None = None,
configs_dir: Path | str | None = None,
) -> FarmStructure:
"""Dynamically discover all coops, floors, and mortality directories on the local machine.
Searches:
1. Hierarchical coop folders: `VIDEOS/<cycle>/<coop>/[L1..Ln, mortality]`
2. Legacy/flat floor folders: `VIDEOS/<cycle>/<coop>-<floor>/`
3. Config files: `configs/floor_config/<coop>-<floor>.yaml`
"""
v_root = Path(videos_root).resolve() if videos_root else DEFAULT_VIDEOS_ROOT
c_dir = Path(configs_dir).resolve() if configs_dir else DEFAULT_CONFIGS_DIR
coops: dict[str, CoopInfo] = {}
def _get_or_create_coop(cid: str, cdir: Path | None = None) -> CoopInfo:
# Canonical key
key = cid.upper() if re.match(r"^[Kk]\d+$", cid) else cid
if key not in coops:
coops[key] = CoopInfo(coop_id=key, coop_dir=cdir)
elif cdir and not coops[key].coop_dir:
coops[key].coop_dir = cdir
return coops[key]
# 1. Scan VIDEOS directory hierarchy
if v_root.is_dir():
search_roots = [v_root]
for sub in v_root.iterdir():
if sub.is_dir() and (sub.name.startswith("cycle") or sub.name.startswith("202")):
search_roots.append(sub)
for root in search_roots:
if not root.is_dir():
continue
for item in sorted(root.iterdir()):
if not item.is_dir() or item.name.startswith("."):
continue
# Case A: Hierarchical Coop Directory (e.g. K1, K2, kandang-atas)
coop_name = item.name
# Check if it has floor subfolders (L1, L2) or mortality folder
has_mortality = (item / "mortality").is_dir()
has_floors = False
for subitem in item.iterdir():
if not subitem.is_dir():
continue
if subitem.name == "mortality":
coop_obj = _get_or_create_coop(coop_name, item)
coop_obj.mortality_dir = subitem
elif FLOOR_SUBDIR_PATTERN.match(subitem.name) or re.match(r"^[Kk]\d+-[Ll]\d+$", subitem.name):
has_floors = True
coop_obj = _get_or_create_coop(coop_name, item)
floor_canonical = f"{coop_obj.coop_id}-{subitem.name.upper()}" if not subitem.name.startswith(coop_obj.coop_id) else subitem.name
coop_obj.floors[floor_canonical] = subitem
if has_mortality or has_floors:
_get_or_create_coop(coop_name, item)
# Case B: Flat floor directory (e.g. K1-L1, K2-L3)
m = COOP_FLOOR_PATTERN.match(item.name)
if m and m.group("coop") and m.group("floor"):
c_id = m.group("coop")
coop_obj = _get_or_create_coop(c_id)
floor_name = item.name.upper() if re.match(r"^[Kk]\d+-[Ll]\d+$", item.name) else item.name
if floor_name not in coop_obj.floors:
coop_obj.floors[floor_name] = item
# 2. Augment with configured floor YAMLs in configs/floor_config/
if c_dir.is_dir():
for cfg_file in sorted(c_dir.glob("*.yaml")):
stem = cfg_file.stem
coop_id = get_coop_for_location(stem)
coop_obj = _get_or_create_coop(coop_id)
if stem not in coop_obj.floors:
# Store placeholder floor reference
coop_obj.floors[stem] = cfg_file
# 3. Auto-populate mortality_dir if present in coop_dir
for coop_obj in coops.values():
if not coop_obj.mortality_dir and coop_obj.coop_dir:
mort_cand = coop_obj.coop_dir / "mortality"
if mort_cand.is_dir():
coop_obj.mortality_dir = mort_cand
return FarmStructure(coops=coops, videos_root=v_root)
def discover_all_mortality_dirs(videos_root: Path | str | None = None) -> dict[str, Path]:
"""Find all mortality directories grouped by coop_id."""
structure = discover_farm_structure(videos_root)
return structure.all_mortality_dirs()
def sync_floor_configs(
videos_root: Path | str | None = None,
configs_dir: Path | str | None = None,
base_config_rel: str = "../cycle7_batch_optimized.yaml",
dry_run: bool = False,
) -> list[Path]:
"""Scan VIDEOS for all discovered floors and auto-create missing configs/floor_config/*.yaml files."""
v_root = Path(videos_root).resolve() if videos_root else DEFAULT_VIDEOS_ROOT
c_dir = Path(configs_dir).resolve() if configs_dir else DEFAULT_CONFIGS_DIR
c_dir.mkdir(parents=True, exist_ok=True)
structure = discover_farm_structure(videos_root=v_root, configs_dir=c_dir)
created: list[Path] = []
for coop_id, coop_info in sorted(structure.coops.items()):
for floor_name in coop_info.covered_floors:
yaml_path = c_dir / f"{floor_name}.yaml"
if yaml_path.exists():
continue
coop = get_coop_for_location(floor_name)
floor_sub = floor_name.replace(f"{coop}-", "").replace(f"{coop}_", "")
content = f"""# Floor Configuration for {floor_name}
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
extends: {base_config_rel}
batch:
location: "{floor_name}"
root_dir: "../VIDEOS/cycle7/{coop}/{floor_sub}"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras:
# CC1:
# roi:
# points:
# - [250, 330]
# - [1650, 330]
# - [1650, 720]
# - [250, 720]
"""
if not dry_run:
yaml_path.write_text(content, encoding="utf-8")
print(f"[sync-configs] 📄 Generated missing floor config: {yaml_path.name}")
else:
print(f"[sync-configs] (dry-run) Would generate: {yaml_path.name}")
created.append(yaml_path)
if not created:
print("[sync-configs] ✅ All discovered floors already have configuration YAMLs.")
else:
print(f"[sync-configs] ✨ Synchronized {len(created)} floor config(s).")
return created
+239
View File
@@ -0,0 +1,239 @@
"""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
# If model_path was not found in this file, check if it extends a base config
extends_match = re.search(r"^\s*(?:extends|base_config):\s*['\"]?([^'\"\s#]+)['\"]?", content, re.MULTILINE)
if extends_match:
base_rel = extends_match.group(1).strip()
base_path = (config_path.parent / base_rel).resolve()
if not base_path.exists():
base_path = (PROJECT_ROOT / base_rel).resolve()
if base_path.exists():
return update_config_yaml_model_path(base_path, new_model_path)
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)
+601
View File
@@ -0,0 +1,601 @@
"""Mortality counter: Whole-image chicken carcass detection and counting without ROI cropping."""
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
from pathlib import Path
import cv2
import numpy as np
import yaml
from ultralytics import YOLO
from chicken_counter.discovery import discover_farm_structure, get_coop_for_location
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 get_camera_source_for_coop(coop_id: str, config_path: str | Path | None = None) -> str | int | None:
"""Retrieve configured stationary camera source (RTSP URL, device index, or snapshot URL) for a coop."""
cfg = load_mortality_config(config_path or DEFAULT_CONFIG_PATH)
cameras = cfg.get("cameras", {})
if not cameras:
return None
if coop_id in cameras:
return cameras[coop_id]
for k, v in cameras.items():
if str(k).upper() == str(coop_id).upper():
return v
if "default" in cameras:
default_tmpl = str(cameras["default"])
num_m = re.search(r"\d+", coop_id)
coop_num = num_m.group(0) if num_m else "1"
return default_tmpl.format(coop=coop_id, coop_num=coop_num)
return None
def capture_camera_snapshot(
source: str | int,
timeout_sec: float = 5.0,
warmup_frames: int = 5,
) -> np.ndarray | None:
"""Capture a single clear still frame from a stationary RTSP stream, HTTP snapshot URL, or video device."""
if isinstance(source, str) and source.startswith(("http://", "https://")) and (source.endswith((".jpg", ".jpeg", ".png", "snapshot", "image")) or "snapshot" in source.lower()):
try:
import urllib.request
req = urllib.request.Request(source, headers={"User-Agent": "ChickenCounter/1.0"})
with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
arr = np.asarray(bytearray(resp.read()), dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if img is not None:
return img
except Exception as err:
print(f"[mortality] ⚠️ HTTP snapshot capture failed from {source}: {err}")
src_val = int(source) if isinstance(source, str) and source.isdigit() else source
cap = cv2.VideoCapture(src_val)
if not cap.isOpened():
print(f"[mortality] ⚠️ Failed to open camera source: {source}")
return None
try:
frame = None
for _ in range(max(1, warmup_frames)):
ret, tmp = cap.read()
if ret and tmp is not None:
frame = tmp
return frame
finally:
cap.release()
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,
coop: 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", False))
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": []}
# Resolve parent coop and covered floors
inferred_coop = coop
if not inferred_coop:
for part in reversed(input_p.parts):
if part.lower() != "mortality" and not re.match(r"^\d{4}-\d{2}-\d{2}$", part):
inferred_coop = get_coop_for_location(part)
break
inferred_coop = inferred_coop or "kandang-atas"
farm_struct = discover_farm_structure()
covered_floors = farm_struct.get_covered_floors(inferred_coop)
covered_str = ", ".join(covered_floors)
mode_str = "2x Similarity & Adaptive Accuracy Refinement" if two_pass else "Single-Pass Direct"
print(f"[mortality] Coop: {inferred_coop} (Covered Floors: {covered_str})")
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"),
"coop": inferred_coop,
"location": inferred_coop,
"covered_floors": covered_floors,
"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] [{inferred_coop}] Summary report saved to: {report_path} (Total Carcasses: {total_mortality_count} across {len(image_files)} images)")
return report_data
def run_all_coop_mortality(
videos_root: str | Path | None = None,
date: str | None = None,
config_path: str | Path | None = DEFAULT_CONFIG_PATH,
conf_threshold: float | None = None,
iou_threshold: float | None = None,
model_path: str | Path | None = None,
device: str | int | None = None,
two_pass: bool | None = None,
) -> dict[str, dict]:
"""Batch-process mortality detection across all discovered coop mortality folders."""
farm_struct = discover_farm_structure(videos_root)
all_m_dirs = farm_struct.all_mortality_dirs()
if not all_m_dirs:
print(f"[mortality-batch] ⚠️ No coop mortality directories found under {farm_struct.videos_root}")
return {}
print(f"\n=================================================================")
print(f" Starting Batch Mortality Detection across {len(all_m_dirs)} Coops")
print(f" Discovered Coops: {', '.join(sorted(all_m_dirs.keys()))}")
if date:
print(f" Target Date: {date}")
print(f"=================================================================\n")
summary: dict[str, dict] = {}
total_carcasses_all = 0
total_images_all = 0
for coop_id in sorted(all_m_dirs.keys()):
mdir = all_m_dirs[coop_id]
covered = farm_struct.get_covered_floors(coop_id)
covered_str = ", ".join(covered)
print(f"\n>>> [MORTALITY] Processing Coop {coop_id} (Floors: {covered_str}) <<<")
print(f" Directory: {mdir}")
try:
res = run_mortality_count(
input_path=mdir,
config_path=config_path,
coop=coop_id,
date=date,
conf_threshold=conf_threshold,
iou_threshold=iou_threshold,
model_path=model_path,
device=device,
two_pass=two_pass,
)
summary[coop_id] = res
c_count = res.get("total_mortality_count", 0)
i_count = res.get("total_images", 0)
total_carcasses_all += c_count
total_images_all += i_count
print(f" ✅ Coop {coop_id} Done: {c_count} carcasses across {i_count} photos.")
except Exception as err:
print(f" ❌ Coop {coop_id} Error: {err}")
summary[coop_id] = {"error": str(err), "coop": coop_id}
print(f"\n=================================================================")
print(f" Batch Mortality Summary:")
for coop_id, res in summary.items():
if "error" in res:
print(f" {coop_id}: ERROR ({res['error']})")
else:
print(f" {coop_id} (Floors: {', '.join(farm_struct.get_covered_floors(coop_id))}): {res.get('total_mortality_count', 0)} carcasses ({res.get('total_images', 0)} images)")
print(f" TOTAL SITE MORTALITY: {total_carcasses_all} carcasses across {total_images_all} images")
print(f"=================================================================\n")
return summary
+22 -1
View File
@@ -10,12 +10,14 @@ from chicken_counter.types import MotionState, TrackObservation
class BackwardMotionDetector:
def __init__(self, config: MotionConfig, roi: RoiConfig) -> None:
def __init__(self, config: MotionConfig, roi: RoiConfig, *, verbose: bool = False) -> None:
self.config = config
self.roi = roi
self.previous_gray: np.ndarray | None = None
self.state = MotionState()
self._roi_bounds = self._compute_roi_bounds()
self.verbose = verbose
self._update_count = 0
def _compute_roi_bounds(self) -> tuple[int, int, int, int]:
x_values = [point[0] for point in self.roi.points]
@@ -94,6 +96,25 @@ class BackwardMotionDetector:
self.state.backward_active = False
if self.state.consecutive_reverse_frames >= self.config.debounce_frames:
was_active = self.state.backward_active
self.state.backward_active = True
if self.verbose and not was_active:
print(
f"[motion #{self._update_count}] BACKWARD TRIGGERED! "
f"smoothed_speed={self.state.smoothed_speed:.1f} "
f"consecutive={self.state.consecutive_reverse_frames}"
)
if self.verbose:
self._update_count += 1
features_found = len(valid_prev) if points is not None and self.previous_gray is not None else 0
print(
f"[motion #{self._update_count}] "
f"features={features_found} "
f"median_speed={median_axis_speed:.1f} "
f"smoothed_speed={self.state.smoothed_speed:.1f} "
f"consecutive_rev={self.state.consecutive_reverse_frames} "
f"backward={self.state.backward_active}"
)
return self.state
+206 -15
View File
@@ -2,6 +2,9 @@
from __future__ import annotations
import json
import shutil
import sys
import time
from dataclasses import dataclass
from pathlib import Path
@@ -19,6 +22,62 @@ from chicken_counter.types import FrameResult, PipelineResult, TrackObservation
from chicken_counter.video_writer import make_video_writer
class _ProgressBar:
def __init__(self, total: int | None, width: int = 30) -> None:
self._total = total
self._width = width
self._last_render = 0.0
self._last_line_len = 0
self._checkpoint_msg = ""
def _build_checkpoint_suffix(self) -> str:
if not self._checkpoint_msg:
return ""
msg = self._checkpoint_msg
self._checkpoint_msg = ""
return f" [{msg}]"
def render(self, frame_index: int, elapsed: float, fps: float, inside: int, total_entered: int, backward: bool) -> None:
now = time.monotonic()
if now - self._last_render < 0.2 and frame_index > 1 and not self._checkpoint_msg:
return
self._last_render = now
elapsed_str = _format_duration(elapsed)
checkpoint_suffix = self._build_checkpoint_suffix()
if self._total:
pct = min(100, frame_index * 100 // self._total)
filled = self._width * pct // 100
bar = "[" + "=" * filled + ">" + " " * (self._width - filled) + "]"
eta_seconds = (self._total - frame_index) / fps if fps > 0 else 0.0
eta_str = _format_duration(eta_seconds)
status = "backward" if backward else "running"
line = (
f"\r{bar} {pct:3d}% {frame_index}/{self._total} "
f"{elapsed_str} eta={eta_str} {fps:.1f}fps "
f"count={inside}/{total_entered} {status}{checkpoint_suffix}"
)
else:
status = "backward" if backward else "running"
line = (
f"\rframe={frame_index} {elapsed_str} {fps:.1f}fps "
f"count={inside}/{total_entered} {status}{checkpoint_suffix}"
)
pad = max(0, self._last_line_len - len(line))
self._last_line_len = len(line)
sys.stderr.write(line + " " * pad)
sys.stderr.flush()
def emit(self, message: str) -> None:
self._checkpoint_msg = message
self._last_render = 0.0
def finish(self) -> None:
sys.stderr.write("\n")
sys.stderr.flush()
@dataclass
class PipelineArtifacts:
capture: cv2.VideoCapture
@@ -31,11 +90,14 @@ class PipelineArtifacts:
total_source_frames: int | None
owns_tracker: bool
detection_zone_rect: tuple[int, int, int, int] | None = None
run_date: str = ""
def build_pipeline(
config: CameraConfig,
tracker: DetectionTracker | None = None,
*,
run_date: str = "",
) -> PipelineArtifacts:
capture = open_capture(config.source)
owns_tracker = tracker is None
@@ -48,8 +110,11 @@ 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)
motion_detector = BackwardMotionDetector(config.motion, config.roi, verbose=config.performance.verbose)
width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
@@ -80,6 +145,12 @@ def build_pipeline(
codec_preference=config.display.codec_preference,
)
if config.stream.enabled:
cam_dir = Path(config.stream.shm_dir) / f"chicken_counter_{config.camera_id}"
if cam_dir.exists():
shutil.rmtree(str(cam_dir))
print(f"[stream] cleaned {cam_dir}")
return PipelineArtifacts(
capture=capture,
tracker=tracker,
@@ -91,18 +162,22 @@ def build_pipeline(
total_source_frames=total_source_frames,
owns_tracker=owns_tracker,
detection_zone_rect=detection_zone_rect,
run_date=run_date,
)
def run_pipeline(
config: CameraConfig,
tracker: DetectionTracker | None = None,
*,
show_progress: bool = False,
run_date: str = "",
) -> PipelineResult:
if tracker is not None:
tracker.config = config
tracker.reset_tracking()
artifacts = build_pipeline(config, tracker=tracker)
artifacts = build_pipeline(config, tracker=tracker, run_date=run_date)
inference_stride = max(1, config.performance.inference_stride)
print(
f"[perf] inference_stride={inference_stride} "
@@ -114,26 +189,51 @@ def run_pipeline(
last_tracks: list[TrackObservation] = []
stopped_reason = "eof"
user_quit = False
verbose = config.performance.verbose
cumulative_timings: dict[str, float] = {"read": 0.0, "infer": 0.0, "motion": 0.0, "count": 0.0, "overlay": 0.0, "write": 0.0}
timed_frames = 0
verbose_interval = max(1, inference_stride * 30)
progress = _ProgressBar(artifacts.total_source_frames) if show_progress else None
try:
while True:
if verbose:
t0 = time.monotonic()
ok, frame = artifacts.capture.read()
if not ok:
break
frame_index += 1
if verbose:
t_read = time.monotonic()
if frame_index % inference_stride == 0 or not last_tracks:
last_tracks = artifacts.tracker.infer(
frame,
crop_rect=artifacts.detection_zone_rect,
)
if verbose and frame_index % inference_stride == 0:
t_infer = time.monotonic()
tracks = last_tracks
motion_state = artifacts.motion_detector.update(frame, tracks, frame_index)
if verbose:
t_motion = time.monotonic()
count_events = artifacts.counting_zone.update(
tracks,
frame_index,
counting_paused=motion_state.backward_active,
)
if verbose:
t_count = time.monotonic()
needs_overlay = config.display.show_window or artifacts.writer is not None or config.stream.enabled
annotated = draw_overlay(
frame,
config,
@@ -142,7 +242,10 @@ def run_pipeline(
motion_state,
frame_index=frame_index,
buffer=artifacts.overlay_buffer,
)
) if needs_overlay else frame
if verbose:
t_overlay = time.monotonic()
result = FrameResult(
frame_index=frame_index,
@@ -152,12 +255,58 @@ def run_pipeline(
motion_state=motion_state,
count_events=count_events,
)
_consume_result(config, artifacts, annotated, result)
_consume_result(config, artifacts, annotated, result, progress)
last_annotated = annotated
if config.stream.enabled and frame_index % 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 verbose:
t_write = time.monotonic()
if frame_index % inference_stride == 0:
cumulative_timings["read"] += (t_read - t0) * 1000
cumulative_timings["infer"] += (t_infer - t_read) * 1000
cumulative_timings["motion"] += (t_motion - t_infer) * 1000
cumulative_timings["count"] += (t_count - t_motion) * 1000
cumulative_timings["overlay"] += (t_overlay - t_count) * 1000
cumulative_timings["write"] += (t_write - t_overlay) * 1000
timed_frames += 1
if frame_index % verbose_interval == 0 and timed_frames > 0:
n = timed_frames
print(
f"[debug ~{verbose_interval}f avg ms] "
f"read={cumulative_timings['read']/n:.1f} "
f"infer={cumulative_timings['infer']/n:.1f} "
f"motion={cumulative_timings['motion']/n:.1f} "
f"count={cumulative_timings['count']/n:.1f} "
f"overlay={cumulative_timings['overlay']/n:.1f} "
f"write={cumulative_timings['write']/n:.1f} "
f"tracks={len(tracks)} "
f"inside={artifacts.counting_zone.inside_box_count} "
f"total={artifacts.counting_zone.total_entered_count} "
f"motion_speed={motion_state.smoothed_speed:.1f} "
f"backward={motion_state.backward_active}"
)
cumulative_timings = {k: 0.0 for k in cumulative_timings}
timed_frames = 0
if progress is not None:
elapsed = time.monotonic() - artifacts.run_start_time
fps = frame_index / elapsed if elapsed > 0 else 0.0
progress.render(
frame_index, elapsed, fps,
artifacts.counting_zone.inside_box_count,
artifacts.counting_zone.total_entered_count,
motion_state.backward_active,
)
if motion_state.backward_active:
stopped_reason = "backward"
print(f"[stop] backward detection confirmed at frame={frame_index}; ending pipeline")
if progress is not None:
progress.emit(f"[stop] backward detection confirmed at frame={frame_index}; ending pipeline")
else:
print(f"[stop] backward detection confirmed at frame={frame_index}; ending pipeline")
break
if config.display.max_frames and frame_index >= config.display.max_frames:
@@ -183,6 +332,9 @@ def run_pipeline(
if user_quit:
stopped_reason = "user_quit"
if progress is not None:
progress.finish()
return PipelineResult(
camera_id=config.camera_id,
total_entered_count=artifacts.counting_zone.total_entered_count,
@@ -199,6 +351,7 @@ def _consume_result(
artifacts: PipelineArtifacts,
annotated,
result: FrameResult,
progress: _ProgressBar | None = None,
) -> None:
if config.display.show_window:
cv2.imshow(config.display.window_name, annotated)
@@ -206,13 +359,46 @@ def _consume_result(
artifacts.writer.write(annotated)
for event in result.count_events:
print(
f"[frame {event.frame_index}] counted track={event.track_id} "
f"inside_box={result.inside_box_count} total_entered={event.total_entered_after_event}"
)
if config.performance.verbose:
msg = (
f"[frame {event.frame_index}] counted track={event.track_id} "
f"inside_box={result.inside_box_count} total_entered={event.total_entered_after_event}"
)
if progress is not None:
progress.emit(msg)
else:
print(msg)
if _should_emit_feedback(config, result.frame_index):
_emit_periodic_feedback(config, artifacts, annotated, result)
_emit_periodic_feedback(config, artifacts, annotated, result, progress)
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)
jpg_path = cam_dir / "frame.jpg"
tmp_path = cam_dir / ".frame_tmp.jpg"
cv2.imwrite(str(tmp_path), frame, [cv2.IMWRITE_JPEG_QUALITY, 75])
tmp_path.replace(jpg_path)
stats = {
"frame_index": result.frame_index,
"inside_box_count": result.inside_box_count,
"total_entered_count": result.total_entered_count,
"track_count": len(result.tracks),
"backward_active": result.motion_state.backward_active,
"smoothed_speed": round(result.motion_state.smoothed_speed, 1),
"count_events": len(result.count_events),
"run_date": run_date,
}
stats_path = cam_dir / "stats.json"
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:
@@ -238,15 +424,16 @@ def _emit_periodic_feedback(
artifacts: PipelineArtifacts,
annotated,
result: FrameResult,
progress: _ProgressBar | None = None,
) -> None:
if config.feedback.log_to_terminal:
elapsed = time.monotonic() - artifacts.run_start_time
fps = result.frame_index / elapsed if elapsed > 0 else 0.0
status = "backward_stop" if result.motion_state.backward_active else "running"
progress = f"frame={result.frame_index}"
progress_text = f"frame={result.frame_index}"
if artifacts.total_source_frames:
progress = f"frame={result.frame_index}/{artifacts.total_source_frames}"
progress_text = f"frame={result.frame_index}/{artifacts.total_source_frames}"
eta_text = ""
if artifacts.total_source_frames and fps > 0:
@@ -254,13 +441,17 @@ def _emit_periodic_feedback(
eta_seconds = remaining_frames / fps
eta_text = f" eta={_format_duration(eta_seconds)}"
print(
f"[checkpoint] {progress} elapsed={_format_duration(elapsed)} "
msg = (
f"[checkpoint] {progress_text} elapsed={_format_duration(elapsed)} "
f"fps={fps:.1f} inside_box={result.inside_box_count} "
f"total_entered={result.total_entered_count} "
f"backward_active={result.motion_state.backward_active} "
f"status={status}{eta_text}"
)
if progress is not None:
progress.emit(msg)
else:
print(f"\r\033[K{msg}")
if config.feedback.save_images:
output_dir = Path(config.feedback.image_output_dir)
+25 -3
View File
@@ -97,10 +97,32 @@ def persist_batch_reports(
output_dir: str | Path,
) -> Path:
output_path = Path(output_dir)
latest = results[-1]
write_camera_report(date, latest, output_path)
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
+156 -66
View File
@@ -1,23 +1,45 @@
"""Run YOLO detection and BoT-SORT tracking on each frame."""
"""Run YOLO detection and BoT-SORT tracking on each frame with per-camera tracker isolation."""
from __future__ import annotations
import time
from pathlib import Path
from typing import Any
import numpy as np
import yaml
from ultralytics import YOLO
from ultralytics.trackers.track import TRACKER_MAP
from ultralytics.utils import IterableSimpleNamespace
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)
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
self._stream_trackers: dict[str, Any] = {}
self._tracker_cfg_obj: IterableSimpleNamespace | None = None
self._init_tracker_cfg()
print(
f"[model] loaded {self.model_kind} from {model_path} "
f"(imgsz={config.detection.imgsz}, device={config.detection.device})"
@@ -25,7 +47,49 @@ class DetectionTracker:
if self.model_kind == "engine":
print("[model] TensorRT engine active; runtime half flag is ignored")
def reset_tracking(self) -> None:
def _init_tracker_cfg(self) -> None:
try:
tracker_file = Path(self.tracker_config_path)
if tracker_file.exists():
with open(tracker_file, "r", encoding="utf-8") as f:
raw_cfg = yaml.safe_load(f) or {}
self._tracker_cfg_obj = IterableSimpleNamespace(**raw_cfg)
self._tracker_cfg_obj.device = self.config.detection.device
except Exception as exc:
if self.verbose:
print(f"[tracker] error reading tracker config: {exc}")
self._tracker_cfg_obj = None
def get_or_create_tracker(self, stream_id: str = "default") -> Any:
if stream_id not in self._stream_trackers:
if self._tracker_cfg_obj is not None and self._tracker_cfg_obj.tracker_type in TRACKER_MAP:
tracker_cls = TRACKER_MAP[self._tracker_cfg_obj.tracker_type]
self._stream_trackers[stream_id] = tracker_cls(args=self._tracker_cfg_obj)
else:
default_args = IterableSimpleNamespace(
tracker_type="botsort",
track_high_thresh=0.5,
track_low_thresh=0.1,
new_track_thresh=0.6,
track_buffer=self.config.tracker.track_buffer,
match_thresh=0.8,
fuse_score=True,
gmc_method="none",
proximity_thresh=0.5,
appearance_thresh=0.25,
with_reid=False,
model="auto",
device=self.config.detection.device,
)
self._stream_trackers[stream_id] = TRACKER_MAP["botsort"](args=default_args)
return self._stream_trackers[stream_id]
def reset_tracking(self, stream_id: str | None = None) -> None:
if stream_id:
if stream_id in self._stream_trackers:
del self._stream_trackers[stream_id]
else:
self._stream_trackers.clear()
if hasattr(self.model, "predictor"):
self.model.predictor = None
@@ -33,20 +97,38 @@ class DetectionTracker:
self,
frame: np.ndarray,
*,
stream_id: str = "default",
crop_rect: tuple[int, int, int, int] | None = None,
) -> list[TrackObservation]:
offset_x = 0
offset_y = 0
source = frame
if crop_rect is not None:
x1, y1, x2, y2 = crop_rect
source = frame[y1:y2, x1:x2]
offset_x, offset_y = x1, y1
results = self.infer_batch([frame], stream_ids=[stream_id], crop_rects=[crop_rect])
return results[0] if results else []
track_kwargs: dict = {
"source": source,
"persist": self.config.tracker.persist,
"tracker": self.tracker_config_path,
def infer_batch(
self,
frames: list[np.ndarray],
*,
stream_ids: list[str] | None = None,
crop_rects: list[tuple[int, int, int, int] | None] | None = None,
) -> list[list[TrackObservation]]:
if not frames:
return []
if stream_ids is None:
stream_ids = [f"cam_{i}" for i in range(len(frames))]
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))
predict_kwargs: dict = {
"conf": self.config.detection.conf,
"iou": self.config.detection.iou,
"classes": self.config.detection.classes,
@@ -55,67 +137,75 @@ class DetectionTracker:
"device": self.config.detection.device,
}
if self.model_kind != "engine" and self.config.performance.half:
track_kwargs["half"] = True
predict_kwargs["half"] = True
results = self.model.track(**track_kwargs)
if self.verbose:
t_start = time.monotonic()
if not results:
return []
try:
results = self.model.predict(source=sources, **predict_kwargs)
except Exception:
results = []
for src in sources:
kw = dict(predict_kwargs)
kw["source"] = src
res = self.model.predict(**kw)
if res:
results.append(res[0])
result = results[0]
boxes = result.boxes
if boxes is None or boxes.id is None:
return []
if self.verbose:
t_track = time.monotonic()
self._infer_count += 1
ids = boxes.id.int().cpu().numpy()
classes = boxes.cls.int().cpu().numpy()
confidences = boxes.conf.cpu().numpy()
xyxy = boxes.xyxy.int().cpu().numpy()
batch_tracks: list[list[TrackObservation]] = []
for idx, result in enumerate(results):
stream_id = stream_ids[idx] if idx < len(stream_ids) else f"stream_{idx}"
tracker = self.get_or_create_tracker(stream_id)
orig_src = sources[idx]
offset_x, offset_y = offsets[idx]
crop_rect = crop_rects[idx] if crop_rects and idx < len(crop_rects) else None
mask_polygons = None
if result.masks is not None and result.masks.xy is not None:
mask_polygons = result.masks.xy
if len(mask_polygons) != len(boxes):
raise RuntimeError(
f"Ultralytics box/mask count mismatch: {len(boxes)} boxes, "
f"{len(mask_polygons)} masks"
)
tracks: list[TrackObservation] = []
for index in range(len(boxes)):
track_id = int(ids[index])
class_id = int(classes[index])
confidence = float(confidences[index])
bbox = xyxy[index]
x1 = int(bbox[0]) + offset_x
y1 = int(bbox[1]) + offset_y
x2 = int(bbox[2]) + offset_x
y2 = int(bbox[3]) + offset_y
centroid = ((x1 + x2) // 2, (y1 + y2) // 2)
if crop_rect is not None and not self._centroid_in_rect(centroid, crop_rect):
boxes = result.boxes
if boxes is None or len(boxes) == 0:
tracker.update(np.empty((0, 6)), orig_src)
batch_tracks.append([])
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
det_np = boxes.cpu().numpy()
tracks_raw = tracker.update(det_np, orig_src)
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,
if len(tracks_raw) == 0:
batch_tracks.append([])
continue
tracks: list[TrackObservation] = []
for row in tracks_raw:
# Format: [x1, y1, x2, y2, track_id, conf, cls, idx]
x1 = int(row[0]) + offset_x
y1 = int(row[1]) + offset_y
x2 = int(row[2]) + offset_x
y2 = int(row[3]) + offset_y
track_id = int(row[4])
conf = float(row[5]) if len(row) > 5 else float(self.config.detection.conf)
cls_id = int(row[6]) if len(row) > 6 else 0
centroid = ((x1 + x2) // 2, (y1 + y2) // 2)
if crop_rect is not None and not self._centroid_in_rect(centroid, crop_rect):
continue
tracks.append(
TrackObservation(
track_id=track_id,
class_id=cls_id,
confidence=conf,
bbox_xyxy=(x1, y1, x2, y2),
centroid=centroid,
mask_polygon_xy=None,
)
)
)
batch_tracks.append(tracks)
return tracks
return batch_tracks
@staticmethod
def _centroid_in_rect(
+14 -7
View File
@@ -51,18 +51,25 @@ 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}"
)
writer = cv2.VideoWriter(pipeline, cv2.CAP_GSTREAMER, 0, fps, (width, height), True)
if writer.isOpened():
return writer
writer.release()
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
writer.release()
return None
+54
View File
@@ -0,0 +1,54 @@
#!/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")
# Startup check: Ensure sibling VIDEOS directory structure exists
VIDEOS_BASE="$SCRIPT_DIR/../VIDEOS"
if [ ! -d "$VIDEOS_BASE" ]; then
echo "[dashboard] 📁 Sibling directory '$VIDEOS_BASE' not found; creating skeleton directory..."
mkdir -p "$VIDEOS_BASE/cycle7/K1/mortality" "$VIDEOS_BASE/cycle7/K2/mortality" "$VIDEOS_BASE/cycle7/K3/mortality" "$VIDEOS_BASE/cycle7/K4/mortality" "$VIDEOS_BASE/cycle7/K5/mortality"
fi
# Auto-discover mortality directories (any folder containing mortality_report.json or mortality folders)
if [ -n "${MORTALITY_DIRS:-}" ]; then
IFS=',' read -ra MDIRS <<< "$MORTALITY_DIRS"
for mdir in "${MDIRS[@]}"; do
CMD+=(--mortality-dir "$mdir")
done
else
# Recursively discover all mortality subdirectories under ../VIDEOS
FOUND_MDIRS=()
while IFS= read -r -d '' mdir; do
FOUND_MDIRS+=("$mdir")
done < <(find "$VIDEOS_BASE" -type d -name "mortality" -print0 2>/dev/null)
if [ ${#FOUND_MDIRS[@]} -gt 0 ]; then
for mdir in "${FOUND_MDIRS[@]}"; do
CMD+=(--mortality-dir "$mdir")
done
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[@]}"
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Store batch run results into a SQLite database.
Reads the aggregate JSON report written by the batch runner and inserts
all camera-level + summary data. Safe to run multiple times — uses
(date, location, camera_id) as the unique key, so re-runs update
existing rows instead of duplicating.
Usage:
python3 store_results.py /path/to/output/counts_2026-06-10.json --location kandang-atas
python3 store_results.py /path/to/output/counts_2026-06-10.json --db /var/lib/chickens.db
"""
from __future__ import annotations
import argparse
import json
import sqlite3
import sys
from pathlib import Path
CREATE_TABLE = """
CREATE TABLE IF NOT EXISTS batch_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
location TEXT NOT NULL,
camera_id TEXT NOT NULL,
total_entered INTEGER NOT NULL DEFAULT 0,
frames_processed INTEGER NOT NULL DEFAULT 0,
elapsed_seconds REAL NOT NULL DEFAULT 0.0,
stopped_reason TEXT NOT NULL DEFAULT '',
source_video TEXT NOT NULL DEFAULT '',
generated_at TEXT NOT NULL DEFAULT '',
UNIQUE(date, location, camera_id)
);
CREATE INDEX IF NOT EXISTS idx_batch_date ON batch_runs (date);
CREATE INDEX IF NOT EXISTS idx_batch_camera ON batch_runs (camera_id);
CREATE INDEX IF NOT EXISTS idx_batch_location ON batch_runs (location);
"""
INSERT_SQL = """
INSERT INTO batch_runs
(date, location, camera_id, total_entered, frames_processed,
elapsed_seconds, stopped_reason, source_video, generated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(date, location, camera_id) DO UPDATE SET
total_entered = excluded.total_entered,
frames_processed = excluded.frames_processed,
elapsed_seconds = excluded.elapsed_seconds,
stopped_reason = excluded.stopped_reason,
source_video = excluded.source_video,
generated_at = excluded.generated_at
"""
SUMMARY_QUERY = """
SELECT
date,
location,
COUNT(*) AS camera_count,
SUM(total_entered) AS total_chickens,
SUM(elapsed_seconds) AS total_seconds,
ROUND(SUM(elapsed_seconds) / 60.0, 1) AS total_minutes
FROM batch_runs
WHERE date = ? AND location = ?
GROUP BY date, location
"""
def store_report(report_path: str, location: str, db_path: str) -> None:
with open(report_path) as f:
report = json.load(f)
date = report["date"]
cameras = report["cameras"]
generated_at = report.get("generated_at", "")
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA journal_mode=WAL")
conn.executescript(CREATE_TABLE)
rows = 0
for camera_id, entry in cameras.items():
if entry.get("skipped"):
continue
conn.execute(INSERT_SQL, (
date, location, camera_id,
entry.get("total_entered", 0),
entry.get("frames_processed", 0),
entry.get("elapsed_seconds", 0),
entry.get("stopped_reason", ""),
entry.get("source_video", ""),
generated_at,
))
rows += 1
conn.commit()
# print summary
row = conn.execute(SUMMARY_QUERY, (date, location)).fetchone()
if row:
print(f"\n[db] {row[0]} | {row[1]} | {row[2]} cameras | "
f"{row[3]} chickens | {row[4]:.0f}s ({row[5]} min)")
# also print per-camera breakdown
cur = conn.execute(
"SELECT camera_id, total_entered, elapsed_seconds "
"FROM batch_runs WHERE date=? AND location=? ORDER BY camera_id",
(date, location))
for cam_id, count, secs in cur:
print(f" {cam_id}: {count} chickens, {secs:.0f}s")
conn.close()
print(f"\n[db] wrote {rows} rows to {db_path}")
def main():
parser = argparse.ArgumentParser(
description="Store batch run results into SQLite")
parser.add_argument("report", help="Path to counts_YYYY-MM-DD.json")
parser.add_argument("--location", required=True, help="Location name (e.g. kandang-atas)")
parser.add_argument("--db", default="chicken_counts.db", help="SQLite database path")
args = parser.parse_args()
if not Path(args.report).exists():
print(f"error: report not found: {args.report}", file=sys.stderr)
sys.exit(1)
store_report(args.report, args.location, args.db)
if __name__ == "__main__":
main()
+690
View File
@@ -0,0 +1,690 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Chicken Counter — Live</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:hidden;height:100vh;display:flex;flex-direction:column}
#top-bar{display:flex;justify-content:space-between;align-items:center;padding:8px 16px;background:#16161e;font-size:12px;flex-shrink:0}
#top-bar h1{font-size:16px;color:#80dc5a}
#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;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}
#frame-area .overlay .tag{display:inline-block;padding:4px 12px;border-radius:6px;font-size:12px;font-weight:700;margin-bottom:4px}
#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: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}
#sidebar .stat .val.good{color:#80dc5a}
#sidebar .stat .val.warn{color:#ff9f43}
#sidebar .cam-list{}
#sidebar .cam-list .cam-row{display:flex;justify-content:space-between;align-items:center;padding:6px 10px;margin:2px 0;border-radius:6px;font-size:12px;cursor:pointer;transition:background .15s}
#sidebar .cam-list .cam-row:hover{background:#1a1a24}
#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, #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-filter-btn{background:#0e0e14;border:1px solid #333;color:#888;border-radius:4px;padding:2px 6px;font-size:9px;cursor:pointer;font-weight:600;transition:all .15s}
.mort-filter-btn:hover{color:#fff;border-color:#555}
.mort-filter-btn.active{background:#ff4757;color:#fff;border-color:#ff4757}
.mort-coop-tag{display:inline-block;background:#33141a;color:#ff6b81;padding:1px 4px;border-radius:3px;font-size:9px;font-weight:700;margin-right:3px}
.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}
.mort-upload-btn{background:#2a1518;color:#ff6b81;border:1px solid #ff4757;font-weight:700;padding:2px 8px;border-radius:4px;font-size:10px;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:3px}
.mort-upload-btn:hover{background:#ff4757;color:#fff}
/* Upload & Capture Modal */
#mort-upload-modal{display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,.85);z-index:1000;align-items:center;justify-content:center;backdrop-filter:blur(4px)}
#mort-upload-modal.active{display:flex}
.mort-upload-box{background:#16161e;border:1px solid #333;border-radius:10px;width:90%;max-width:480px;padding:18px;box-shadow:0 8px 32px rgba(0,0,0,.8);position:relative;color:#ddd}
.mort-upload-box h2{font-size:15px;margin:0 0 10px 0;color:#fff;display:flex;align-items:center;gap:6px}
.mort-tab-row{display:flex;gap:6px;margin-bottom:12px;border-bottom:1px solid #282834;padding-bottom:8px}
.mort-tab-btn{background:none;border:none;color:#888;font-size:11px;font-weight:600;cursor:pointer;padding:4px 10px;border-radius:4px;transition:all .15s}
.mort-tab-btn:hover{color:#fff}
.mort-tab-btn.active{background:#2a1518;color:#ff6b81;border:1px solid #ff4757}
.mort-cam-preview-box{background:#0e0e14;border:1px solid #333;border-radius:6px;overflow:hidden;position:relative;height:180px;display:flex;align-items:center;justify-content:center;margin-bottom:10px}
.mort-cam-preview-box img{width:100%;height:100%;object-fit:contain;background:#000}
.mort-cam-preview-overlay{position:absolute;bottom:6px;right:8px;background:rgba(0,0,0,.75);padding:2px 8px;border-radius:4px;font-size:10px;color:#2ed573;border:1px solid #333}
.mort-capture-btn{width:100%;padding:10px;background:#ff4757;color:#fff;border:none;border-radius:6px;font-weight:700;font-size:13px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px;transition:background .15s}
.mort-capture-btn:hover{background:#ff6b81}
.mort-capture-btn:disabled{background:#333;color:#777;cursor:not-allowed}
.mort-upload-row{display:flex;gap:10px;margin-bottom:12px}
.mort-upload-field{flex:1}
.mort-upload-field label{display:block;font-size:10px;color:#888;margin-bottom:4px;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
.mort-upload-field select, .mort-upload-field input[type="date"]{width:100%;padding:6px 8px;background:#0e0e14;border:1px solid #333;border-radius:6px;color:#fff;font-size:11px;outline:none;box-sizing:border-box}
.mort-dropzone{border:2px dashed #444;border-radius:8px;padding:18px 12px;text-align:center;background:#0e0e14;cursor:pointer;transition:border-color .15s, background .15s}
.mort-dropzone:hover, .mort-dropzone.dragover{border-color:#ff4757;background:#1b1419}
.mort-dropzone-icon{font-size:26px;margin-bottom:4px;display:block}
.mort-dropzone-txt{font-size:12px;color:#aaa}
.mort-dropzone-sub{font-size:10px;color:#666;margin-top:2px}
.mort-preview-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(70px, 1fr));gap:6px;margin-top:10px;max-height:140px;overflow-y:auto}
.mort-preview-item{position:relative;border-radius:4px;overflow:hidden;border:1px solid #333;height:65px}
.mort-preview-item img{width:100%;height:100%;object-fit:cover}
.mort-preview-item .rm-btn{position:absolute;top:2px;right:2px;background:rgba(0,0,0,.7);color:#ff4757;border:none;border-radius:50%;width:16px;height:16px;font-size:10px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1}
.mort-submit-btn{width:100%;padding:9px;background:#ff4757;color:#fff;border:none;border-radius:6px;font-weight:700;font-size:12px;cursor:pointer;margin-top:12px;transition:background .15s}
.mort-submit-btn:hover{background:#ff6b81}
.mort-submit-btn:disabled{background:#333;color:#777;cursor:not-allowed}
.mort-upload-status{margin-top:10px;font-size:11px;text-align:center;display:none}
/* 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>
<div id="top-bar">
<h1>&#x1f414; Chicken Counter</h1>
<span style="color:#888"><span id="stat-date">{{ date }}</span> &nbsp;|&nbsp; <span id="clock"></span></span>
</div>
<div id="main">
<div id="frame-area">
<canvas id="frame-canvas" style="display:none"></canvas>
<img id="frame-img" src="" alt="live stream" style="display:none">
<div id="waiting-msg" style="color:#555;font-size:16px;text-align:center">waiting for pipeline...</div>
<div class="overlay" id="frame-overlay" style="display:none">
<div class="tag live" id="cam-tag"></div>
<div class="count" id="cam-count"></div>
</div>
</div>
<div id="sidebar">
<div class="stat"><label>Total Entered</label><div class="val good" id="s-total">--</div></div>
<div class="stat"><label>Inside Box</label><div class="val" id="s-inside">--</div></div>
<div class="stat"><label>Tracks</label><div class="val" id="s-tracks">--</div></div>
<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">
<h3>
<span>&#x2620;&#xfe0f; Mortality Scans</span>
<span style="display:flex;gap:5px;align-items:center">
<button class="mort-upload-btn" onclick="openMortUploadModal()">&#x1f4f7; Upload / Capture</button>
<span class="mort-badge" id="mort-header-badge">0</span>
</span>
</h3>
<div id="mort-coop-filters" style="display:flex;gap:4px;flex-wrap:wrap;margin:4px 0 6px 0"></div>
<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>&#x1f4ca; History</h3>
</div>
</div>
</div>
<!-- Modal for Capturing Mortality Photos from Stationary Camera or Uploading -->
<div id="mort-upload-modal" onclick="closeMortUploadModal(event)">
<div class="mort-upload-box" onclick="event.stopPropagation()">
<button class="close-btn" onclick="closeMortUploadModal(event)" style="top:10px;right:14px;font-size:24px">&times;</button>
<h2>&#x1f4f8; Mortality Inspection Camera</h2>
<div class="mort-tab-row">
<button class="mort-tab-btn active" id="tab-btn-cam" onclick="switchMortModalTab('cam')">&#x1f4f7; Stationary Camera</button>
<button class="mort-tab-btn" id="tab-btn-file" onclick="switchMortModalTab('file')">&#x1f4c1; File Upload</button>
</div>
<div class="mort-upload-row">
<div class="mort-upload-field">
<label for="mort-upload-coop">Target Coop / Cage</label>
<select id="mort-upload-coop" onchange="onMortCoopChanged()">
<option value="K1">K1 (L1, L2, L3)</option>
<option value="K2">K2 (L1, L2, L3)</option>
<option value="K3">K3 (L1, L2, L3)</option>
<option value="K4">K4 (L1, L2)</option>
<option value="K5">K5 (L1, L2)</option>
<option value="kandang-atas">kandang-atas</option>
</select>
</div>
<div class="mort-upload-field">
<label for="mort-upload-date">Date</label>
<input type="date" id="mort-upload-date">
</div>
</div>
<!-- Tab 1: Stationary Camera Capture (Default) -->
<div id="mort-cam-tab-content">
<div class="mort-cam-preview-box" id="mort-cam-preview-box">
<img id="mort-cam-live-img" src="" alt="Stationary Camera View" onerror="this.style.display='none';document.getElementById('mort-cam-offline-msg').style.display='block'">
<div id="mort-cam-offline-msg" style="display:none;color:#777;font-size:11px;text-align:center;padding:12px">
&#x1f4f7; Stationary Camera Preview<br><span style="color:#555;font-size:9px">Click Capture to snap high-res photo</span>
</div>
<div class="mort-cam-preview-overlay" id="mort-cam-status-overlay">&#x1f534; Camera Ready</div>
</div>
<button class="mort-capture-btn" id="mort-capture-btn" onclick="captureFromStationaryCamera()">
&#x1f4f8; Snap &amp; Count Batch from Camera
</button>
<div style="font-size:10px;color:#666;text-align:center;margin-top:6px">
Place batch of chickens on table &rarr; click Snap &rarr; clear table &rarr; repeat if needed
</div>
</div>
<!-- Tab 2: Manual File Upload -->
<div id="mort-file-tab-content" style="display:none">
<input type="file" id="mort-file-input" accept="image/*" multiple style="display:none" onchange="handleMortFilesSelected(event)">
<div class="mort-dropzone" id="mort-dropzone" onclick="document.getElementById('mort-file-input').click()">
<span class="mort-dropzone-icon">&#x1f4f8;</span>
<div class="mort-dropzone-txt">Browse or Drop Photos</div>
<div class="mort-dropzone-sub">Select multiple photos for this coop</div>
</div>
<div class="mort-preview-grid" id="mort-preview-grid"></div>
<button class="mort-submit-btn" id="mort-submit-btn" onclick="submitMortalityUpload()" disabled>
Count Mortalities (0 photos selected)
</button>
</div>
<div class="mort-upload-status" id="mort-upload-status"></div>
</div>
</div>
<!-- Modal for high-res mortality preview -->
<div id="mort-modal" onclick="closeMortModal(event)">
<button class="close-btn" onclick="closeMortModal(event)">&times;</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 }}";
var DB_PATH = "{{ db_path }}";
var RUN_DATE = "{{ date }}";
var cameras = [], activeCam = null, lastUpdate = 0;
var perCam = {}, lastFramePerCam = {};
function boot() {
loadCameras();
setInterval(loadCameras, 3000);
setInterval(poll, POLL_MS);
setInterval(updateClock, 1000);
loadHistory();
setInterval(loadHistory, 60000);
loadMortality();
setInterval(loadMortality, 30000);
updateClock();
}
function loadCameras() {
fetch("/api/cameras").then(r => r.json()).then(data => {
cameras = data.cameras || [];
renderCamList();
if (cameras.length && !activeCam) selectCam(cameras[0]);
detectActive();
if (!cameras.length) showWaiting("no cameras detected");
}).catch(function() {});
updateSidebar();
}
function detectActive() {
var pending = cameras.length;
cameras.forEach(function(cam) {
fetch("/shm/" + cam + "/stats.json?t=" + Date.now()).then(r => r.ok ? r.json() : null).then(s => {
pending--;
if (!s) return;
var prev = lastFramePerCam[cam] || 0;
perCam[cam] = { total: s.total_entered_count, inside: s.inside_box_count, frame: s.frame_index, tracks: s.track_count, speed: s.smoothed_speed };
if (s.frame_index > prev) {
lastFramePerCam[cam] = s.frame_index;
if (activeCam !== cam) selectCam(cam);
}
}).catch(function() {}).finally(function() { if (pending === 0) renderCamList(); });
});
}
function selectCam(id) {
activeCam = id;
document.getElementById("waiting-msg").style.display = "none";
document.getElementById("frame-overlay").style.display = "block";
document.getElementById("frame-img").style.display = "block";
document.getElementById("frame-img").src = "/stream/" + id;
renderCamList();
refreshNow();
}
function poll() {
if (!activeCam) return;
var t = Date.now();
fetch("/shm/" + activeCam + "/stats.json?t=" + t).then(r => r.ok ? r.json() : null).then(s => {
if (!s) { setOffline(); return; }
lastUpdate = Date.now();
lastFramePerCam[activeCam] = s.frame_index;
perCam[activeCam] = { total: s.total_entered_count, inside: s.inside_box_count, frame: s.frame_index, tracks: s.track_count, speed: s.smoothed_speed };
document.getElementById("s-total").textContent = s.total_entered_count;
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 = 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() {
poll();
}
function showWaiting(msg) {
document.getElementById("waiting-msg").textContent = msg;
document.getElementById("waiting-msg").style.display = "block";
document.getElementById("frame-overlay").style.display = "none";
document.getElementById("frame-img").style.display = "none";
}
function setOffline() {
var tag = document.getElementById("cam-tag");
if (tag && activeCam) {
tag.textContent = activeCam + " \u2022 OFFLINE";
tag.className = "tag off";
}
}
function updateSidebar() {}
function renderCamList() {
var ul = document.getElementById("cam-list");
ul.innerHTML = cameras.map(function(c) {
var info = perCam[c] || {};
var cls = c === activeCam ? " act" : "";
return '<div class="cam-row' + cls + '" onclick="selectCam(\'' + c + '\')">' +
'<span>' + c + '</span>' +
'<span class="cam-total">' + (info.total || 0) + '</span></div>';
}).join("");
}
function updateClock() {
document.getElementById("clock").textContent = new Date().toLocaleTimeString();
}
function loadHistory() {
if (!DB_PATH) return;
var panel = document.getElementById("db-panel");
panel.style.display = "block";
fetch("/api/db/history").then(r => r.json()).then(rows => {
var html = "<h3>&#x1f4ca; History</h3>";
if (rows && rows.length) {
html += rows.map(function(r) {
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() {});
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;
var html = panel.innerHTML;
html += '<h3 style="margin-top:12px">&#x1f4f9; ' + 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">' + Number(c.total_entered || 0).toLocaleString() + '</span></div>';
});
panel.innerHTML = html;
}).catch(function() {});
}
}
var allMortalityData = [];
var selectedMortCoop = "ALL";
function loadMortality() {
fetch("/api/mortality/history").then(r => r.ok ? r.json() : []).then(reports => {
var panel = document.getElementById("mort-panel");
if (!reports || !reports.length) {
fetch("/api/mortality/latest").then(r => r.ok ? r.json() : null).then(latest => {
if (!latest || latest.error || !latest.results || !latest.results.length) {
panel.style.display = "none";
return;
}
allMortalityData = [latest];
renderMortalityUI();
}).catch(function() { panel.style.display = "none"; });
return;
}
allMortalityData = reports;
renderMortalityUI();
}).catch(function() {});
}
function renderMortalityUI() {
var panel = document.getElementById("mort-panel");
if (!allMortalityData || !allMortalityData.length) {
panel.style.display = "none";
return;
}
panel.style.display = "block";
var coopsSet = {};
allMortalityData.forEach(function(rep) {
var c = rep.coop || rep.location || "kandang-atas";
coopsSet[c] = (coopsSet[c] || 0) + (rep.total_mortality_count || 0);
});
var coopsList = Object.keys(coopsSet).sort();
var filterDiv = document.getElementById("mort-coop-filters");
if (coopsList.length > 1) {
var fHtml = '<button class="mort-filter-btn' + (selectedMortCoop === 'ALL' ? ' active' : '') + '" onclick="setMortCoopFilter(\'ALL\')">ALL</button>';
coopsList.forEach(function(c) {
fHtml += '<button class="mort-filter-btn' + (selectedMortCoop === c ? ' active' : '') + '" onclick="setMortCoopFilter(\'' + c + '\')">' + c + '</button>';
});
filterDiv.innerHTML = fHtml;
filterDiv.style.display = "flex";
} else {
filterDiv.style.display = "none";
}
var filteredReports = selectedMortCoop === "ALL"
? allMortalityData
: allMortalityData.filter(function(r) { return (r.coop || r.location || "").toUpperCase() === selectedMortCoop.toUpperCase(); });
var totalCarcasses = 0;
var allItems = [];
filteredReports.forEach(function(rep) {
totalCarcasses += (rep.total_mortality_count || 0);
var cName = rep.coop || rep.location || "kandang-atas";
var cFloors = rep.covered_floors ? rep.covered_floors.join(", ") : "";
(rep.results || []).forEach(function(res) {
allItems.push({
item: res,
coop: cName,
floors: cFloors,
date: rep.date || ""
});
});
});
document.getElementById("mort-header-badge").textContent = totalCarcasses + " detected";
var summaryTxt = (selectedMortCoop === "ALL" ? "All Coops" : selectedMortCoop) + " \u2022 " + allItems.length + " scan(s)";
document.getElementById("mort-summary-text").textContent = summaryTxt;
var grid = document.getElementById("mort-grid");
grid.innerHTML = allItems.map(function(obj) {
var item = obj.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);
var captionText = '[' + obj.coop + (obj.floors ? ' (Floors: ' + obj.floors + ')' : '') + '] ' + title + ' (' + count + ' carcasses)';
return '<div class="mort-card" onclick="openMortModal(\'' + imgSrc + '\', \'' + captionText.replace(/'/g, "\\'") + '\')">' +
'<img src="' + imgSrc + '" alt="' + title + '" loading="lazy" onerror="this.style.opacity=0.3">' +
'<div class="info">' +
'<span style="max-width:85px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + title + '">' +
'<span class="mort-coop-tag">' + obj.coop + '</span>' + title + '</span>' +
'<span class="cnt">' + count + ' 💀</span>' +
'</div></div>';
}).join("");
}
function setMortCoopFilter(coop) {
selectedMortCoop = coop;
renderMortalityUI();
}
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");
}
/* Mortality Inspection Camera & Upload Logic */
var selectedUploadFiles = [];
var mortModalTab = "cam";
function switchMortModalTab(tab) {
mortModalTab = tab;
var btnCam = document.getElementById("tab-btn-cam");
var btnFile = document.getElementById("tab-btn-file");
var contentCam = document.getElementById("mort-cam-tab-content");
var contentFile = document.getElementById("mort-file-tab-content");
if (tab === "cam") {
if (btnCam) btnCam.classList.add("active");
if (btnFile) btnFile.classList.remove("active");
if (contentCam) contentCam.style.display = "block";
if (contentFile) contentFile.style.display = "none";
refreshMortCamPreview();
} else {
if (btnFile) btnFile.classList.add("active");
if (btnCam) btnCam.classList.remove("active");
if (contentFile) contentFile.style.display = "block";
if (contentCam) contentCam.style.display = "none";
}
}
function onMortCoopChanged() {
if (mortModalTab === "cam") {
refreshMortCamPreview();
}
}
function refreshMortCamPreview() {
var coop = document.getElementById("mort-upload-coop").value || "K1";
var img = document.getElementById("mort-cam-live-img");
var offlineMsg = document.getElementById("mort-cam-offline-msg");
var overlay = document.getElementById("mort-cam-status-overlay");
if (!img) return;
overlay.textContent = "Connecting to " + coop + " camera...";
overlay.style.color = "#ff9f43";
img.onload = function() {
img.style.display = "block";
offlineMsg.style.display = "none";
overlay.textContent = "🟢 " + coop + " Camera Live";
overlay.style.color = "#2ed573";
};
img.onerror = function() {
img.style.display = "none";
offlineMsg.style.display = "block";
overlay.textContent = "⚪ Stationary Camera Ready";
overlay.style.color = "#aaa";
};
img.src = "/api/mortality/camera/preview?coop=" + encodeURIComponent(coop) + "&_t=" + Date.now();
}
function captureFromStationaryCamera() {
var coop = document.getElementById("mort-upload-coop").value || "K1";
var date = document.getElementById("mort-upload-date").value || new Date().toISOString().split("T")[0];
var btn = document.getElementById("mort-capture-btn");
var statusDiv = document.getElementById("mort-upload-status");
btn.disabled = true;
btn.innerHTML = "⏳ Snapping &amp; Counting with YOLO...";
statusDiv.style.display = "block";
statusDiv.style.color = "#ff9f43";
statusDiv.textContent = "Capturing frame from " + coop + " stationary camera and counting carcasses...";
fetch("/api/mortality/capture", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ coop: coop, date: date })
}).then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === "success") {
statusDiv.style.color = "#2ed573";
statusDiv.innerHTML = "✅ <b>Batch #" + data.total_images + ": " + data.batch_count + " carcass(es)</b> detected!<br>" +
"<span style='color:#ff9f43'>Total for " + coop + " Today: <b>" + data.total_mortality_count + " carcasses</b> across " + data.total_images + " batch(es)</span>";
btn.disabled = false;
btn.innerHTML = "📸 Snap &amp; Count Next Batch";
loadMortality();
setTimeout(refreshMortCamPreview, 600);
} else {
statusDiv.style.color = "#ff4757";
statusDiv.textContent = "❌ Capture error: " + (data.message || "Failed to capture frame");
btn.disabled = false;
btn.innerHTML = "📸 Retry Capture";
}
}).catch(function(err) {
statusDiv.style.color = "#ff4757";
statusDiv.textContent = "❌ Network error: " + err.message;
btn.disabled = false;
btn.innerHTML = "📸 Retry Capture";
});
}
function openMortUploadModal() {
var modal = document.getElementById("mort-upload-modal");
var today = new Date().toISOString().split("T")[0];
document.getElementById("mort-upload-date").value = today;
var coopSelect = document.getElementById("mort-upload-coop");
if (allMortalityData && allMortalityData.length) {
var coopsSet = {};
allMortalityData.forEach(function(r) {
var c = r.coop || r.location;
if (c) coopsSet[c] = true;
});
["K1", "K2", "K3", "K4", "K5", "kandang-atas"].forEach(function(c) { coopsSet[c] = true; });
var list = Object.keys(coopsSet).sort();
if (list.length > 0) {
coopSelect.innerHTML = list.map(function(c) {
return '<option value="' + c + '">' + c + '</option>';
}).join("");
}
}
selectedUploadFiles = [];
renderUploadPreviews();
document.getElementById("mort-upload-status").style.display = "none";
modal.classList.add("active");
switchMortModalTab("cam");
}
function closeMortUploadModal(event) {
var modal = document.getElementById("mort-upload-modal");
modal.classList.remove("active");
}
function handleMortFilesSelected(event) {
var files = event.target.files;
if (!files || !files.length) return;
for (var i = 0; i < files.length; i++) {
selectedUploadFiles.push(files[i]);
}
renderUploadPreviews();
}
function removeUploadFile(idx) {
selectedUploadFiles.splice(idx, 1);
renderUploadPreviews();
}
function renderUploadPreviews() {
var grid = document.getElementById("mort-preview-grid");
var btn = document.getElementById("mort-submit-btn");
if (!selectedUploadFiles.length) {
grid.innerHTML = "";
btn.disabled = true;
btn.textContent = "Count Mortalities (0 photos selected)";
return;
}
btn.disabled = false;
btn.textContent = "Scan & Count Chickens (" + selectedUploadFiles.length + " photo" + (selectedUploadFiles.length > 1 ? "s" : "") + ")";
grid.innerHTML = selectedUploadFiles.map(function(file, idx) {
var url = URL.createObjectURL(file);
return '<div class="mort-preview-item">' +
'<img src="' + url + '" alt="preview">' +
'<button class="rm-btn" onclick="removeUploadFile(' + idx + ')">&times;</button>' +
'</div>';
}).join("");
}
function submitMortalityUpload() {
if (!selectedUploadFiles.length) return;
var coop = document.getElementById("mort-upload-coop").value || "K1";
var date = document.getElementById("mort-upload-date").value || new Date().toISOString().split("T")[0];
var btn = document.getElementById("mort-submit-btn");
var statusDiv = document.getElementById("mort-upload-status");
btn.disabled = true;
btn.textContent = "⏳ Running YOLO Detection...";
statusDiv.style.display = "block";
statusDiv.style.color = "#ff9f43";
statusDiv.textContent = "Uploading " + selectedUploadFiles.length + " photo(s) and counting carcasses...";
var formData = new FormData();
formData.append("coop", coop);
formData.append("date", date);
for (var i = 0; i < selectedUploadFiles.length; i++) {
formData.append("images", selectedUploadFiles[i]);
}
fetch("/api/mortality/upload", {
method: "POST",
body: formData
}).then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === "success") {
statusDiv.style.color = "#2ed573";
statusDiv.innerHTML = "✅ <b>" + data.total_mortality_count + " carcasses</b> detected across " + data.total_images + " photo(s) for <b>" + coop + "</b> (" + date + ")!";
btn.textContent = "✅ Scan Completed";
loadMortality();
setTimeout(function() {
closeMortUploadModal();
}, 2500);
} else {
statusDiv.style.color = "#ff4757";
statusDiv.textContent = "❌ Error: " + (data.message || "Failed to process photos");
btn.disabled = false;
btn.textContent = "Retry Scan";
}
}).catch(function(err) {
statusDiv.style.color = "#ff4757";
statusDiv.textContent = "❌ Network error: " + err.message;
btn.disabled = false;
btn.textContent = "Retry Scan";
});
}
document.addEventListener("keydown", function(e) {
if (e.key === "Escape") {
closeMortModal();
closeMortUploadModal();
}
});
setTimeout(function() { if (activeCam) refreshNow(); }, 500);
boot();
</script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
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")
# Loop through each item
for item in "${my_array[@]}"; do
chicken-counter batch --config configs/cycle7_batch.yaml --date ${item} --no-video
done
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -d "$SCRIPT_DIR/../src" ]; then
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
else
PROJECT_ROOT="$SCRIPT_DIR"
fi
cd "$PROJECT_ROOT"
export PYTHONPATH="$PROJECT_ROOT/src"
if [ -f "$PROJECT_ROOT/venv/bin/python" ]; then
PYTHON="$PROJECT_ROOT/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 "=== 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 ==="
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -d "$SCRIPT_DIR/../src" ]; then
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
else
PROJECT_ROOT="$SCRIPT_DIR"
fi
cd "$PROJECT_ROOT"
export PYTHONPATH="$PROJECT_ROOT/src"
if [ -f "$PROJECT_ROOT/venv/bin/python" ]; then
PYTHON="$PROJECT_ROOT/venv/bin/python"
else
PYTHON="python3"
fi
if [ $# -ge 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 ==="
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more