diff --git a/.gitignore b/.gitignore index b3ec7d5..6195a92 100644 --- a/.gitignore +++ b/.gitignore @@ -218,3 +218,11 @@ __marimo__/ # Streamlit .streamlit/secrets.toml + +# Project local / runtime outputs +runs/ +output/ +*.log +*.db-shm +*.db-wal +*.2026* diff --git a/API.md b/API.md index c6642a8..1f18c2a 100644 --- a/API.md +++ b/API.md @@ -2,7 +2,22 @@ Base URL: `http://:8080` -## Live Dashboard +## System Status & Live Monitoring + +### `GET /api/status` +Returns real-time pipeline activity status, active streaming cameras, latest processed date, and all-time total counts. + +```json +{ + "status": "running", + "is_counting_active": true, + "active_cameras": ["CC1", "CC2", "CC3", "CC4"], + "latest_counted_date": "2026-06-18", + "total_chickens_all_time": 146418, + "cycle_start_date": "2026-05-22", + "timestamp": "2026-08-14T08:30:00.000000+00:00" +} +``` ### `GET /` Returns the dashboard HTML page. @@ -39,6 +54,20 @@ Live JPEG frame from the active pipeline. All endpoints require the dashboard to be started with `--db `. If no DB exists, endpoints return `[]` or `{}`. +### `GET /api/config/cycle_start_date` +Returns or updates the active Day 0 (`cycle_start_date`). + +```bash +# Query active Day 0 +GET /api/config/cycle_start_date +→ {"cycle_start_date": "2026-05-22"} + +# Override Day 0 dynamically via query param or POST payload +GET /api/config/cycle_start_date?set=2026-05-22 +POST /api/config/cycle_start_date {"cycle_start_date": "2026-05-22"} +→ {"status": "ok", "cycle_start_date": "2026-05-22"} +``` + ### `GET /api/db/summary` Overall totals across all dates and locations. @@ -147,3 +176,131 @@ Data is inserted automatically by the batch runner when `location` and `db_path` ```bash python3 store_results.py output/counts_2026-06-10.json --location kandang-atas --db chicken_counts.db ``` + +--- + +## Mortality Detection + +Mortality endpoints require the dashboard to be started with `--mortality-dir `. 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_.jpg`), and aggregates the daily total carcass count into `total_mortality_count`. + +### `GET /api/mortality/latest` +Returns the most recently modified `mortality_report.json` across all registered mortality directories. + +```json +{ + "date": "2026-07-09", + "mode": "similarity_two_pass", + "model_path": "...", + "conf_threshold": 0.7, + "iou_threshold": 0.8, + "total_images": 2, + "total_mortality_count": 35, + "_dir": "/path/to/mortality", + "results": [ + { + "input_image": "scan_01.jpg", + "output_image": "output_scan_01.jpg", + "count": 19, + "detections": [ + { + "id": 1, + "box": [177, 161, 288, 347], + "confidence": 0.9597, + "area": 20646 + } + ] + }, + { + "input_image": "scan_02.jpg", + "output_image": "output_scan_02.jpg", + "count": 16, + "detections": [...] + } + ] +} +``` + +### `GET /api/mortality/history` +Returns all `mortality_report.json` files from all registered directories, sorted newest first. Each report contains `total_mortality_count` (grand total across all images in that run) and `total_images`. + +### `GET /api/mortality/date/` +Returns all mortality scans and total carcass counts recorded on a specific date: + +```json +{ + "date": "2026-07-09", + "total_mortality_count": 35, + "total_images": 2, + "reports": [...], + "results": [...] +} +``` + +### `GET /api/mortality/image/` +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://: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 +``` + + diff --git a/README.md b/README.md old mode 100755 new mode 100644 index 683d8d1..48e0c00 --- a/README.md +++ b/README.md @@ -47,12 +47,16 @@ src/chicken_counter/ ## Install ```bash -python -m pip install -e . +python3 -m venv venv +venv/bin/pip install --upgrade pip +venv/bin/pip install -r requirements.txt ``` For Jetson deployment you will usually want a Jetson-compatible OpenCV and PyTorch stack already installed, then install the rest of the package around that environment. +> **Tip:** See `RUN.md` for a full step-by-step quickstart guide for new developers. + ## Run Update `configs/cameras/example_camera.yaml` with: @@ -199,7 +203,9 @@ detection: imgsz: 640 # keep 640 while using existing TensorRT .engine ``` -`configs/cycle7_batch.yaml` already uses these production defaults. +`configs/cycle7_batch.yaml` and `configs/cycle7_batch_optimized.yaml` already use these production defaults. + +`configs/cycle7_batch_optimized.yaml` adds per-camera parallelism, trimmed inference settings, and optimized YAML structure for the Sukawarna enclosure. **Validation:** run a short clip with stride enabled, then compare `total_entered` against `inference_stride: 1` and `motion.stride_frames: 1`. Watch checkpoint `fps=` logs for @@ -217,6 +223,39 @@ for tuning. - The optical-flow trigger is vision-first, though the config structure leaves room for a future controller/encoder integration path +## Multi-Stage Growth Cycles & Day 0 Configuration + +The pipeline dynamically adjusts detection and ROI entry thresholds based on flock age (Days Old Chick / DOC vs Mid-Cycle): + +- **Day 0 (`cycle_start_date`)**: Configured in `configs/cycle7_batch_optimized.yaml` (default: `"2026-05-22"`). Can be overridden via CLI (`--cycle-start-date YYYY-MM-DD`) or REST API (`/api/config/cycle_start_date`). +- **`early_cycle` (Days 0–15)**: Automatically applies high-sensitivity detection thresholds (`conf: 0.12`, `min_box_area_px: 200`, `min_overlap_ratio: 0.25`) for small fast-moving DOC chicks. +- **`mid_cycle` (Days 16+)**: Preserves standard tuned per-camera defaults (`conf: 0.35–0.50`, `min_box_area_px: 2500–3000`). + +## Mortality Detection + +A separate pipeline detects carcasses (dead birds) from still photos. Supports multi-image daily runs and date subfolders (`mortality/YYYY-MM-DD/`). + +```bash +# Run on default mortality directory +./test_run_mortality.sh + +# Run on a specific date (auto-creates/routes to mortality/2026-05-23/) +chicken-counter mortality --date 2026-05-23 +``` + +Key features: +- **Multi-Image & Multi-Day Support**: Processes multiple images per day (e.g. morning/afternoon scans), aggregates the grand total carcass count (`total_mortality_count`), and saves outputs into date-isolated directories. +- **2-Pass Detect & Refine**: runs the YOLO segmentation model once to find candidate regions, then uses `cv2.matchTemplate` to do a similarity search over each candidate before confirming it as a detection. This reduces false positives significantly. +- **Containment filtering**: boxes where `IoA > 0.50` against a larger box are suppressed. +- **Centroid deduplication**: detections whose centroids are within `dedupe_radius_px` of each other are merged to prevent counting the same carcass twice. +- Uses the **segmentation model** (`models/chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt`) which provides higher boundary precision than the standard detection model. + +Outputs for each daily run: +- `output_.jpg` — annotated images with bounding boxes and carcass IDs +- `mortality_report.json` — full summary report with `total_mortality_count`, per-image breakdown, and detection coordinates + +Config: `configs/mortality_config.yaml` + ## Headless Jetson MP4 Example For a headless run that saves both output video and periodic checkpoint images, use a @@ -255,7 +294,7 @@ motion: flow_scale: 0.5 display: show_window: false - output_path: /media/jetson/DATA/try-sukawarna-vis.mp4 + output_path: /home/asus/.Codes/try-sukawarna-vis.mp4 encoder: auto output_bitrate_kbps: 4000 performance: @@ -294,14 +333,15 @@ For everyday processing of 4 cameras, use `configs/cycle7_batch.yaml`. ### Input folder layout -Place today's videos under: +Place today's videos in a `VIDEOS` folder **adjacent to the project directory** (i.e. `../VIDEOS` relative to the project root): ```text -/media/jetson/DATA/chicken-sukawarna/cycle7/2026-07-09/ - kandang_1_camera_1_2026-07-09_120056.mp4 - kandang_1_camera_2_2026-07-09_120456.mp4 - kandang_1_camera_3_2026-07-09_121012.mp4 - kandang_1_camera_4_2026-07-09_121530.mp4 +../VIDEOS/cycle7/kandang-atas/ + 2026-06-18/ + kandang_1_camera_1_2026-06-18_120056.mp4 + kandang_1_camera_2_2026-06-18_120456.mp4 + kandang_1_camera_3_2026-06-18_121012.mp4 + kandang_1_camera_4_2026-06-18_121530.mp4 ``` Date folders use `YYYY-MM-DD`. Camera files are matched by `camera_num` using the @@ -310,13 +350,32 @@ pattern `kandang_*_camera_{num}_*.mp4`. ### Run commands ```bash -# Process today's folder -chicken-counter batch --config configs/cycle7_batch.yaml +# Process today's folder using default parallel process mode +chicken-counter batch --config configs/cycle7_batch_optimized.yaml -# Process a specific date -chicken-counter batch --config configs/cycle7_batch.yaml --date 2026-07-09 +# Process a specific date using Model-Level Tensor Batching mode +chicken-counter batch --config configs/cycle7_batch_optimized.yaml --date 2026-06-18 --mode tensor_batching + +# Process a specific date using Hybrid mode (Threaded CPU + Batched GPU) +chicken-counter batch --config configs/cycle7_batch_optimized.yaml --date 2026-06-18 --mode hybrid + +# Run automated batch script for Tensor Batching +./test_run_tensor_batch.sh # Runs all dates (2026-06-10 to 2026-06-19) +./test_run_tensor_batch.sh 2026-06-18 # Runs a specific date + +# Run automated batch script for Hybrid execution mode +./test_run_hybrid.sh # Runs all dates (2026-06-10 to 2026-06-19) +./test_run_hybrid.sh 2026-06-18 # Runs a specific date ``` +### Execution Modes (`execution_mode`) + +Set `batch.execution_mode` in `configs/cycle7_batch_optimized.yaml` or override via `--mode`: + +- `parallel_processes` (Default): Runs cameras in separate OS processes (e.g. via `test_run.sh`). +- `tensor_batching`: Synchronizes camera frame streams and executes a single batched GPU model inference pass across all cameras (`batch_size=N`). +- `hybrid`: Combines multi-threaded CPU frame capture, optical flow, and rendering across CPU cores with a single synchronized batched GPU forward pass (`batch_size=N`). + Single-camera mode still works: ```bash @@ -327,7 +386,7 @@ chicken-counter run --config configs/cameras/example_camera.yaml ### Output layout ```text -/media/jetson/DATA/chicken-sukawarna/cycle7/2026-07-09/output/ +../VIDEOS/cycle7/kandang-atas/2026-06-18/output/ CC1_vis.mp4 CC1_compressed.mp4 CC2_vis.mp4 @@ -335,7 +394,7 @@ chicken-counter run --config configs/cameras/example_camera.yaml ... checkpoints/CC1/frame_003000.jpg checkpoints/CC2/frame_006000.jpg - counts_2026-07-09.json + counts_2026-06-18.json ``` After all 4 cameras finish counting, the batch runner compresses each annotated video @@ -384,11 +443,37 @@ For a ~72k frame run that is about 24 images per camera. ### Cron example ```cron -0 7 * * * cd /media/jetson/DATA/chicken-sukawarna && /usr/bin/chicken-counter batch --config configs/cycle7_batch.yaml >> logs/cycle7-batch.log 2>&1 +0 7 * * * cd /path/to/chicken-counting-sukawarna-det && ./test_run.sh >> logs/cycle7-batch.log 2>&1 ``` Requires `ffmpeg` on the Jetson PATH for post-run compression. +## Dashboard & API + +Start the live dashboard and API server: + +```bash +# Portable launcher (recommended) — auto-discovers mortality dir +./start_dashboard.sh + +# Or start manually +PYTHONPATH=src venv/bin/python dashboard.py \ + --port 8080 \ + --db db/chicken_counts.db \ + --mortality-dir ../VIDEOS/cycle7/kandang-atas/mortality +``` + +Key API endpoints (see `API.md` for full schema): + +| Endpoint | Description | +| :--- | :--- | +| `GET /api/db/summary` | Lifetime totals across all dates | +| `GET /api/db/history` | Per-date summary, newest first | +| `GET /api/db/date/` | 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/` | Serve annotated output JPEG | + ## Next Jetson-Focused Improvements 1. Add a hardware-aware video ingest path for CSI/GStreamer. @@ -397,4 +482,5 @@ Requires `ffmpeg` on the Jetson PATH for post-run compression. Recent work includes daily 4-camera batch processing, JSON count reports, post-run compression under 200 MB, GStreamer hardware encoding, overlay buffer reuse, -duplicate optical-flow removal (`gmc_method: none`), and long-run ETA logging. +duplicate optical-flow removal (`gmc_method: none`), long-run ETA logging, mortality +2-pass detection with feature similarity search, and a REST API via `dashboard.py`. diff --git a/RUN.md b/RUN.md index 91b56f0..585fdcc 100644 --- a/RUN.md +++ b/RUN.md @@ -1,11 +1,240 @@ -# Counter -## Pakai virtual env di /media/jetson/DATA/karung-sukawarna/venv +# Quick Start Guide -alias chicken-counter='PYTHONPATH={fullpath git clone folder} /media/jetson/DATA/karung-sukawarna/venv/bin/python -m chicken_counter.cli' +This guide gets a new developer up and running from scratch. -chicken-counter batch --config configs/cycle7_batch.yaml --date 2026-06-10 --no-video --progress-bar +--- -# Dashboard -## Pakai virtual env di /media/jetson/DATA/karung-sukawarna/venv -source /media/jetson/DATA/karung-sukawarna/venv/bin/source -python dashboard.py --port 8080 +## 1. Prerequisites + +- Python 3.10+ +- `ffmpeg` on PATH (for video compression) +- NVIDIA GPU + CUDA drivers (optional but recommended for inference speed) + +--- + +## 2. First-Time Setup + +```bash +# Clone / copy the project folder, then enter it +cd chicken-counting-sukawarna-det + +# Create a virtual environment and install all dependencies +python3 -m venv venv +venv/bin/pip install --upgrade pip +venv/bin/pip install -r requirements.txt +``` + +> **Note**: If moving the project from another machine, always recreate the venv. +> Do NOT copy the `venv/` folder — it contains absolute paths baked in from the source machine. + +--- + +## 3. Project Layout + +```text +chicken-counting-sukawarna-det/ +├── configs/ +│ ├── cycle7_batch_optimized.yaml ← Main batch processing config +│ ├── cycle7_batch.yaml ← Alternate batch config +│ ├── mortality_config.yaml ← Mortality (carcass) detection config +│ ├── cameras/example_camera.yaml ← Single-camera run config template +│ └── trackers/botsort_chicken.yaml ← BoT-SORT tracker settings +├── db/ +│ └── chicken_counts.db ← SQLite database (auto-created) +├── models/ ← Place your .pt / .onnx / .engine files here +│ ├── chicken-detection-model-v26n-300e-best-2026-05-02-NEW.onnx +│ └── chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt ← Used by mortality +├── src/chicken_counter/ ← Main Python package +├── templates/ ← Dashboard HTML +├── dashboard.py ← Live API + Dashboard server +├── start_dashboard.sh ← Portable dashboard launcher ← USE THIS +├── test_run_mortality.sh ← Run mortality detection +├── test_run.sh ← Run batch processing all dates +└── requirements.txt +``` + +--- + +## 4. Model Setup + +Put your model files in the `models/` directory. + +| Purpose | File | +| :--- | :--- | +| Batch video counting | `chicken-detection-model-v26n-300e-best-2026-05-02-NEW.onnx` (speed) or `.pt` (accuracy) | +| Mortality detection | `chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt` | + +Update `configs/mortality_config.yaml` and `configs/cycle7_batch_optimized.yaml` if using different filenames. + +--- + +## 5. Running the Systems + +### A — Batch Video Processing (Daily Chicken Count) + +Place input videos under the `VIDEOS` folder adjacent to the project: + +```text +../VIDEOS/cycle7/kandang-atas/ + 2026-06-18/ + kandang_1_camera_1_2026-06-18_120056.mp4 + kandang_1_camera_2_2026-06-18_120456.mp4 + ... +``` + +Then run: + +```bash +# Run all dates (multi-process mode) +./test_run.sh + +# Run all dates (Tensor Batching mode) +./test_run_tensor_batch.sh + +# Run all dates (Hybrid mode: Threaded CPU + Batched GPU) +./test_run_hybrid.sh + +# Run a specific date (4 cameras in parallel) +PYTHONPATH=src venv/bin/python -m chicken_counter.cli batch \ + --config configs/cycle7_batch_optimized.yaml \ + --date 2026-06-18 + +# Run a specific date using Model-Level Tensor Batching +PYTHONPATH=src venv/bin/python -m chicken_counter.cli batch \ + --config configs/cycle7_batch_optimized.yaml \ + --date 2026-06-18 \ + --mode tensor_batching + +# Run a specific date using Hybrid Execution Mode +PYTHONPATH=src venv/bin/python -m chicken_counter.cli batch \ + --config configs/cycle7_batch_optimized.yaml \ + --date 2026-06-18 \ + --mode hybrid + +# Run with video output enabled +./test_run_video_2026-06-18.sh +``` + +Output is saved in `../VIDEOS/cycle7/kandang-atas/2026-06-18/output/`. + +--- + +### B — Mortality Detection (Carcass Photo Scanning) + +Place input photos in `../VIDEOS/cycle7/kandang-atas/mortality/`. + +```bash +# Run with default config +./test_run_mortality.sh + +# Override confidence threshold +CONF=0.65 ./test_run_mortality.sh + +# Run on a specific image +./test_run_mortality.sh /path/to/photo.jpg +``` + +Output annotated images are saved as `output_.jpg` in the same directory. +A `mortality_report.json` is also saved there with full detection data. + +#### Key config options in `configs/mortality_config.yaml` + +| Setting | Description | +| :--- | :--- | +| `conf` | Detection confidence threshold (0.0–1.0) | +| `iou` | IoU NMS threshold | +| `min_box_area_px` | Minimum bounding box area in pixels | +| `dedupe_radius_px` | Centroid deduplication radius in pixels | +| `two_pass` | Enable 2x Detect & Refine pipeline | +| `classes` | `[0]` = chicken only; ignores background/text/equipment | + +--- + +### C — Dashboard & API Server + +```bash +# Start the dashboard (auto-discovers mortality directory) +./start_dashboard.sh + +# Custom port and directories +PORT=9090 ./start_dashboard.sh + +# Multiple mortality directories +MORTALITY_DIRS="/path/to/mortality1,/path/to/mortality2" ./start_dashboard.sh +``` + +Open in browser: **http://localhost:8080** + +Available API endpoints: + +| Endpoint | Description | +| :--- | :--- | +| `GET /api/status` | Live system status, active counting cameras & latest date | +| `GET /api/cameras` | Live camera list from `/dev/shm` | +| `GET /api/db/summary` | Total chickens, days, hours across all dates | +| `GET /api/db/history` | Per-date summary, newest first | +| `GET /api/db/date/` | Per-camera breakdown for a specific date | +| `GET /api/db/camera/` | History for a specific camera (CC1, CC2...) | +| `GET /api/db/location/` | 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/` | Serve annotated output JPEG by filename | +| `GET /shm//stats.json` | Live pipeline stats for a running camera | +| `GET /shm//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. diff --git a/chicken-dashboard.service b/chicken-dashboard.service index eec1cdd..37ead2b 100644 --- a/chicken-dashboard.service +++ b/chicken-dashboard.service @@ -4,12 +4,13 @@ After=network.target [Service] Type=simple -User=dsutanto -WorkingDirectory=/media/jetson/DATA/.Codes/chicken-counting-sukawarna-det -ExecStart=/media/jetson/DATA/karung-sukawarna/venv/bin/python dashboard.py --port 8080 --db db/chicken_counts.db +User=asus +WorkingDirectory=/home/asus/.Codes/chicken-counting-sukawarna-det +ExecStart=/home/asus/.Codes/chicken-counting-sukawarna-det/start_dashboard.sh Restart=always RestartSec=5 Environment=PYTHONUNBUFFERED=1 +Environment=PORT=8080 [Install] WantedBy=multi-user.target diff --git a/configs/cameras/example_camera.yaml b/configs/cameras/example_camera.yaml old mode 100755 new mode 100644 index adb2e19..f566ec1 --- a/configs/cameras/example_camera.yaml +++ b/configs/cameras/example_camera.yaml @@ -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 diff --git a/configs/cycle7_batch.yaml b/configs/cycle7_batch.yaml old mode 100755 new mode 100644 index 1803fdb..ccd88ce --- a/configs/cycle7_batch.yaml +++ b/configs/cycle7_batch.yaml @@ -1,16 +1,16 @@ batch: - root_dir: /media/jetson/DATA/.Codes/VIDEOS/cycle7/kandang-atas + root_dir: ../VIDEOS/cycle7/kandang-atas camera_glob: "kandang_*_camera_{num}_*.mp4" output_subdir: output compress_max_mb: 200 delete_intermediate: false checkpoint_every_n_frames: 3000 location: kandang-atas - db_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/db/chicken_counts.db + db_path: db/chicken_counts.db defaults: detection: - model_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine + model_path: models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.onnx #pt = accuracy, onnx = speed classes: [0] ignored_classes: [1, 2] conf: 0.35 @@ -19,15 +19,18 @@ defaults: device: "0" min_box_area_px: 3000 validate_while_inside: true + # Dedupe off by default; enabled only on CC2/CC3 (see cameras below). + dedupe_radius_px: 0 + dedupe_frames: 12 detection_zone: enabled: true buffer_above_px: 250 buffer_below_px: 250 show_in_overlay: true tracker: - tracker_config_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/configs/trackers/botsort_chicken.yaml + tracker_config_path: configs/trackers/botsort_chicken.yaml persist: true - track_buffer: 75 + track_buffer: 90 gate: mode: two_line lines_y: [420, 730] @@ -97,6 +100,10 @@ cameras: CC2: camera_num: 2 count_anchor: [900, 120] + detection: + # ~5% double-count from ID flips; tight radius only. + dedupe_radius_px: 24 + dedupe_frames: 12 roi: points: - [20, 380] @@ -106,6 +113,9 @@ cameras: CC3: camera_num: 3 count_anchor: [900, 120] + detection: + dedupe_radius_px: 24 + dedupe_frames: 12 roi: points: - [20, 330] diff --git a/configs/cycle7_batch.yaml.2 b/configs/cycle7_batch.yaml.2 old mode 100755 new mode 100644 index c11cb45..ada5117 --- a/configs/cycle7_batch.yaml.2 +++ b/configs/cycle7_batch.yaml.2 @@ -1,5 +1,5 @@ batch: - root_dir: /media/jetson/DATA/.Codes/VIDEOS/cycle7/kandang-atas + root_dir: /home/asus/.Codes/VIDEOS/cycle7/kandang-atas camera_glob: "kandang_*_camera_{num}_*.mp4" output_subdir: output compress_max_mb: 200 @@ -8,7 +8,7 @@ batch: defaults: detection: - model_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine + model_path: /home/asus/.Codes/chicken-counting-sukawarna-det/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine classes: [0] ignored_classes: [1, 2] conf: 0.35 @@ -23,7 +23,7 @@ defaults: buffer_below_px: 250 show_in_overlay: true tracker: - tracker_config_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/configs/trackers/botsort_chicken.yaml + tracker_config_path: /home/asus/.Codes/chicken-counting-sukawarna-det/configs/trackers/botsort_chicken.yaml persist: true track_buffer: 75 gate: diff --git a/configs/cycle7_batch.yaml.3 b/configs/cycle7_batch.yaml.3 old mode 100755 new mode 100644 index d50284f..2709e6c --- a/configs/cycle7_batch.yaml.3 +++ b/configs/cycle7_batch.yaml.3 @@ -1,5 +1,5 @@ batch: - root_dir: /media/jetson/DATA/.Codes/VIDEOS/cycle7/kandang-atas + root_dir: /home/asus/.Codes/VIDEOS/cycle7/kandang-atas camera_glob: "kandang_*_camera_{num}_*.mp4" output_subdir: output compress_max_mb: 200 @@ -8,7 +8,7 @@ batch: defaults: detection: - model_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine + model_path: /home/asus/.Codes/chicken-counting-sukawarna-det/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine classes: [0] ignored_classes: [1, 2] conf: 0.35 @@ -23,7 +23,7 @@ defaults: buffer_below_px: 250 show_in_overlay: true tracker: - tracker_config_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/configs/trackers/botsort_chicken.yaml + tracker_config_path: /home/asus/.Codes/chicken-counting-sukawarna-det/configs/trackers/botsort_chicken.yaml persist: true track_buffer: 75 gate: diff --git a/configs/cycle7_batch.yaml.4 b/configs/cycle7_batch.yaml.4 old mode 100755 new mode 100644 index d50284f..2709e6c --- a/configs/cycle7_batch.yaml.4 +++ b/configs/cycle7_batch.yaml.4 @@ -1,5 +1,5 @@ batch: - root_dir: /media/jetson/DATA/.Codes/VIDEOS/cycle7/kandang-atas + root_dir: /home/asus/.Codes/VIDEOS/cycle7/kandang-atas camera_glob: "kandang_*_camera_{num}_*.mp4" output_subdir: output compress_max_mb: 200 @@ -8,7 +8,7 @@ batch: defaults: detection: - model_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine + model_path: /home/asus/.Codes/chicken-counting-sukawarna-det/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine classes: [0] ignored_classes: [1, 2] conf: 0.35 @@ -23,7 +23,7 @@ defaults: buffer_below_px: 250 show_in_overlay: true tracker: - tracker_config_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/configs/trackers/botsort_chicken.yaml + tracker_config_path: /home/asus/.Codes/chicken-counting-sukawarna-det/configs/trackers/botsort_chicken.yaml persist: true track_buffer: 75 gate: diff --git a/configs/cycle7_batch_optimized.yaml b/configs/cycle7_batch_optimized.yaml index 6469ea2..f31112e 100644 --- a/configs/cycle7_batch_optimized.yaml +++ b/configs/cycle7_batch_optimized.yaml @@ -1,5 +1,5 @@ batch: - root_dir: /media/jetson/DATA/.Codes/VIDEOS/cycle7/kandang-atas + root_dir: "../VIDEOS/cycle7/kandang-atas" camera_glob: "kandang_*_camera_{num}_*.mp4" output_subdir: output compress_max_mb: 200 @@ -7,27 +7,44 @@ batch: checkpoint_every_n_frames: 3000 location: kandang-atas db_path: db/chicken_counts.db + cycle_start_date: "2026-05-22" #day 0 + execution_mode: parallel_processes # parallel_processes (default) | tensor_batching | hybrid + +stages: + early_cycle: + day_min: 0 + day_max: 15 + detection: + conf: 0.1 + min_box_area_px: 100 + roi: + min_overlap_ratio: 0.1 # 0.25 + mid_cycle: + day_min: 16 + day_max: 999 defaults: detection: - model_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine + model_path: models/NUC5070_chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine # TensorRT engine (RTX 5070) classes: [0] ignored_classes: [1, 2] - conf: 0.55 # ↑ 0.35 → fewer false positives, less tracking CPU + conf: 0.40 # mild lift vs 0.35; 0.55 was undercounting iou: 0.55 imgsz: 640 device: "0" - min_box_area_px: 5000 # ↑ 3000 → filter small false positives + min_box_area_px: 3000 validate_while_inside: true + dedupe_radius_px: 0 # off for CC1/CC4; CC2/CC3 enable below + dedupe_frames: 12 detection_zone: enabled: true buffer_above_px: 250 buffer_below_px: 250 show_in_overlay: true tracker: - tracker_config_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/configs/trackers/botsort_chicken.yaml + tracker_config_path: configs/trackers/botsort_chicken.yaml persist: true - track_buffer: 75 + track_buffer: 90 gate: mode: two_line lines_y: [420, 730] @@ -41,7 +58,7 @@ defaults: reverse_exit_threshold: -0.5 debounce_frames: 12 min_features: 40 # ↓ 60 → less optical flow computation - max_corners: 50 # ↓ 80 → fewer corner features + max_corners: 50 # ↓ 80 → fewer corner featuresis stride_frames: 3 # ↑ 2 → motion detection every 3rd frame flow_scale: 0.5 quality_level: 0.01 @@ -83,12 +100,15 @@ defaults: inset_right_px: 60 inset_top_px: 0 inset_bottom_px: 0 - min_overlap_ratio: 0.35 # ↑ 0.30 → stricter counting validation + min_overlap_ratio: 0.30 cameras: CC1: camera_num: 1 count_anchor: [780, 120] + detection: + min_box_area_px: 2500 + conf: 0.35 roi: points: - [250, 330] @@ -98,8 +118,10 @@ cameras: CC2: camera_num: 2 count_anchor: [900, 120] - detection: # per-camera override: wider angle = smaller bboxes - min_box_area_px: 3000 + detection: + conf: 0.5 # From 0.45 + dedupe_radius_px: 32 + dedupe_frames: 24 # From 18 roi: points: - [20, 380] @@ -109,6 +131,9 @@ cameras: CC3: camera_num: 3 count_anchor: [900, 120] + detection: + dedupe_radius_px: 24 + dedupe_frames: 16 # From 12 roi: points: - [20, 330] diff --git a/configs/mortality_config.yaml b/configs/mortality_config.yaml new file mode 100644 index 0000000..5aabeeb --- /dev/null +++ b/configs/mortality_config.yaml @@ -0,0 +1,13 @@ +# Configuration for Mortality Chicken Carcass Detection & Counting +mortality: + input_dir: "../VIDEOS/cycle7/kandang-atas/mortality" + output_dir: null # null = save output_ in input directory + model_path: "models/chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt" + device: "0" # "0" for GPU acceleration, or "cpu" + conf: 0.7 + iou: 0.8 + min_box_area_px: 1500 + dedupe_radius_px: 30.0 + two_pass: false # Enable Union 2x Detect & Refine crop pipeline + classes: [0] # Class 0 = chicken ONLY; ignores background/text/equipment (classes 1 not-chicken, 2 half-chicken) + imgsz: 640 diff --git a/configs/trackers/botsort_chicken.yaml b/configs/trackers/botsort_chicken.yaml old mode 100755 new mode 100644 index d0beb51..414168c --- a/configs/trackers/botsort_chicken.yaml +++ b/configs/trackers/botsort_chicken.yaml @@ -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 \ No newline at end of file +model: auto diff --git a/dashboard.py b/dashboard.py old mode 100755 new mode 100644 index 0ee1fa9..89f0bbc --- a/dashboard.py +++ b/dashboard.py @@ -5,9 +5,11 @@ from __future__ import annotations import argparse import json +import mimetypes import sqlite3 import threading import time +from datetime import datetime, timezone from http.server import HTTPServer, SimpleHTTPRequestHandler from pathlib import Path from socketserver import ThreadingMixIn @@ -25,6 +27,22 @@ TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" _db_conn = None _db_lock = threading.Lock() _db_path = "" +_mortality_dirs: list[Path] = [] +_cycle_start_date: str = "2026-05-22" + + +def _calc_cycle_info(target_date_str: str) -> dict: + if not _cycle_start_date or not target_date_str: + return {} + try: + from datetime import date as date_type + start_d = date_type.fromisoformat(str(_cycle_start_date)) + run_d = date_type.fromisoformat(str(target_date_str)) + c_day = (run_d - start_d).days + stage = "early_cycle" if 0 <= c_day <= 15 else ("mid_cycle" if c_day >= 16 else "pre_cycle") + return {"cycle_day": c_day, "stage": stage} + except Exception: + return {} def _get_db(): @@ -93,6 +111,10 @@ class DashboardHandler(SimpleHTTPRequestHandler): self._handle_stream(path) return + if path in ("/api/status", "/api/system/status"): + self._handle_status() + return + if path == "/api/cameras": self._send_json({"cameras": _discover_cameras(self.shm_dir)}) return @@ -101,12 +123,45 @@ class DashboardHandler(SimpleHTTPRequestHandler): self._handle_db(path) return + if path.startswith("/api/mortality"): + self._handle_mortality(path) + return + if path.startswith("/shm/"): self._handle_shm(path) return self._send_error(404) + def _handle_status(self): + cams = _discover_cameras(self.shm_dir) + now = time.time() + active_streams = [] + for cam in cams: + stat_file = Path(self.shm_dir) / f"chicken_counter_{cam}" / "stats.json" + if stat_file.is_file() and (now - stat_file.stat().st_mtime) < 15.0: + active_streams.append(cam) + + is_running = len(active_streams) > 0 + conn = _get_db() + latest_date = None + total_chickens = 0 + if conn: + row = conn.execute("SELECT MAX(date) AS latest_date, SUM(total_entered) AS total FROM batch_runs").fetchone() + if row: + latest_date = row["latest_date"] + total_chickens = row["total"] or 0 + + self._send_json({ + "status": "running" if is_running else "idle", + "is_counting_active": is_running, + "active_cameras": active_streams, + "latest_counted_date": latest_date, + "total_chickens_all_time": total_chickens, + "cycle_start_date": _cycle_start_date, + "timestamp": datetime.now(timezone.utc).isoformat(), + }) + def _handle_stream(self, path): cam_id = path[len("/stream/"):] frame_path = Path(self.shm_dir) / f"chicken_counter_{cam_id}" / "frame.jpg" @@ -170,12 +225,19 @@ class DashboardHandler(SimpleHTTPRequestHandler): if path == "/api/db/summary": row = conn.execute("SELECT COUNT(DISTINCT date) AS days, COUNT(DISTINCT location) AS locations, COUNT(*) AS total_runs, SUM(total_entered) AS total_chickens, ROUND(SUM(elapsed_seconds)/3600.0,1) AS total_hours FROM batch_runs").fetchone() - self._send_json(dict(row)) + d = dict(row) + d["cycle_start_date"] = _cycle_start_date + self._send_json(d) return if path == "/api/db/history": rows = conn.execute("SELECT date, location, COUNT(*) AS cams, SUM(total_entered) AS total, ROUND(SUM(elapsed_seconds)/60.0,1) AS minutes FROM batch_runs GROUP BY date, location ORDER BY date DESC, location LIMIT 50").fetchall() - self._send_json([dict(r) for r in rows]) + history = [] + for r in rows: + d = dict(r) + d.update(_calc_cycle_info(d["date"])) + history.append(d) + self._send_json(history) return # /api/db/date/ @@ -184,7 +246,9 @@ class DashboardHandler(SimpleHTTPRequestHandler): date = path[len(prefix):] cameras = conn.execute("SELECT camera_id, total_entered, frames_processed, ROUND(elapsed_seconds,1) AS elapsed_seconds, stopped_reason, source_video, location FROM batch_runs WHERE date=? ORDER BY camera_id", (date,)).fetchall() total = conn.execute("SELECT SUM(total_entered) AS total, ROUND(SUM(elapsed_seconds)/60.0,1) AS minutes FROM batch_runs WHERE date=?", (date,)).fetchone() - self._send_json({"date": date, "total": dict(total), "cameras": [dict(r) for r in cameras]}) + res = {"date": date, "total": dict(total), "cameras": [dict(r) for r in cameras]} + res.update(_calc_cycle_info(date)) + self._send_json(res) return # /api/db/camera/ @@ -206,6 +270,128 @@ class DashboardHandler(SimpleHTTPRequestHandler): self._send_json({}) + def _handle_mortality(self, path: str) -> None: + """Serve mortality detection results. + + GET /api/mortality/latest - Most recent mortality_report.json across all dirs. + GET /api/mortality/history - List of all mortality reports found (newest first). + GET /api/mortality/date/ - Mortality breakdown for a specific date (YYYY-MM-DD). + GET /api/mortality/image/ - Serve an output_*.jpg annotated image by filename. + """ + if not _mortality_dirs: + self._send_json({"error": "No mortality directory configured. Start dashboard with --mortality-dir."}) + return + + def _enrich_report(data: dict) -> dict: + if "total_mortality_count" not in data and "results" in data: + data["total_mortality_count"] = sum(r.get("count", 0) for r in data["results"]) + return data + + def _find_report_paths() -> list[Path]: + found = [] + for mdir in _mortality_dirs: + p = Path(mdir) + if p.is_dir(): + found.extend(list(p.rglob("mortality_report.json"))) + return found + + # --- /api/mortality/history --- + if path == "/api/mortality/history": + results = [] + for report_path in _find_report_paths(): + try: + data = json.loads(report_path.read_text(encoding="utf-8")) + data["_dir"] = str(report_path.parent) + data["_report_mtime"] = report_path.stat().st_mtime + results.append(_enrich_report(data)) + except (json.JSONDecodeError, OSError): + pass + results.sort(key=lambda x: x.get("_report_mtime", 0), reverse=True) + self._send_json(results) + return + + # --- /api/mortality/latest --- + if path == "/api/mortality/latest": + latest = None + latest_mtime = 0.0 + for report_path in _find_report_paths(): + try: + mtime = report_path.stat().st_mtime + if mtime > latest_mtime: + latest_mtime = mtime + latest = json.loads(report_path.read_text(encoding="utf-8")) + latest["_dir"] = str(report_path.parent) + except (json.JSONDecodeError, OSError): + pass + if latest: + self._send_json(_enrich_report(latest)) + else: + self._send_json({"error": "No mortality report found."}) + return + + # --- /api/mortality/date/ --- + prefix_date = "/api/mortality/date/" + if path.startswith(prefix_date): + target_date = path[len(prefix_date):] + matched_reports = [] + total_day_carcasses = 0 + total_day_images = 0 + all_results = [] + + for report_path in _find_report_paths(): + try: + data = json.loads(report_path.read_text(encoding="utf-8")) + report_date = data.get("date") or time.strftime("%Y-%m-%d", time.localtime(report_path.stat().st_mtime)) + if report_date == target_date or report_path.parent.name == target_date: + enriched = _enrich_report(data) + total_day_carcasses += enriched.get("total_mortality_count", 0) + total_day_images += enriched.get("total_images", 0) + all_results.extend(enriched.get("results", [])) + matched_reports.append(enriched) + except (json.JSONDecodeError, OSError): + pass + + self._send_json({ + "date": target_date, + "total_mortality_count": total_day_carcasses, + "total_images": total_day_images, + "reports": matched_reports, + "results": all_results, + }) + return + + # --- /api/mortality/image/ --- + prefix_img = "/api/mortality/image/" + if path.startswith(prefix_img): + raw_name = path[len(prefix_img):] + filename = Path(raw_name).name # Prevent path traversal attacks + # Only allow serving output_*.jpg files for security + if not (filename.startswith("output_") and filename.lower().endswith((".jpg", ".jpeg", ".png"))): + self._send_error(403) + return + for mdir in _mortality_dirs: + p = Path(mdir) + if p.is_dir(): + for img_path in (p.rglob(filename) if filename else []): + if img_path.is_file(): + try: + data = img_path.read_bytes() + ct = mimetypes.guess_type(filename)[0] or "image/jpeg" + self.send_response(200) + self.send_header("Content-Type", ct) + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-cache") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(data) + return + except (FileNotFoundError, OSError): + pass + self._send_error(404) + return + + self._send_error(404) + def _serve_html(self): html_path = TEMPLATE_DIR / "index.html" html = html_path.read_text(encoding="utf-8") @@ -217,6 +403,7 @@ class DashboardHandler(SimpleHTTPRequestHandler): self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(data))) + self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() self.wfile.write(data) @@ -224,6 +411,7 @@ class DashboardHandler(SimpleHTTPRequestHandler): data = json.dumps(obj).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) @@ -235,7 +423,7 @@ class DashboardHandler(SimpleHTTPRequestHandler): def main(): - global _db_path + global _db_path, _mortality_dirs parser = argparse.ArgumentParser(description="Chicken Counter live dashboard") parser.add_argument("--port", type=int, default=DEFAULT_PORT) @@ -243,8 +431,22 @@ def main(): parser.add_argument("--poll-ms", type=int, default=1000) parser.add_argument("--date", default="") parser.add_argument("--db", default="db/chicken_counts.db") + parser.add_argument("--cycle-start-date", default="", help="Start date of cycle (Day 0) in YYYY-MM-DD format.") + parser.add_argument( + "--mortality-dir", + action="append", + dest="mortality_dirs", + default=[], + metavar="DIR", + help="Directory containing mortality_report.json and output images. Repeatable for multiple dirs.", + ) args = parser.parse_args() + _mortality_dirs = [Path(d).resolve() for d in args.mortality_dirs] + if args.cycle_start_date: + global _cycle_start_date + _cycle_start_date = args.cycle_start_date + DashboardHandler.shm_dir = args.shm_dir DashboardHandler.poll_ms = args.poll_ms DashboardHandler.run_date = args.date diff --git a/db/chicken_counts.db b/db/chicken_counts.db index db4a3e7..67e3007 100644 Binary files a/db/chicken_counts.db and b/db/chicken_counts.db differ diff --git a/db/chicken_counts.db-shm b/db/chicken_counts.db-shm deleted file mode 100644 index fe9ac28..0000000 Binary files a/db/chicken_counts.db-shm and /dev/null differ diff --git a/db/chicken_counts.db-wal b/db/chicken_counts.db-wal deleted file mode 100644 index e69de29..0000000 diff --git a/db/chicken_counts_NUC_Parallel_report_1.xlsx b/db/chicken_counts_NUC_Parallel_report_1.xlsx new file mode 100644 index 0000000..551a0fb Binary files /dev/null and b/db/chicken_counts_NUC_Parallel_report_1.xlsx differ diff --git a/db/chicken_counts_NUC_Parallel_report_10.xlsx b/db/chicken_counts_NUC_Parallel_report_10.xlsx new file mode 100644 index 0000000..01991df Binary files /dev/null and b/db/chicken_counts_NUC_Parallel_report_10.xlsx differ diff --git a/db/chicken_counts_NUC_Parallel_report_11_engine_NUC5070.xlsx b/db/chicken_counts_NUC_Parallel_report_11_engine_NUC5070.xlsx new file mode 100644 index 0000000..bf441e0 Binary files /dev/null and b/db/chicken_counts_NUC_Parallel_report_11_engine_NUC5070.xlsx differ diff --git a/db/chicken_counts_NUC_Parallel_report_2.xlsx b/db/chicken_counts_NUC_Parallel_report_2.xlsx new file mode 100644 index 0000000..86d80f5 Binary files /dev/null and b/db/chicken_counts_NUC_Parallel_report_2.xlsx differ diff --git a/db/chicken_counts_NUC_Parallel_report_2026-05-23_Special_engine_NUC5070.xlsx b/db/chicken_counts_NUC_Parallel_report_2026-05-23_Special_engine_NUC5070.xlsx new file mode 100644 index 0000000..8c8a0c4 Binary files /dev/null and b/db/chicken_counts_NUC_Parallel_report_2026-05-23_Special_engine_NUC5070.xlsx differ diff --git a/db/chicken_counts_NUC_Parallel_report_3.xlsx b/db/chicken_counts_NUC_Parallel_report_3.xlsx new file mode 100644 index 0000000..2147da3 Binary files /dev/null and b/db/chicken_counts_NUC_Parallel_report_3.xlsx differ diff --git a/db/chicken_counts_NUC_Parallel_report_4.xlsx b/db/chicken_counts_NUC_Parallel_report_4.xlsx new file mode 100644 index 0000000..f6e0004 Binary files /dev/null and b/db/chicken_counts_NUC_Parallel_report_4.xlsx differ diff --git a/db/chicken_counts_NUC_Parallel_report_5.xlsx b/db/chicken_counts_NUC_Parallel_report_5.xlsx new file mode 100644 index 0000000..666bcb4 Binary files /dev/null and b/db/chicken_counts_NUC_Parallel_report_5.xlsx differ diff --git a/db/chicken_counts_NUC_Parallel_report_6.xlsx b/db/chicken_counts_NUC_Parallel_report_6.xlsx new file mode 100644 index 0000000..0c619ab Binary files /dev/null and b/db/chicken_counts_NUC_Parallel_report_6.xlsx differ diff --git a/db/chicken_counts_NUC_Parallel_report_7.xlsx b/db/chicken_counts_NUC_Parallel_report_7.xlsx new file mode 100644 index 0000000..19b8289 Binary files /dev/null and b/db/chicken_counts_NUC_Parallel_report_7.xlsx differ diff --git a/db/chicken_counts_NUC_Parallel_report_8.xlsx b/db/chicken_counts_NUC_Parallel_report_8.xlsx new file mode 100644 index 0000000..f76ed6b Binary files /dev/null and b/db/chicken_counts_NUC_Parallel_report_8.xlsx differ diff --git a/db/chicken_counts_NUC_Parallel_report_9.xlsx b/db/chicken_counts_NUC_Parallel_report_9.xlsx new file mode 100644 index 0000000..c3895be Binary files /dev/null and b/db/chicken_counts_NUC_Parallel_report_9.xlsx differ diff --git a/export_excel_report.py b/export_excel_report.py new file mode 100644 index 0000000..3b79b73 --- /dev/null +++ b/export_excel_report.py @@ -0,0 +1,381 @@ +import sqlite3 +import pandas as pd +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter +from openpyxl.chart import BarChart, Reference + +def generate_excel_report(db_path, output_excel_path, target_date=None): + conn = sqlite3.connect(db_path) + if target_date: + df = pd.read_sql_query("SELECT * FROM batch_runs WHERE date = ? ORDER BY date ASC, camera_id ASC", conn, params=(target_date,)) + else: + df = pd.read_sql_query("SELECT * FROM batch_runs ORDER BY date ASC, camera_id ASC", conn) + conn.close() + + wb = openpyxl.Workbook() + # Remove default sheet + wb.remove(wb.active) + + # Styles + font_family = "Segoe UI" + header_fill = PatternFill(start_color="1F4E78", end_color="1F4E78", fill_type="solid") # Dark Navy + header_font = Font(name=font_family, size=11, bold=True, color="FFFFFF") + + accent_fill = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") # Light Soft Blue + total_fill = PatternFill(start_color="B4C6E7", end_color="B4C6E7", fill_type="solid") + total_font = Font(name=font_family, size=11, bold=True, color="000000") + + kpi_title_font = Font(name=font_family, size=9, bold=False, color="595959") + kpi_value_font = Font(name=font_family, size=18, bold=True, color="1F4E78") + + thin_border = Border( + left=Side(style='thin', color='D9D9D9'), + right=Side(style='thin', color='D9D9D9'), + top=Side(style='thin', color='D9D9D9'), + bottom=Side(style='thin', color='D9D9D9') + ) + + top_thick_bottom_double = Border( + top=Side(style='thin', color='000000'), + bottom=Side(style='double', color='000000') + ) + + # ------------------------------------------------------------- + # SHEET 1: Daily Summary + # ------------------------------------------------------------- + ws_summary = wb.create_sheet(title="Daily Summary") + ws_summary.views.sheetView[0].showGridLines = True + + # Title Block + ws_summary["A1"] = "Chicken Counter - Cycle 7 Summary Report" + ws_summary["A1"].font = Font(name=font_family, size=16, bold=True, color="1F4E78") + ws_summary["A2"] = f"Location: {df['location'].iloc[0]} | Date Range: {df['date'].min()} to {df['date'].max()}" + ws_summary["A2"].font = Font(name=font_family, size=10, italic=True, color="595959") + + # KPI Cards Block (Rows 4 to 6) + kpis = [ + ("TOTAL CHICKENS COUNTED", df['total_entered'].sum(), "#,##0"), + ("TOTAL BATCH RUNS", len(df), "#,##0"), + ("TOTAL FRAMES PROCESSED", df['frames_processed'].sum(), "#,##0"), + ("TOTAL ELAPSED (MINUTES)", round(df['elapsed_seconds'].sum() / 60, 1), "#,##0.0") + ] + + col_starts = [1, 3, 5, 7] + for (title, val, num_fmt), col_idx in zip(kpis, col_starts): + c1 = ws_summary.cell(row=4, column=col_idx, value=title) + c1.font = kpi_title_font + c1.fill = accent_fill + ws_summary.merge_cells(start_row=4, start_column=col_idx, end_row=4, end_column=col_idx+1) + + c2 = ws_summary.cell(row=5, column=col_idx, value=val) + c2.font = kpi_value_font + c2.number_format = num_fmt + c2.alignment = Alignment(horizontal='center', vertical='center') + ws_summary.merge_cells(start_row=5, start_column=col_idx, end_row=6, end_column=col_idx+1) + + # Pivot Data: Date vs Camera + pivot = df.pivot_table(index='date', columns='camera_id', values='total_entered', aggfunc='sum', fill_value=0) + cameras = sorted(list(pivot.columns)) + + start_row = 9 + ws_summary.cell(row=start_row, column=1, value="Date").font = header_font + ws_summary.cell(row=start_row, column=1).fill = header_fill + ws_summary.cell(row=start_row, column=1).alignment = Alignment(horizontal='center') + + for idx, cam in enumerate(cameras): + cell = ws_summary.cell(row=start_row, column=idx+2, value=cam) + cell.font = header_font + cell.fill = header_fill + cell.alignment = Alignment(horizontal='center') + + tot_header = ws_summary.cell(row=start_row, column=len(cameras)+2, value="Daily Total") + tot_header.font = header_font + tot_header.fill = header_fill + tot_header.alignment = Alignment(horizontal='center') + + current_r = start_row + 1 + for date_val, row_data in pivot.iterrows(): + ws_summary.cell(row=current_r, column=1, value=str(date_val)).font = Font(name=font_family, size=11) + ws_summary.cell(row=current_r, column=1).alignment = Alignment(horizontal='center') + ws_summary.cell(row=current_r, column=1).border = thin_border + + for idx, cam in enumerate(cameras): + c = ws_summary.cell(row=current_r, column=idx+2, value=int(row_data[cam])) + c.font = Font(name=font_family, size=11) + c.number_format = "#,##0" + c.alignment = Alignment(horizontal='right') + c.border = thin_border + + # Excel SUM Formula for Daily Total + start_col_let = get_column_letter(2) + end_col_let = get_column_letter(len(cameras) + 1) + tot_c = ws_summary.cell(row=current_r, column=len(cameras)+2, value=f"=SUM({start_col_let}{current_r}:{end_col_let}{current_r})") + tot_c.font = Font(name=font_family, size=11, bold=True) + tot_c.number_format = "#,##0" + tot_c.alignment = Alignment(horizontal='right') + tot_c.border = thin_border + + current_r += 1 + + # Total Row at Bottom + ws_summary.cell(row=current_r, column=1, value="Total").font = total_font + ws_summary.cell(row=current_r, column=1).fill = total_fill + ws_summary.cell(row=current_r, column=1).alignment = Alignment(horizontal='center') + ws_summary.cell(row=current_r, column=1).border = top_thick_bottom_double + + for idx, cam in enumerate(cameras): + col_let = get_column_letter(idx + 2) + c = ws_summary.cell(row=current_r, column=idx+2, value=f"=SUM({col_let}{start_row+1}:{col_let}{current_r-1})") + c.font = total_font + c.fill = total_fill + c.number_format = "#,##0" + c.alignment = Alignment(horizontal='right') + c.border = top_thick_bottom_double + + final_col_let = get_column_letter(len(cameras) + 2) + tot_final = ws_summary.cell(row=current_r, column=len(cameras)+2, value=f"=SUM({final_col_let}{start_row+1}:{final_col_let}{current_r-1})") + tot_final.font = total_font + tot_final.fill = total_fill + tot_final.number_format = "#,##0" + tot_final.alignment = Alignment(horizontal='right') + tot_final.border = top_thick_bottom_double + + # Add Chart to Summary Sheet + chart = BarChart() + chart.type = "col" + chart.style = 10 + chart.title = "Daily Chicken Counts by Camera" + chart.y_axis.title = "Chicken Count" + chart.x_axis.title = "Date" + chart.width = 16 + chart.height = 10 + + data_ref = Reference(ws_summary, min_col=2, min_row=start_row, max_col=len(cameras)+1, max_row=current_r-1) + cats_ref = Reference(ws_summary, min_col=1, min_row=start_row+1, max_row=current_r-1) + chart.add_data(data_ref, titles_from_data=True) + chart.set_categories(cats_ref) + + ws_summary.add_chart(chart, "I9") + + # ------------------------------------------------------------- + # SHEET 2: Camera Breakdown + # ------------------------------------------------------------- + ws_cam = wb.create_sheet(title="Camera Summary") + ws_cam.views.sheetView[0].showGridLines = True + + ws_cam["A1"] = "Camera Performance Summary" + ws_cam["A1"].font = Font(name=font_family, size=14, bold=True, color="1F4E78") + + cam_pivot = df.groupby('camera_id').agg( + total_entered=('total_entered', 'sum'), + avg_entered=('total_entered', 'mean'), + total_frames=('frames_processed', 'sum'), + total_elapsed_sec=('elapsed_seconds', 'sum'), + total_runs=('id', 'count') + ).reset_index() + + cam_headers = ["Camera ID", "Total Chicken Count", "Average Count / Run", "Total Frames", "Total Time (Minutes)", "Total Runs"] + for c_idx, h_text in enumerate(cam_headers, 1): + cell = ws_cam.cell(row=3, column=c_idx, value=h_text) + cell.font = header_font + cell.fill = header_fill + cell.alignment = Alignment(horizontal='center') + + for r_idx, row in cam_pivot.iterrows(): + row_num = 4 + r_idx + ws_cam.cell(row=row_num, column=1, value=row['camera_id']).alignment = Alignment(horizontal='center') + + ws_cam.cell(row=row_num, column=2, value=int(row['total_entered'])).number_format = "#,##0" + ws_cam.cell(row=row_num, column=3, value=round(row['avg_entered'], 1)).number_format = "#,##0.0" + ws_cam.cell(row=row_num, column=4, value=int(row['total_frames'])).number_format = "#,##0" + ws_cam.cell(row=row_num, column=5, value=round(row['total_elapsed_sec'] / 60, 2)).number_format = "#,##0.00" + ws_cam.cell(row=row_num, column=6, value=int(row['total_runs'])).number_format = "#,##0" + + for c_idx in range(1, 7): + ws_cam.cell(row=row_num, column=c_idx).font = Font(name=font_family, size=11) + ws_cam.cell(row=row_num, column=c_idx).border = thin_border + + # Total Row for Camera Summary + tot_row_cam = 4 + len(cam_pivot) + ws_cam.cell(row=tot_row_cam, column=1, value="Total").font = total_font + ws_cam.cell(row=tot_row_cam, column=1).fill = total_fill + ws_cam.cell(row=tot_row_cam, column=1).alignment = Alignment(horizontal='center') + ws_cam.cell(row=tot_row_cam, column=1).border = top_thick_bottom_double + + for c_idx in [2, 4, 6]: + col_let = get_column_letter(c_idx) + c = ws_cam.cell(row=tot_row_cam, column=c_idx, value=f"=SUM({col_let}4:{col_let}{tot_row_cam-1})") + c.font = total_font + c.fill = total_fill + c.number_format = "#,##0" + c.border = top_thick_bottom_double + + # Average for Avg column + c_avg = ws_cam.cell(row=tot_row_cam, column=3, value=f"=AVERAGE(C4:C{tot_row_cam-1})") + c_avg.font = total_font + c_avg.fill = total_fill + c_avg.number_format = "#,##0.0" + c_avg.border = top_thick_bottom_double + + # Sum for elapsed + c_time = ws_cam.cell(row=tot_row_cam, column=5, value=f"=SUM(E4:E{tot_row_cam-1})") + c_time.font = total_font + c_time.fill = total_fill + c_time.number_format = "#,##0.00" + c_time.border = top_thick_bottom_double + + # ------------------------------------------------------------- + # SHEET 3: Raw Batch Runs + # ------------------------------------------------------------- + ws_raw = wb.create_sheet(title="Raw Batch Runs") + ws_raw.views.sheetView[0].showGridLines = True + + raw_headers = ["ID", "Date", "Location", "Camera ID", "Total Entered", "Frames Processed", "Elapsed (s)", "Stopped Reason", "Source Video", "Generated At"] + for c_idx, h_text in enumerate(raw_headers, 1): + cell = ws_raw.cell(row=1, column=c_idx, value=h_text) + cell.font = header_font + cell.fill = header_fill + cell.alignment = Alignment(horizontal='center') + + for r_idx, row in df.iterrows(): + row_num = 2 + r_idx + ws_raw.cell(row=row_num, column=1, value=int(row['id'])).alignment = Alignment(horizontal='center') + ws_raw.cell(row=row_num, column=2, value=str(row['date'])).alignment = Alignment(horizontal='center') + ws_raw.cell(row=row_num, column=3, value=str(row['location'])).alignment = Alignment(horizontal='center') + ws_raw.cell(row=row_num, column=4, value=str(row['camera_id'])).alignment = Alignment(horizontal='center') + + ws_raw.cell(row=row_num, column=5, value=int(row['total_entered'])).number_format = "#,##0" + ws_raw.cell(row=row_num, column=6, value=int(row['frames_processed'])).number_format = "#,##0" + ws_raw.cell(row=row_num, column=7, value=float(row['elapsed_seconds'])).number_format = "#,##0.0" + ws_raw.cell(row=row_num, column=8, value=str(row['stopped_reason'])).alignment = Alignment(horizontal='center') + ws_raw.cell(row=row_num, column=9, value=str(row['source_video'])) + ws_raw.cell(row=row_num, column=10, value=str(row['generated_at'])) + + for c_idx in range(1, 11): + ws_raw.cell(row=row_num, column=c_idx).font = Font(name=font_family, size=10) + ws_raw.cell(row=row_num, column=c_idx).border = thin_border + + # ------------------------------------------------------------- + # SHEET 4: Configurations + # ------------------------------------------------------------- + ws_cfg = wb.create_sheet(title="Configurations") + ws_cfg.views.sheetView[0].showGridLines = True + + ws_cfg["A1"] = "Pipeline & Model Configurations" + ws_cfg["A1"].font = Font(name=font_family, size=14, bold=True, color="1F4E78") + + # Global Settings Table + ws_cfg["A3"] = "Global Pipeline & Detection Settings" + ws_cfg["A3"].font = Font(name=font_family, size=11, bold=True, color="1F4E78") + + global_configs = [ + ("Hardware / Target Platform", "ASUS NUC (AMD Ryzen 9 9955HX + NVIDIA GeForce RTX 5070 8GB)"), + ("Model Path (Engine)", "models/NUC5070_chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine"), + ("Model Architecture", "YOLOv8/v26n TensorRT FP16 compiled engine"), + ("Inference Image Size (imgsz)", "640 x 640"), + ("IoU Threshold", "0.55"), + ("Default Confidence Threshold (conf)", "0.40"), + ("Target Classes", "[0] (Ignored: [1, 2])"), + ("Default Min Box Area (px)", "3000"), + ("Tracker Architecture", "BoT-SORT (persist=True, track_buffer=90)"), + ("Gate Counting Mode", "two_line (lines_y: [420, 730], direction: bottom_to_up)"), + ("Motion Flow Analysis", "vertical (forward_sign: 1.0, ema_alpha: 0.2, min_features: 40)"), + ("Inference Stride", "2 frames (motion.stride_frames: 3)"), + ("Execution Mode", "parallel_processes (MAX_JOBS=4)") + ] + + ws_cfg.cell(row=4, column=1, value="Configuration Parameter").font = header_font + ws_cfg.cell(row=4, column=1).fill = header_fill + ws_cfg.cell(row=4, column=2, value="Setting / Value").font = header_font + ws_cfg.cell(row=4, column=2).fill = header_fill + + for idx, (param, val) in enumerate(global_configs, start=5): + c1 = ws_cfg.cell(row=idx, column=1, value=param) + c2 = ws_cfg.cell(row=idx, column=2, value=val) + c1.font = Font(name=font_family, size=10, bold=True) + c2.font = Font(name=font_family, size=10) + c1.border = thin_border + c2.border = thin_border + c1.fill = accent_fill + + # Stages Table + stage_start_row = 5 + len(global_configs) + 2 + ws_cfg.cell(row=stage_start_row-1, column=1, value="Cycle Stage Adaptation Rules").font = Font(name=font_family, size=11, bold=True, color="1F4E78") + + stage_configs = [ + ("early_cycle (Day 0 - 15)", "conf: 0.10, min_box_area_px: 100, min_overlap_ratio: 0.10 (Chicks adaptation)"), + ("mid_cycle (Day 16+)", "conf: 0.40, min_box_area_px: 3000, default filters (Grown chicken standard)") + ] + + ws_cfg.cell(row=stage_start_row, column=1, value="Cycle Stage").font = header_font + ws_cfg.cell(row=stage_start_row, column=1).fill = header_fill + ws_cfg.cell(row=stage_start_row, column=2, value="Applied Overrides").font = header_font + ws_cfg.cell(row=stage_start_row, column=2).fill = header_fill + + for idx, (stg, desc) in enumerate(stage_configs, start=stage_start_row+1): + c1 = ws_cfg.cell(row=idx, column=1, value=stg) + c2 = ws_cfg.cell(row=idx, column=2, value=desc) + c1.font = Font(name=font_family, size=10, bold=True) + c2.font = Font(name=font_family, size=10) + c1.border = thin_border + c2.border = thin_border + c1.fill = accent_fill + + # Per Camera Settings Table + cam_cfg_start = stage_start_row + len(stage_configs) + 3 + ws_cfg.cell(row=cam_cfg_start-1, column=1, value="Per-Camera Specific Configurations").font = Font(name=font_family, size=11, bold=True, color="1F4E78") + + cam_headers_cfg = ["Camera ID", "Count Anchor [X, Y]", "Confidence (conf)", "Min Box Area (px)", "Dedupe Settings", "ROI Polygon Points [X, Y]"] + for c_idx, h in enumerate(cam_headers_cfg, 1): + cell = ws_cfg.cell(row=cam_cfg_start, column=c_idx, value=h) + cell.font = header_font + cell.fill = header_fill + cell.alignment = Alignment(horizontal='center') + + cam_details = [ + ("CC1", "[780, 120]", "0.35", "2500", "Disabled", "[(250, 330), (1650, 330), (1650, 720), (250, 720)]"), + ("CC2", "[900, 120]", "0.50", "3000", "radius=32px, frames=24", "[(20, 380), (1880, 380), (1880, 720), (20, 720)]"), + ("CC3", "[900, 120]", "0.40", "3000", "radius=24px, frames=16", "[(20, 330), (1880, 330), (1880, 720), (20, 720)]"), + ("CC4", "[700, 120]", "0.40", "3000", "Disabled", "[(50, 330), (1450, 330), (1450, 720), (50, 720)]") + ] + + for r_offset, r_data in enumerate(cam_details, 1): + curr_row = cam_cfg_start + r_offset + for col_i, val in enumerate(r_data, 1): + cell = ws_cfg.cell(row=curr_row, column=col_i, value=val) + cell.font = Font(name=font_family, size=10) + cell.border = thin_border + if col_i in [1, 2, 3, 4]: + cell.alignment = Alignment(horizontal='center') + + # Auto-adjust column widths across all sheets + for ws in wb.worksheets: + for col in ws.columns: + max_len = 0 + col_letter = get_column_letter(col[0].column) + for cell in col: + # Avoid large width from title/merged cells + if cell.row < 3 and ws.title == "Daily Summary": + continue + val_str = str(cell.value or '') + if len(val_str) > max_len: + max_len = len(val_str) + ws.column_dimensions[col_letter].width = max(max_len + 4, 12) + + ws_summary.column_dimensions['A'].width = 16 + ws_summary.column_dimensions['B'].width = 14 + ws_summary.column_dimensions['C'].width = 14 + ws_summary.column_dimensions['D'].width = 14 + ws_summary.column_dimensions['E'].width = 14 + ws_summary.column_dimensions['F'].width = 16 + + wb.save(output_excel_path) + print(f"Excel report successfully generated: {output_excel_path}") + +if __name__ == "__main__": + from pathlib import Path + BASE_DIR = Path(__file__).resolve().parent + db_file = str(BASE_DIR / "db" / "chicken_counts.db") + out_file = str(BASE_DIR / "db" / "chicken_counts_report.xlsx") + generate_excel_report(db_file, out_file) diff --git a/models/NUC5070_chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine b/models/NUC5070_chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine new file mode 100644 index 0000000..a4fb380 Binary files /dev/null and b/models/NUC5070_chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine differ diff --git a/models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine b/models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine old mode 100755 new mode 100644 index b189515..c940d2d Binary files a/models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine and b/models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine differ diff --git a/models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.fp16.onnx b/models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.fp16.onnx new file mode 100644 index 0000000..bab766f Binary files /dev/null and b/models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.fp16.onnx differ diff --git a/models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.onnx b/models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.onnx old mode 100755 new mode 100644 index 55acf3a..692dc45 Binary files a/models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.onnx and b/models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.onnx differ diff --git a/models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.pt b/models/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.pt old mode 100755 new mode 100644 diff --git a/models/chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt b/models/chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt old mode 100755 new mode 100644 diff --git a/models/chicken-pose-v6m.pt b/models/chicken-pose-v6m.pt new file mode 100644 index 0000000..a37db87 Binary files /dev/null and b/models/chicken-pose-v6m.pt differ diff --git a/models/jetson_chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine b/models/jetson_chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine new file mode 100644 index 0000000..b189515 Binary files /dev/null and b/models/jetson_chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine differ diff --git a/models/jetson_chicken-detection-model-v26n-300e-best-2026-05-02-NEW.onnx b/models/jetson_chicken-detection-model-v26n-300e-best-2026-05-02-NEW.onnx new file mode 100644 index 0000000..5239f71 Binary files /dev/null and b/models/jetson_chicken-detection-model-v26n-300e-best-2026-05-02-NEW.onnx differ diff --git a/pyproject.toml b/pyproject.toml old mode 100755 new mode 100644 diff --git a/src/chicken_counter.egg-info/PKG-INFO b/src/chicken_counter.egg-info/PKG-INFO deleted file mode 100755 index ed46aea..0000000 --- a/src/chicken_counter.egg-info/PKG-INFO +++ /dev/null @@ -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. diff --git a/src/chicken_counter.egg-info/SOURCES.txt b/src/chicken_counter.egg-info/SOURCES.txt deleted file mode 100755 index d9983d5..0000000 --- a/src/chicken_counter.egg-info/SOURCES.txt +++ /dev/null @@ -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 \ No newline at end of file diff --git a/src/chicken_counter.egg-info/dependency_links.txt b/src/chicken_counter.egg-info/dependency_links.txt deleted file mode 100755 index 8b13789..0000000 --- a/src/chicken_counter.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/chicken_counter.egg-info/entry_points.txt b/src/chicken_counter.egg-info/entry_points.txt deleted file mode 100755 index 7d5ab66..0000000 --- a/src/chicken_counter.egg-info/entry_points.txt +++ /dev/null @@ -1,2 +0,0 @@ -[console_scripts] -chicken-counter = chicken_counter.cli:main diff --git a/src/chicken_counter.egg-info/requires.txt b/src/chicken_counter.egg-info/requires.txt deleted file mode 100755 index 9856261..0000000 --- a/src/chicken_counter.egg-info/requires.txt +++ /dev/null @@ -1,4 +0,0 @@ -numpy>=1.26 -opencv-python>=4.10 -PyYAML>=6.0.2 -ultralytics>=8.4.38 diff --git a/src/chicken_counter.egg-info/top_level.txt b/src/chicken_counter.egg-info/top_level.txt deleted file mode 100755 index b7d2b60..0000000 --- a/src/chicken_counter.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -chicken_counter diff --git a/src/chicken_counter/__init__.py b/src/chicken_counter/__init__.py old mode 100755 new mode 100644 diff --git a/src/chicken_counter/__pycache__/__init__.cpython-312.pyc b/src/chicken_counter/__pycache__/__init__.cpython-312.pyc deleted file mode 100755 index 8a08b18..0000000 Binary files a/src/chicken_counter/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/src/chicken_counter/__pycache__/__init__.cpython-313.pyc b/src/chicken_counter/__pycache__/__init__.cpython-313.pyc deleted file mode 100755 index c3e13d0..0000000 Binary files a/src/chicken_counter/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/src/chicken_counter/__pycache__/batch_discovery.cpython-312.pyc b/src/chicken_counter/__pycache__/batch_discovery.cpython-312.pyc deleted file mode 100755 index 0036e6d..0000000 Binary files a/src/chicken_counter/__pycache__/batch_discovery.cpython-312.pyc and /dev/null differ diff --git a/src/chicken_counter/__pycache__/batch_runner.cpython-312.pyc b/src/chicken_counter/__pycache__/batch_runner.cpython-312.pyc deleted file mode 100755 index 9eff444..0000000 Binary files a/src/chicken_counter/__pycache__/batch_runner.cpython-312.pyc and /dev/null differ diff --git a/src/chicken_counter/__pycache__/capture.cpython-312.pyc b/src/chicken_counter/__pycache__/capture.cpython-312.pyc deleted file mode 100755 index b0ecbd3..0000000 Binary files a/src/chicken_counter/__pycache__/capture.cpython-312.pyc and /dev/null differ diff --git a/src/chicken_counter/__pycache__/cli.cpython-312.pyc b/src/chicken_counter/__pycache__/cli.cpython-312.pyc deleted file mode 100755 index bfc0a11..0000000 Binary files a/src/chicken_counter/__pycache__/cli.cpython-312.pyc and /dev/null differ diff --git a/src/chicken_counter/__pycache__/compress.cpython-312.pyc b/src/chicken_counter/__pycache__/compress.cpython-312.pyc deleted file mode 100755 index d43f2ca..0000000 Binary files a/src/chicken_counter/__pycache__/compress.cpython-312.pyc and /dev/null differ diff --git a/src/chicken_counter/__pycache__/config.cpython-312.pyc b/src/chicken_counter/__pycache__/config.cpython-312.pyc deleted file mode 100755 index c370467..0000000 Binary files a/src/chicken_counter/__pycache__/config.cpython-312.pyc and /dev/null differ diff --git a/src/chicken_counter/__pycache__/config.cpython-313.pyc b/src/chicken_counter/__pycache__/config.cpython-313.pyc deleted file mode 100755 index 769c338..0000000 Binary files a/src/chicken_counter/__pycache__/config.cpython-313.pyc and /dev/null differ diff --git a/src/chicken_counter/__pycache__/counting.cpython-312.pyc b/src/chicken_counter/__pycache__/counting.cpython-312.pyc deleted file mode 100755 index bc745d6..0000000 Binary files a/src/chicken_counter/__pycache__/counting.cpython-312.pyc and /dev/null differ diff --git a/src/chicken_counter/__pycache__/motion.cpython-312.pyc b/src/chicken_counter/__pycache__/motion.cpython-312.pyc deleted file mode 100755 index 56c8cb0..0000000 Binary files a/src/chicken_counter/__pycache__/motion.cpython-312.pyc and /dev/null differ diff --git a/src/chicken_counter/__pycache__/overlay.cpython-312.pyc b/src/chicken_counter/__pycache__/overlay.cpython-312.pyc deleted file mode 100755 index 8e3c476..0000000 Binary files a/src/chicken_counter/__pycache__/overlay.cpython-312.pyc and /dev/null differ diff --git a/src/chicken_counter/__pycache__/pipeline.cpython-312.pyc b/src/chicken_counter/__pycache__/pipeline.cpython-312.pyc deleted file mode 100755 index 3907bba..0000000 Binary files a/src/chicken_counter/__pycache__/pipeline.cpython-312.pyc and /dev/null differ diff --git a/src/chicken_counter/__pycache__/report.cpython-312.pyc b/src/chicken_counter/__pycache__/report.cpython-312.pyc deleted file mode 100755 index 95969f2..0000000 Binary files a/src/chicken_counter/__pycache__/report.cpython-312.pyc and /dev/null differ diff --git a/src/chicken_counter/__pycache__/tracking.cpython-312.pyc b/src/chicken_counter/__pycache__/tracking.cpython-312.pyc deleted file mode 100755 index 744512f..0000000 Binary files a/src/chicken_counter/__pycache__/tracking.cpython-312.pyc and /dev/null differ diff --git a/src/chicken_counter/__pycache__/tracking.cpython-313.pyc b/src/chicken_counter/__pycache__/tracking.cpython-313.pyc deleted file mode 100755 index bd1485a..0000000 Binary files a/src/chicken_counter/__pycache__/tracking.cpython-313.pyc and /dev/null differ diff --git a/src/chicken_counter/__pycache__/types.cpython-312.pyc b/src/chicken_counter/__pycache__/types.cpython-312.pyc deleted file mode 100755 index c5e7e8e..0000000 Binary files a/src/chicken_counter/__pycache__/types.cpython-312.pyc and /dev/null differ diff --git a/src/chicken_counter/__pycache__/types.cpython-313.pyc b/src/chicken_counter/__pycache__/types.cpython-313.pyc deleted file mode 100755 index b96cb3c..0000000 Binary files a/src/chicken_counter/__pycache__/types.cpython-313.pyc and /dev/null differ diff --git a/src/chicken_counter/__pycache__/video_writer.cpython-312.pyc b/src/chicken_counter/__pycache__/video_writer.cpython-312.pyc deleted file mode 100755 index caa36d6..0000000 Binary files a/src/chicken_counter/__pycache__/video_writer.cpython-312.pyc and /dev/null differ diff --git a/src/chicken_counter/batch_discovery.py b/src/chicken_counter/batch_discovery.py old mode 100755 new mode 100644 diff --git a/src/chicken_counter/batch_runner.py b/src/chicken_counter/batch_runner.py old mode 100755 new mode 100644 index 9318c33..3a82be6 --- a/src/chicken_counter/batch_runner.py +++ b/src/chicken_counter/batch_runner.py @@ -1,17 +1,20 @@ -"""Run CC1–CC4 sequentially, then compress videos and write the JSON report.""" - -from __future__ import annotations - import shutil +import time +from concurrent.futures import ThreadPoolExecutor +import cv2 +import numpy as np from datetime import date as date_type from pathlib import Path -from chicken_counter.batch_discovery import discover_camera_videos +from chicken_counter.batch_discovery import CameraDiscoveryResult, discover_camera_videos from chicken_counter.compress import compress_video_to_target -from chicken_counter.config import BatchSettings, build_camera_config_from_batch -from chicken_counter.pipeline import run_pipeline +from chicken_counter.config import BatchSettings, CameraConfig, CameraPreset, build_camera_config_from_batch +from chicken_counter.engine_utils import ensure_compatible_model +from chicken_counter.overlay import draw_overlay +from chicken_counter.pipeline import PipelineArtifacts, _consume_result, _write_stream_frame, build_pipeline, run_pipeline from chicken_counter.report import build_batch_report, persist_batch_reports -from chicken_counter.types import CameraBatchResult +from chicken_counter.tracking import DetectionTracker +from chicken_counter.types import CameraBatchResult, FrameResult, PipelineResult, TrackObservation def _store_to_db(report_path: Path, location: str, db_path: str) -> None: @@ -26,7 +29,7 @@ def _store_to_db(report_path: Path, location: str, db_path: str) -> None: return Path(db_path).parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(db_path) + conn = sqlite3.connect(db_path, timeout=60.0) conn.execute("PRAGMA journal_mode=WAL") conn.execute("""CREATE TABLE IF NOT EXISTS batch_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -67,13 +70,524 @@ def _store_to_db(report_path: Path, location: str, db_path: str) -> None: print(f"[db] stored {date} ({location}) → {db_path}") -def run_daily_batch(settings: BatchSettings, date: str | None = None, *, verbose: bool = False, no_video: bool = False, show_progress: bool = False) -> Path: +def run_tensor_batched_daily_batch( + settings: BatchSettings, + date: str | None = None, + *, + verbose: bool = False, + no_video: bool = False, + show_progress: bool = False, + cycle_start_date: str | None = None, +) -> Path: + if cycle_start_date: + settings.batch.cycle_start_date = cycle_start_date + run_date = date or date_type.today().isoformat() day_dir = Path(settings.batch.root_dir) / run_date output_dir = day_dir / settings.batch.output_subdir output_dir.mkdir(parents=True, exist_ok=True) - print(f"[batch] starting daily run for {run_date}") + print(f"[batch-tensor] starting tensor-batched daily run for {run_date}") + print(f"[batch-tensor] input folder: {day_dir}") + print(f"[batch-tensor] output folder: {output_dir}") + + shm_dir = Path("/dev/shm") + for d in shm_dir.glob("chicken_counter_*"): + if d.is_dir(): + try: + shutil.rmtree(str(d)) + except Exception: + pass + + discovery = discover_camera_videos(day_dir, settings) + camera_order = sorted(settings.cameras.items(), key=lambda item: item[1].camera_num) + + active_cams: list[tuple[str, CameraConfig, PipelineArtifacts]] = [] + camera_results: list[CameraBatchResult] = [] + report_path = output_dir / f"counts_{run_date}.json" + + first_config = None + for camera_id, _preset in camera_order: + if camera_id in discovery.skipped: + skip_reason = discovery.skipped[camera_id] + print(f"[batch-tensor] skipping {camera_id}: {skip_reason}") + camera_results.append( + CameraBatchResult( + camera_id=camera_id, + skipped=True, + skip_reason=skip_reason, + ) + ) + continue + + source_path = discovery.found[camera_id] + vis_path = output_dir / f"{camera_id}_vis.mp4" if not no_video else None + checkpoint_dir = output_dir / "checkpoints" / camera_id + + camera_config = build_camera_config_from_batch( + settings, + camera_id, + source=source_path, + output_path=vis_path, + checkpoint_dir=checkpoint_dir, + date=run_date, + ) + camera_config.performance.verbose = verbose + if first_config is None: + first_config = camera_config + + if not first_config: + persist_batch_reports(run_date, camera_results, output_dir) + _store_to_db(report_path, settings.batch.location, settings.batch.db_path) + return report_path + + shared_tracker = DetectionTracker(first_config) + + for camera_id, _preset in camera_order: + if camera_id in discovery.skipped: + continue + source_path = discovery.found[camera_id] + vis_path = output_dir / f"{camera_id}_vis.mp4" if not no_video else None + checkpoint_dir = output_dir / "checkpoints" / camera_id + + camera_config = build_camera_config_from_batch( + settings, + camera_id, + source=source_path, + output_path=vis_path, + checkpoint_dir=checkpoint_dir, + date=run_date, + ) + camera_config.performance.verbose = verbose + artifacts = build_pipeline(camera_config, tracker=shared_tracker, run_date=run_date) + active_cams.append((camera_id, camera_config, artifacts)) + + inference_stride = max(1, first_config.performance.inference_stride) + frame_index = 0 + active_indices = list(range(len(active_cams))) + last_batch_tracks: dict[str, list[TrackObservation]] = {cam_id: [] for cam_id, _, _ in active_cams} + stopped_reasons: dict[str, str] = {cam_id: "eof" for cam_id, _, _ in active_cams} + frame_counts: dict[str, int] = {cam_id: 0 for cam_id, _, _ in active_cams} + + print(f"[batch-tensor] running synchronized batch tracking across {len(active_cams)} active cameras...") + + while active_indices: + frame_index += 1 + current_frames = [] + current_crops = [] + current_active = [] + + for idx in list(active_indices): + cam_id, config, artifacts = active_cams[idx] + ok, frame = artifacts.capture.read() + if not ok: + stopped_reasons[cam_id] = "eof" + active_indices.remove(idx) + continue + frame_counts[cam_id] += 1 + current_frames.append(frame) + current_crops.append(artifacts.detection_zone_rect) + current_active.append(idx) + + if not current_active: + break + + if frame_index % inference_stride == 0 or any(not last_batch_tracks[active_cams[i][0]] for i in current_active): + batch_tracks_list = shared_tracker.infer_batch(current_frames, crop_rects=current_crops) + for i, idx in enumerate(current_active): + cam_id = active_cams[idx][0] + last_batch_tracks[cam_id] = batch_tracks_list[i] + + for i, idx in enumerate(current_active): + cam_id, config, artifacts = active_cams[idx] + frame = current_frames[i] + tracks = last_batch_tracks[cam_id] + + motion_state = artifacts.motion_detector.update(frame, tracks, frame_counts[cam_id]) + count_events = artifacts.counting_zone.update( + tracks, frame_counts[cam_id], counting_paused=motion_state.backward_active + ) + + needs_overlay = config.display.show_window or artifacts.writer is not None or config.stream.enabled + annotated = draw_overlay( + frame, config, artifacts.counting_zone, tracks, motion_state, + frame_index=frame_counts[cam_id], buffer=artifacts.overlay_buffer + ) if needs_overlay else frame + + result = FrameResult( + frame_index=frame_counts[cam_id], + tracks=tracks, + inside_box_count=artifacts.counting_zone.inside_box_count, + total_entered_count=artifacts.counting_zone.total_entered_count, + motion_state=motion_state, + count_events=count_events, + ) + _consume_result(config, artifacts, annotated, result, None) + + if config.stream.enabled and frame_counts[cam_id] % max(1, config.stream.interval_frames) == 0: + _write_stream_frame(config.stream.shm_dir, config.camera_id, annotated, result, run_date=artifacts.run_date) + + if motion_state.backward_active: + stopped_reasons[cam_id] = "backward" + print(f"[batch-tensor] backward motion confirmed for {cam_id} at frame={frame_counts[cam_id]}") + active_indices.remove(idx) + + for cam_id, config, artifacts in active_cams: + elapsed_seconds = time.monotonic() - artifacts.run_start_time + artifacts.capture.release() + if artifacts.writer is not None: + artifacts.writer.release() + p_res = PipelineResult( + camera_id=cam_id, + total_entered_count=artifacts.counting_zone.total_entered_count, + frames_processed=frame_counts[cam_id], + stopped_reason=stopped_reasons[cam_id], + vis_video_path=config.display.output_path, + source_video=str(config.source), + elapsed_seconds=elapsed_seconds, + ) + camera_results.append(CameraBatchResult(camera_id=cam_id, pipeline=p_res)) + print( + f"[batch-tensor] finished {cam_id}: total_entered={p_res.total_entered_count} " + f"frames={p_res.frames_processed} reason={p_res.stopped_reason}" + ) + + persist_batch_reports(run_date, camera_results, output_dir) + _store_to_db(report_path, settings.batch.location, settings.batch.db_path) + + if not no_video: + print("[batch-tensor] all cameras complete; starting compression") + for item in camera_results: + if item.skipped or item.pipeline is None: + continue + vis_path = item.pipeline.vis_video_path + if not vis_path: + continue + compressed_path = output_dir / f"{item.camera_id}_compressed.mp4" + size_mb = compress_video_to_target( + vis_path, + compressed_path, + max_mb=settings.batch.compress_max_mb, + ) + item.compressed_video_path = str(compressed_path) + item.compressed_size_mb = size_mb + + if settings.batch.delete_intermediate: + Path(vis_path).unlink(missing_ok=True) + + persist_batch_reports(run_date, camera_results, output_dir) + + report = build_batch_report(run_date, camera_results, output_dir=output_dir) + print( + f"[batch-tensor] complete for {run_date}: total_entered_sum={report.total_entered_sum} " + f"report={report_path}" + ) + _store_to_db(report_path, settings.batch.location, settings.batch.db_path) + return report_path + + +def run_hybrid_daily_batch( + settings: BatchSettings, + date: str | None = None, + *, + verbose: bool = False, + no_video: bool = False, + show_progress: bool = False, + cycle_start_date: str | None = None, +) -> Path: + if cycle_start_date: + settings.batch.cycle_start_date = cycle_start_date + + run_date = date or date_type.today().isoformat() + day_dir = Path(settings.batch.root_dir) / run_date + output_dir = day_dir / settings.batch.output_subdir + output_dir.mkdir(parents=True, exist_ok=True) + + print(f"[batch-hybrid] starting hybrid (threaded CPU + batched GPU) daily run for {run_date}") + print(f"[batch-hybrid] input folder: {day_dir}") + print(f"[batch-hybrid] output folder: {output_dir}") + + shm_dir = Path("/dev/shm") + for d in shm_dir.glob("chicken_counter_*"): + if d.is_dir(): + try: + shutil.rmtree(str(d)) + except Exception: + pass + + discovery = discover_camera_videos(day_dir, settings) + camera_order = sorted(settings.cameras.items(), key=lambda item: item[1].camera_num) + + active_cams: list[tuple[str, CameraConfig, PipelineArtifacts]] = [] + camera_results: list[CameraBatchResult] = [] + report_path = output_dir / f"counts_{run_date}.json" + + first_config = None + for camera_id, _preset in camera_order: + if camera_id in discovery.skipped: + skip_reason = discovery.skipped[camera_id] + print(f"[batch-hybrid] skipping {camera_id}: {skip_reason}") + camera_results.append( + CameraBatchResult( + camera_id=camera_id, + skipped=True, + skip_reason=skip_reason, + ) + ) + continue + + source_path = discovery.found[camera_id] + vis_path = output_dir / f"{camera_id}_vis.mp4" if not no_video else None + checkpoint_dir = output_dir / "checkpoints" / camera_id + + camera_config = build_camera_config_from_batch( + settings, + camera_id, + source=source_path, + output_path=vis_path, + checkpoint_dir=checkpoint_dir, + date=run_date, + ) + camera_config.performance.verbose = verbose + if first_config is None: + first_config = camera_config + + if not first_config: + persist_batch_reports(run_date, camera_results, output_dir) + _store_to_db(report_path, settings.batch.location, settings.batch.db_path) + return report_path + + shared_tracker = DetectionTracker(first_config) + + for camera_id, _preset in camera_order: + if camera_id in discovery.skipped: + continue + source_path = discovery.found[camera_id] + vis_path = output_dir / f"{camera_id}_vis.mp4" if not no_video else None + checkpoint_dir = output_dir / "checkpoints" / camera_id + + camera_config = build_camera_config_from_batch( + settings, + camera_id, + source=source_path, + output_path=vis_path, + checkpoint_dir=checkpoint_dir, + date=run_date, + ) + camera_config.performance.verbose = verbose + artifacts = build_pipeline(camera_config, tracker=shared_tracker, run_date=run_date) + active_cams.append((camera_id, camera_config, artifacts)) + + inference_stride = max(1, first_config.performance.inference_stride) + frame_index = 0 + active_indices = list(range(len(active_cams))) + last_batch_tracks: dict[str, list[TrackObservation]] = {cam_id: [] for cam_id, _, _ in active_cams} + stopped_reasons: dict[str, str] = {cam_id: "eof" for cam_id, _, _ in active_cams} + frame_counts: dict[str, int] = {cam_id: 0 for cam_id, _, _ in active_cams} + elapsed_times: dict[str, float] = {} + + print(f"[batch-hybrid] running hybrid pipeline across {len(active_cams)} active cameras...") + + def read_camera_frame(idx: int): + cam_id, config, artifacts = active_cams[idx] + ok, frame = artifacts.capture.read() + if not ok: + return idx, False, None, None + return idx, True, frame, artifacts.detection_zone_rect + + def process_camera_post(idx: int, frame, tracks): + cam_id, config, artifacts = active_cams[idx] + count = frame_counts[cam_id] + motion_state = artifacts.motion_detector.update(frame, tracks, count) + count_events = artifacts.counting_zone.update( + tracks, count, counting_paused=motion_state.backward_active + ) + needs_overlay = config.display.show_window or artifacts.writer is not None or config.stream.enabled + annotated = draw_overlay( + frame, config, artifacts.counting_zone, tracks, motion_state, + frame_index=count, buffer=artifacts.overlay_buffer + ) if needs_overlay else frame + + result = FrameResult( + frame_index=count, + tracks=tracks, + inside_box_count=artifacts.counting_zone.inside_box_count, + total_entered_count=artifacts.counting_zone.total_entered_count, + motion_state=motion_state, + count_events=count_events, + ) + _consume_result(config, artifacts, annotated, result, None) + + if config.stream.enabled and count % max(1, config.stream.interval_frames) == 0: + _write_stream_frame(config.stream.shm_dir, config.camera_id, annotated, result, run_date=artifacts.run_date) + + return idx, motion_state + + max_threads = max(1, len(active_cams)) + with ThreadPoolExecutor(max_workers=max_threads) as pool: + while active_indices: + frame_index += 1 + current_active = list(active_indices) + + # 1. Parallel frame capture across active cameras (threaded CPU) + read_futures = [pool.submit(read_camera_frame, idx) for idx in current_active] + + captured = [] + for fut in read_futures: + idx, ok, frame, crop_rect = fut.result() + cam_id, _, artifacts = active_cams[idx] + if not ok: + stopped_reasons[cam_id] = "eof" + if cam_id not in elapsed_times: + elapsed_times[cam_id] = time.monotonic() - artifacts.run_start_time + if idx in active_indices: + active_indices.remove(idx) + else: + frame_counts[cam_id] += 1 + captured.append((idx, frame, crop_rect)) + + if not captured: + break + + current_active_now = [item[0] for item in captured] + current_frames = [item[1] for item in captured] + current_crops = [item[2] for item in captured] + + # 2. Batched GPU inference (Synchronous on main thread) + if frame_index % inference_stride == 0 or any(not last_batch_tracks[active_cams[i][0]] for i in current_active_now): + batch_tracks_list = shared_tracker.infer_batch(current_frames, crop_rects=current_crops) + for i, idx in enumerate(current_active_now): + cam_id = active_cams[idx][0] + last_batch_tracks[cam_id] = batch_tracks_list[i] + + # 3. Parallel Post-processing across active cameras (threaded CPU) + post_futures = [ + pool.submit( + process_camera_post, + idx, + current_frames[i], + last_batch_tracks[active_cams[idx][0]], + ) + for i, idx in enumerate(current_active_now) + ] + + for fut in post_futures: + idx, motion_state = fut.result() + if motion_state.backward_active: + cam_id, _, artifacts = active_cams[idx] + stopped_reasons[cam_id] = "backward" + if cam_id not in elapsed_times: + elapsed_times[cam_id] = time.monotonic() - artifacts.run_start_time + print(f"[batch-hybrid] backward motion confirmed for {cam_id} at frame={frame_counts[cam_id]}") + if idx in active_indices: + active_indices.remove(idx) + + for cam_id, config, artifacts in active_cams: + elapsed_seconds = elapsed_times.get(cam_id, time.monotonic() - artifacts.run_start_time) + artifacts.capture.release() + if artifacts.writer is not None: + artifacts.writer.release() + p_res = PipelineResult( + camera_id=cam_id, + total_entered_count=artifacts.counting_zone.total_entered_count, + frames_processed=frame_counts[cam_id], + stopped_reason=stopped_reasons[cam_id], + vis_video_path=config.display.output_path, + source_video=str(config.source), + elapsed_seconds=elapsed_seconds, + ) + camera_results.append(CameraBatchResult(camera_id=cam_id, pipeline=p_res)) + print( + f"[batch-hybrid] finished {cam_id}: total_entered={p_res.total_entered_count} " + f"frames={p_res.frames_processed} reason={p_res.stopped_reason}" + ) + + persist_batch_reports(run_date, camera_results, output_dir) + _store_to_db(report_path, settings.batch.location, settings.batch.db_path) + + if not no_video: + print("[batch-hybrid] all cameras complete; starting compression") + for item in camera_results: + if item.skipped or item.pipeline is None: + continue + vis_path = item.pipeline.vis_video_path + if not vis_path: + continue + compressed_path = output_dir / f"{item.camera_id}_compressed.mp4" + size_mb = compress_video_to_target( + vis_path, + compressed_path, + max_mb=settings.batch.compress_max_mb, + ) + item.compressed_video_path = str(compressed_path) + item.compressed_size_mb = size_mb + + if settings.batch.delete_intermediate: + Path(vis_path).unlink(missing_ok=True) + + persist_batch_reports(run_date, camera_results, output_dir) + + report = build_batch_report(run_date, camera_results, output_dir=output_dir) + print( + f"[batch-hybrid] complete for {run_date}: total_entered_sum={report.total_entered_sum} " + f"report={report_path}" + ) + _store_to_db(report_path, settings.batch.location, settings.batch.db_path) + return report_path + + +def run_daily_batch( + settings: BatchSettings, + date: str | None = None, + *, + camera_id: str | None = None, + verbose: bool = False, + no_video: bool = False, + show_progress: bool = False, + cycle_start_date: str | None = None, +) -> Path: + if cycle_start_date: + settings.batch.cycle_start_date = cycle_start_date + + # Pre-validate and ensure compatible model in batch settings + default_model = settings.defaults.get("detection", {}).get("model_path") + if default_model: + device = settings.defaults.get("detection", {}).get("device", "0") + imgsz = settings.defaults.get("detection", {}).get("imgsz", 640) + validated_model = ensure_compatible_model( + default_model, + device=device, + imgsz=imgsz, + config_file_path=getattr(settings, "config_path", None), + ) + settings.defaults["detection"]["model_path"] = validated_model + + execution_mode = settings.batch.execution_mode + if execution_mode == "hybrid" and not camera_id: + return run_hybrid_daily_batch( + settings, + date=date, + verbose=verbose, + no_video=no_video, + show_progress=show_progress, + cycle_start_date=cycle_start_date, + ) + if execution_mode == "tensor_batching" and not camera_id: + return run_tensor_batched_daily_batch( + settings, + date=date, + verbose=verbose, + no_video=no_video, + show_progress=show_progress, + cycle_start_date=cycle_start_date, + ) + + run_date = date or date_type.today().isoformat() + day_dir = Path(settings.batch.root_dir) / run_date + output_dir = day_dir / settings.batch.output_subdir + output_dir.mkdir(parents=True, exist_ok=True) + + print(f"[batch] starting daily run for {run_date} (mode: {execution_mode})" + (f" (camera: {camera_id})" if camera_id else "")) print(f"[batch] input folder: {day_dir}") print(f"[batch] output folder: {output_dir}") @@ -81,13 +595,17 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None, *, verbose shm_dir = Path("/dev/shm") for d in shm_dir.glob("chicken_counter_*"): if d.is_dir(): - shutil.rmtree(str(d)) - print(f"[batch] cleaned {d}") + 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" @@ -118,6 +636,7 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None, *, verbose source=source_path, output_path=vis_path, checkpoint_dir=checkpoint_dir, + date=run_date, ) camera_config.performance.verbose = verbose pipeline_result = run_pipeline(camera_config, show_progress=show_progress, run_date=run_date) diff --git a/src/chicken_counter/capture.py b/src/chicken_counter/capture.py old mode 100755 new mode 100644 diff --git a/src/chicken_counter/cli.py b/src/chicken_counter/cli.py old mode 100755 new mode 100644 index 9ce3fac..f3149f8 --- a/src/chicken_counter/cli.py +++ b/src/chicken_counter/cli.py @@ -6,6 +6,7 @@ import argparse from chicken_counter.batch_runner import run_daily_batch from chicken_counter.config import load_batch_config, load_camera_config +from chicken_counter.mortality import DEFAULT_CONFIG_PATH, DEFAULT_MORTALITY_DIR, DEFAULT_MODEL_PATH, run_mortality_count from chicken_counter.pipeline import run_pipeline @@ -25,10 +26,33 @@ def build_parser() -> argparse.ArgumentParser: "--date", help="Processing date folder in YYYY-MM-DD format. Defaults to today.", ) + batch_parser.add_argument("--camera", "--camera-id", help="Filter to run only a specific camera ID (e.g. CC1).") batch_parser.add_argument("--verbose", action="store_true", help="Enable debug-level logging.") batch_parser.add_argument("--no-video", action="store_true", help="Skip video output and compression for speed.") + batch_parser.add_argument("--output-subdir", help="Override output subdirectory name.") + batch_parser.add_argument("--cycle-start-date", help="Override start date of cycle (Day 0) in YYYY-MM-DD format.") + batch_parser.add_argument( + "--mode", + "--execution-mode", + dest="mode", + choices=["parallel_processes", "tensor_batching", "hybrid"], + help="Batch execution mode: 'parallel_processes' (default), 'tensor_batching', or 'hybrid'.", + ) batch_parser.add_argument("--progress-bar", action="store_true", help="Show a terminal progress bar.") + mortality_parser = subparsers.add_parser("mortality", help="Run whole-image mortality chicken detection on photo(s).") + mortality_parser.add_argument("--config", default=DEFAULT_CONFIG_PATH, help="Path to mortality YAML config file.") + mortality_parser.add_argument("--input", "-i", default=None, help="Path to image file or directory containing images.") + mortality_parser.add_argument("--output-dir", "-o", default=None, help="Directory to save annotated images and report.") + mortality_parser.add_argument("--model-path", "-m", default=None, help="Path to YOLO model (.onnx/.pt/.engine).") + mortality_parser.add_argument("--conf", type=float, default=None, help="Confidence threshold (overrides YAML config).") + mortality_parser.add_argument("--iou", type=float, default=None, help="NMS IoU threshold (overrides YAML config).") + mortality_parser.add_argument("--min-area", type=int, default=None, help="Minimum box area in pixels (overrides YAML config).") + mortality_parser.add_argument("--dedupe-radius", type=float, default=None, help="Deduplication radius in pixels (overrides YAML config).") + mortality_parser.add_argument("--date", default=None, help="Date for the mortality run in YYYY-MM-DD format.") + mortality_parser.add_argument("--two-pass", action="store_true", default=None, help="Enable 2x Detect & Refine crop pipeline.") + mortality_parser.add_argument("--no-two-pass", action="store_false", dest="two_pass", help="Disable 2x Detect & Refine crop pipeline.") + parser.add_argument("--config", help=argparse.SUPPRESS) parser.add_argument("--camera-id", help=argparse.SUPPRESS) return parser @@ -38,9 +62,38 @@ def main() -> None: parser = build_parser() args = parser.parse_args() + if args.command == "mortality": + run_mortality_count( + input_path=args.input, + output_dir=args.output_dir, + model_path=args.model_path, + conf_threshold=args.conf, + iou_threshold=args.iou, + min_box_area_px=args.min_area, + dedupe_radius_px=args.dedupe_radius, + two_pass=args.two_pass, + config_path=args.config, + date=getattr(args, "date", None), + ) + return + if args.command == "batch": settings = load_batch_config(args.config) - run_daily_batch(settings, date=args.date, verbose=args.verbose, no_video=args.no_video, show_progress=args.progress_bar) + if getattr(args, "output_subdir", None): + settings.batch.output_subdir = args.output_subdir + if getattr(args, "cycle_start_date", None): + settings.batch.cycle_start_date = args.cycle_start_date + if getattr(args, "mode", None): + settings.batch.execution_mode = args.mode + run_daily_batch( + settings, + date=args.date, + camera_id=getattr(args, "camera", None), + verbose=args.verbose, + no_video=args.no_video, + show_progress=args.progress_bar, + cycle_start_date=getattr(args, "cycle_start_date", None), + ) return if args.command == "run": diff --git a/src/chicken_counter/compress.py b/src/chicken_counter/compress.py old mode 100755 new mode 100644 index 59df721..4879bc7 --- a/src/chicken_counter/compress.py +++ b/src/chicken_counter/compress.py @@ -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"], ] diff --git a/src/chicken_counter/config.py b/src/chicken_counter/config.py old mode 100755 new mode 100644 index 5440ea4..12e38d4 --- a/src/chicken_counter/config.py +++ b/src/chicken_counter/config.py @@ -13,6 +13,19 @@ import yaml Point = tuple[int, int] +PROJECT_ROOT = Path(__file__).resolve().parents[2] + + +def resolve_project_path(p: str | Path | None, base_dir: Path | None = None) -> str: + """Resolve relative path against PROJECT_ROOT (or base_dir) so configs work anywhere.""" + if p is None or p == "": + return "" + path_obj = Path(p) + if path_obj.is_absolute(): + return str(path_obj) + root = base_dir or PROJECT_ROOT + return str((root / path_obj).resolve()) + @dataclass class DetectionConfig: @@ -25,6 +38,9 @@ class DetectionConfig: device: str | int | None = None min_box_area_px: int = 0 validate_while_inside: bool = True + # Off by default; enable per-camera (e.g. CC2/CC3) for ID-flip double counts. + dedupe_radius_px: int = 0 + dedupe_frames: int = 12 @dataclass @@ -53,7 +69,7 @@ class DetectionZoneConfig: class TrackerConfig: tracker_config_path: str persist: bool = True - track_buffer: int = 75 + track_buffer: int = 90 @dataclass @@ -145,6 +161,7 @@ class OverlayConfig: show_track_ring: bool = False count_anchor: Point = (900, 120) inside_box_only: bool = True + validated_only: bool = True pending_blink: bool = True pending_colors: list[Color] = field( default_factory=lambda: [(255, 255, 0), (0, 255, 255)] @@ -202,6 +219,7 @@ class CameraConfig: feedback: FeedbackConfig detection_zone: DetectionZoneConfig = field(default_factory=DetectionZoneConfig) stream: StreamConfig = field(default_factory=StreamConfig) + config_path: str | None = None @dataclass @@ -214,6 +232,8 @@ class BatchConfig: checkpoint_every_n_frames: int = 3000 location: str = "" db_path: str = "" + cycle_start_date: str | None = None + execution_mode: str = "parallel_processes" @dataclass @@ -224,6 +244,8 @@ class CameraPreset: count_anchor: Point | None = None gate: GateConfig | None = None motion: MotionConfig | None = None + # Deep-merged over batch defaults (e.g. detection.dedupe_* for CC2/CC3). + overrides: dict[str, Any] = field(default_factory=dict) @dataclass @@ -231,6 +253,8 @@ class BatchSettings: batch: BatchConfig defaults: dict[str, Any] cameras: dict[str, CameraPreset] + stages: dict[str, Any] = field(default_factory=dict) + config_path: str | None = None def _load_data(path: Path) -> dict[str, Any]: @@ -252,7 +276,9 @@ def _build_overlay_config(overlay_raw: dict[str, Any]) -> OverlayConfig: overlay_kwargs["pending_colors"] = [ tuple(map(int, color)) for color in overlay_raw["pending_colors"] ] - return OverlayConfig(**overlay_kwargs) + valid_fields = OverlayConfig.__dataclass_fields__.keys() + filtered_kwargs = {k: v for k, v in overlay_kwargs.items() if k in valid_fields} + return OverlayConfig(**filtered_kwargs) def _build_roi_config(roi_raw: dict[str, Any]) -> RoiConfig: @@ -267,6 +293,13 @@ def _build_roi_config(roi_raw: dict[str, Any]) -> RoiConfig: def _build_camera_config(raw: dict[str, Any]) -> CameraConfig: + raw = copy.deepcopy(raw) + if "detection" in raw and "model_path" in raw["detection"]: + raw["detection"]["model_path"] = resolve_project_path(raw["detection"]["model_path"]) + if "tracker" in raw and "tracker_config_path" in raw["tracker"]: + raw["tracker"]["tracker_config_path"] = resolve_project_path(raw["tracker"]["tracker_config_path"]) + if "source" in raw: + raw["source"] = resolve_project_path(raw["source"]) return CameraConfig( camera_id=raw["camera_id"], source=raw["source"], @@ -281,6 +314,7 @@ def _build_camera_config(raw: dict[str, Any]) -> CameraConfig: feedback=FeedbackConfig(**raw.get("feedback", {})), detection_zone=DetectionZoneConfig(**raw.get("detection_zone", {})), stream=StreamConfig(**raw.get("stream", {})), + config_path=raw.get("config_path"), ) @@ -295,7 +329,7 @@ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any def load_camera_config(path: str | Path, camera_id: str | None = None) -> CameraConfig: - config_path = Path(path) + config_path = Path(path).resolve() raw = _load_data(config_path) if "batch" in raw: @@ -308,18 +342,26 @@ def load_camera_config(path: str | Path, camera_id: str | None = None) -> Camera raise ValueError("camera_id is required when config contains multiple cameras") raw = raw["cameras"][camera_id] + raw["config_path"] = str(config_path) return _build_camera_config(raw) def load_batch_config(path: str | Path) -> BatchSettings: - config_path = Path(path) + config_path = Path(path).resolve() raw = _load_data(config_path) if "batch" not in raw: raise ValueError("Batch config must contain a top-level 'batch' section") - batch = BatchConfig(**raw["batch"]) + raw_batch = copy.deepcopy(raw["batch"]) + if "root_dir" in raw_batch: + raw_batch["root_dir"] = resolve_project_path(raw_batch["root_dir"]) + if "db_path" in raw_batch: + raw_batch["db_path"] = resolve_project_path(raw_batch["db_path"]) + + batch = BatchConfig(**raw_batch) defaults = raw.get("defaults", {}) + stages = raw.get("stages", {}) cameras: dict[str, CameraPreset] = {} for camera_id, camera_raw in raw.get("cameras", {}).items(): @@ -333,6 +375,17 @@ def load_batch_config(path: str | Path) -> BatchSettings: gate = GateConfig(**camera_raw["gate"]) if "gate" in camera_raw else None motion = MotionConfig(**camera_raw["motion"]) if "motion" in camera_raw else None + # Preserve per-camera section overrides so they merge into defaults at build time. + reserved = {"camera_num", "roi", "count_anchor", "gate", "motion"} + overrides = { + key: value + for key, value in camera_raw.items() + if key not in reserved and isinstance(value, dict) + } + # count_anchor is also applied via preset; keep overlay-only dict overrides. + if "overlay" in camera_raw and isinstance(camera_raw["overlay"], dict): + overrides["overlay"] = camera_raw["overlay"] + cameras[camera_id] = CameraPreset( camera_id=camera_id, camera_num=int(camera_raw["camera_num"]), @@ -340,9 +393,16 @@ def load_batch_config(path: str | Path) -> BatchSettings: count_anchor=count_anchor, gate=gate, motion=motion, + overrides=overrides, ) - return BatchSettings(batch=batch, defaults=defaults, cameras=cameras) + return BatchSettings( + batch=batch, + defaults=defaults, + cameras=cameras, + stages=stages, + config_path=str(config_path), + ) def build_camera_config_from_batch( @@ -352,12 +412,45 @@ def build_camera_config_from_batch( source: str | Path, output_path: str | Path | None, checkpoint_dir: str | Path, + date: str | None = None, ) -> CameraConfig: if camera_id not in settings.cameras: raise KeyError(f"Unknown camera_id in batch config: {camera_id}") preset = settings.cameras[camera_id] - raw = _deep_merge(settings.defaults, {"camera_id": camera_id, "source": str(source)}) + raw = _deep_merge( + settings.defaults, + { + "camera_id": camera_id, + "source": str(source), + "config_path": getattr(settings, "config_path", None), + }, + ) + if preset.overrides: + raw = _deep_merge(raw, preset.overrides) + + # Resolve cycle stage if cycle_start_date and date are present + if settings.batch.cycle_start_date and date: + try: + from datetime import date as date_type + start_d = date_type.fromisoformat(str(settings.batch.cycle_start_date)) + run_d = date_type.fromisoformat(str(date)) + cycle_day = (run_d - start_d).days + if cycle_day >= 0 and settings.stages: + active_stage_name = None + stage_overrides = {} + for stage_name, stage_cfg in settings.stages.items(): + day_min = stage_cfg.get("day_min", 0) + day_max = stage_cfg.get("day_max", 999) + if day_min <= cycle_day <= day_max: + active_stage_name = stage_name + stage_overrides = {k: v for k, v in stage_cfg.items() if k not in ("day_min", "day_max")} + break + if stage_overrides: + print(f"[batch] Date {date} -> Cycle Day {cycle_day} (Stage: {active_stage_name})") + raw = _deep_merge(raw, stage_overrides) + except Exception: + pass raw.setdefault("roi", {}) raw["roi"]["points"] = [list(point) for point in preset.roi.points] diff --git a/src/chicken_counter/counting.py b/src/chicken_counter/counting.py old mode 100755 new mode 100644 index 6bfbe3e..fc8d688 --- a/src/chicken_counter/counting.py +++ b/src/chicken_counter/counting.py @@ -20,6 +20,8 @@ class CountingZone: track_buffer: int, min_box_area_px: int = 0, validate_while_inside: bool = True, + dedupe_radius_px: int = 0, + dedupe_frames: int = 12, *, verbose: bool = False, ) -> None: @@ -30,6 +32,8 @@ class CountingZone: self.min_box_area_px = min_box_area_px self.min_overlap_ratio = roi.min_overlap_ratio self.validate_while_inside = validate_while_inside + self.dedupe_radius_px = max(0, dedupe_radius_px) + self.dedupe_frames = max(0, dedupe_frames) self.verbose = verbose self.inside_box_count = 0 self.total_entered_count = 0 @@ -40,6 +44,8 @@ class CountingZone: self.current_inside_ids: set[int] = set() self.sequence_numbers_by_track_id: dict[int, int] = {} self.latest_validated_track_id: int | None = None + # Recent validated centroids used to suppress ID-switch double counts. + self._recent_counts: deque[tuple[int, tuple[int, int], int]] = deque() self._counting_polygon = np.array(roi.counting_polygon(), dtype=np.int32) self._counting_rect = roi.counting_rect() @@ -93,28 +99,44 @@ 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: - 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}" + 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 @@ -168,6 +190,31 @@ class CountingZone: def _meets_validation_thresholds(self, track: TrackObservation) -> bool: return self._meets_size_threshold(track) and self._meets_overlap_threshold(track) + def _remember_count(self, frame_index: int, centroid: tuple[int, int], sequence: int) -> None: + if self.dedupe_radius_px <= 0 or self.dedupe_frames <= 0: + return + self._recent_counts.append((frame_index, centroid, sequence)) + self._prune_recent_counts(frame_index) + + def _prune_recent_counts(self, frame_index: int) -> None: + while self._recent_counts and frame_index - self._recent_counts[0][0] > self.dedupe_frames: + self._recent_counts.popleft() + + def _find_recent_duplicate(self, centroid: tuple[int, int], frame_index: int) -> int | None: + """Return prior sequence number if centroid is near a recent count, else None.""" + if self.dedupe_radius_px <= 0 or self.dedupe_frames <= 0: + return None + + self._prune_recent_counts(frame_index) + radius_sq = self.dedupe_radius_px * self.dedupe_radius_px + cx, cy = centroid + for _, (px, py), sequence in reversed(self._recent_counts): + dx = cx - px + dy = cy - py + if dx * dx + dy * dy <= radius_sq: + return sequence + return None + def _purge_stale(self, frame_index: int, active_ids: set[int]) -> None: stale_ids = [ track_id @@ -179,3 +226,4 @@ class CountingZone: self.histories.pop(track_id, None) self.prev_inside_ids.discard(track_id) self.current_inside_ids.discard(track_id) + self._prune_recent_counts(frame_index) diff --git a/src/chicken_counter/engine_utils.py b/src/chicken_counter/engine_utils.py new file mode 100644 index 0000000..1200fb1 --- /dev/null +++ b/src/chicken_counter/engine_utils.py @@ -0,0 +1,229 @@ +"""TensorRT engine compatibility verification, auto-recompilation, and config updates.""" + +from __future__ import annotations + +import re +import shutil +from pathlib import Path +from typing import Any + +import numpy as np + +PROJECT_ROOT = Path(__file__).resolve().parents[2] + + +def verify_engine_compatibility( + model_path: str | Path, + *, + device: str | int | None = "0", + imgsz: int = 640, +) -> tuple[bool, str | None]: + """Verify if a TensorRT .engine file can be loaded and executed on the current system/GPU. + + Returns (True, None) if compatible, or (False, error_reason) if incompatible. + """ + model_path = Path(model_path) + if not model_path.exists(): + return False, f"Model file not found: {model_path}" + + if model_path.suffix.lower() != ".engine": + return True, None + + try: + from ultralytics import YOLO + + target_device = str(device) if device is not None else "0" + model = YOLO(str(model_path), task="detect") + dummy_frame = np.zeros((imgsz, imgsz, 3), dtype=np.uint8) + model.predict(dummy_frame, device=target_device, verbose=False) + return True, None + except Exception as exc: + return False, str(exc) + + +def find_matching_pt_model( + engine_path: str | Path, + search_dirs: list[str | Path] | None = None, +) -> Path | None: + """Find the matching .pt weights file for a given .engine file.""" + engine_path = Path(engine_path) + + # 1. Look in the same directory and standard models/ directories + default_dirs = [ + engine_path.parent, + PROJECT_ROOT / "models", + PROJECT_ROOT, + ] + dirs_to_search = [Path(d).resolve() for d in (search_dirs or default_dirs) if Path(d).exists()] + + # 2. Check direct stem match (e.g. model.engine -> model.pt) + direct_pt = engine_path.with_suffix(".pt") + if direct_pt.exists(): + return direct_pt.resolve() + + for directory in dirs_to_search: + direct = directory / f"{engine_path.stem}.pt" + if direct.exists(): + return direct.resolve() + + # 3. Check stripped prefix match (e.g. NUC5070_model.engine or jetson_model.engine -> model.pt) + cleaned_stem = re.sub(r"^(NUC\w*|jetson\w*|orin\w*|xavier\w*|nano\w*|arm\w*|x86\w*|gpu\w*)_", "", engine_path.stem, flags=re.IGNORECASE) + for directory in dirs_to_search: + candidate = directory / f"{cleaned_stem}.pt" + if candidate.exists(): + return candidate.resolve() + + # 4. Search all .pt files in search dirs and find closest substring/stem match + all_pts: list[Path] = [] + for directory in dirs_to_search: + all_pts.extend(directory.glob("*.pt")) + + if not all_pts: + return None + + # Try matching chicken detection models specifically + for pt in all_pts: + if "seg" not in pt.stem.lower() and "pose" not in pt.stem.lower() and "chicken" in pt.stem.lower(): + if cleaned_stem.lower() in pt.stem.lower() or pt.stem.lower() in cleaned_stem.lower(): + return pt.resolve() + + # Fallback to any chicken detection .pt model + for pt in all_pts: + if "seg" not in pt.stem.lower() and "pose" not in pt.stem.lower() and "chicken" in pt.stem.lower(): + return pt.resolve() + + return all_pts[0].resolve() if all_pts else None + + +def recompile_engine_from_pt( + pt_path: str | Path, + *, + imgsz: int = 640, + device: str | int | None = "0", + half: bool = True, + workspace: int = 4, + verbose: bool = True, +) -> Path: + """Compile a new TensorRT .engine from a .pt file on the current machine.""" + pt_path = Path(pt_path).resolve() + if not pt_path.exists(): + raise FileNotFoundError(f"Source PyTorch model not found: {pt_path}") + + from ultralytics import YOLO + + target_device = str(device) if device is not None else "0" + print(f"[engine_utils] ⚙️ Compiling TensorRT .engine from: {pt_path.name} (device={target_device}, imgsz={imgsz}, half={half})...") + + model = YOLO(str(pt_path), task="detect") + exported_engine = model.export( + format="engine", + imgsz=imgsz, + half=half, + workspace=workspace, + device=target_device, + verbose=verbose, + ) + + exported_path = Path(exported_engine).resolve() + print(f"[engine_utils] ✅ Successfully compiled TensorRT engine: {exported_path}") + return exported_path + + +def update_config_yaml_model_path( + config_file_path: str | Path, + new_model_path: str | Path, +) -> bool: + """Update defaults.detection.model_path in a YAML configuration file while preserving formatting.""" + config_path = Path(config_file_path).resolve() + if not config_path.exists(): + return False + + # Format model path relative to project root if applicable + new_model_path = Path(new_model_path).resolve() + try: + rel_path = new_model_path.relative_to(PROJECT_ROOT) + formatted_path = str(rel_path) + except ValueError: + formatted_path = str(new_model_path) + + content = config_path.read_text(encoding="utf-8") + + # Match 'model_path: ' under detection section + pattern = r"^([ \t]*model_path:[ \t]*)(?:['\"]?)([^'\"\r\n#]+)(?:['\"]?)([ \t]*(?:#.*)?)$" + + def replacer(match: re.Match) -> str: + prefix = match.group(1) + comment = match.group(3) or "" + if comment and not comment.startswith(" "): + comment = f" {comment.lstrip()}" + if not comment.startswith(" "): + comment = f" {comment}" + return f"{prefix}{formatted_path}{comment}" + + new_content, count = re.subn(pattern, replacer, content, count=1, flags=re.MULTILINE) + if count > 0: + config_path.write_text(new_content, encoding="utf-8") + print(f"[engine_utils] 💾 Updated YAML config '{config_path.name}' -> model_path: {formatted_path}") + return True + + return False + + +def ensure_compatible_model( + model_path: str | Path, + *, + device: str | int | None = "0", + imgsz: int = 640, + config_file_path: str | Path | None = None, +) -> str: + """Ensure the model at model_path is compatible with the current hardware. + + If an incompatible .engine is detected: + 1. Automatically locates the matching .pt file. + 2. Recompiles a new .engine optimized for this system. + 3. Updates config_file_path (e.g. cycle7_batch_optimized.yaml) with the new engine path. + 4. Returns the path to the compatible model. + """ + model_path_obj = Path(model_path) + if model_path_obj.suffix.lower() != ".engine": + return str(model_path) + + is_compatible, error_msg = verify_engine_compatibility( + model_path_obj, + device=device, + imgsz=imgsz, + ) + if is_compatible: + return str(model_path_obj) + + print(f"\n[engine_utils] ⚠️ TensorRT engine '{model_path_obj.name}' is incompatible with this system/GPU.") + print(f"[engine_utils] Reason: {error_msg}") + print("[engine_utils] 🔄 Auto-recompilation triggered: Searching for matching .pt model...") + + pt_model = find_matching_pt_model(model_path_obj) + if pt_model is None: + raise RuntimeError( + f"TensorRT engine '{model_path}' is incompatible with this hardware, " + f"and no matching .pt source model was found in {PROJECT_ROOT / 'models'} to recompile from." + ) + + print(f"[engine_utils] 📦 Found source PyTorch model: {pt_model.name}") + try: + new_engine_path = recompile_engine_from_pt( + pt_model, + imgsz=imgsz, + device=device, + half=True, + workspace=4, + verbose=True, + ) + except Exception as export_err: + print(f"[engine_utils] ❌ Engine recompilation failed: {export_err}") + print(f"[engine_utils] ⚠️ Falling back to PyTorch .pt model: {pt_model}") + return str(pt_model) + + # If config file is specified, update it + if config_file_path: + update_config_yaml_model_path(config_file_path, new_engine_path) + + return str(new_engine_path) diff --git a/src/chicken_counter/mortality.py b/src/chicken_counter/mortality.py new file mode 100644 index 0000000..cade46a --- /dev/null +++ b/src/chicken_counter/mortality.py @@ -0,0 +1,456 @@ +"""Mortality counter: Whole-image chicken carcass detection and counting without ROI cropping.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + +import cv2 +import numpy as np +import yaml +from ultralytics import YOLO + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_MODEL_PATH = str(PROJECT_ROOT / "models" / "chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt") +DEFAULT_MORTALITY_DIR = str((PROJECT_ROOT.parent / "VIDEOS" / "cycle7" / "kandang-atas" / "mortality").resolve()) +DEFAULT_CONFIG_PATH = str(PROJECT_ROOT / "configs" / "mortality_config.yaml") + + +def resolve_path(p: str | Path | None, base_dir: Path | None = None) -> str | None: + """Resolve relative path against PROJECT_ROOT (or base_dir) so it works regardless of working directory.""" + if p is None: + return None + path_obj = Path(p) + if path_obj.is_absolute(): + return str(path_obj) + root = base_dir or PROJECT_ROOT + return str((root / path_obj).resolve()) + + +def load_mortality_config(config_path: str | Path) -> dict: + """Load mortality YAML config file if present.""" + p = Path(config_path) + if not p.is_file(): + return {} + with open(p, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + return data.get("mortality", data) + + +def deduplicate_boxes( + boxes_list: list[dict], + dedupe_radius_px: float = 30.0, + iou_thresh: float = 0.35, + ioa_thresh: float = 0.50, +) -> list[dict]: + """Sort detections by confidence descending and suppress duplicate, overlapping, or nested sub-boxes (box inside a box).""" + if not boxes_list: + return boxes_list + + sorted_boxes = sorted(boxes_list, key=lambda b: b["confidence"], reverse=True) + kept: list[dict] = [] + + for item in sorted_boxes: + x1, y1, x2, y2 = item["box"] + cx = (x1 + x2) / 2.0 + cy = (y1 + y2) / 2.0 + area_item = (x2 - x1) * (y2 - y1) + + is_duplicate = False + for k in kept: + kx1, ky1, kx2, ky2 = k["box"] + kcx = (kx1 + kx2) / 2.0 + kcy = (ky1 + ky2) / 2.0 + area_k = (kx2 - kx1) * (ky2 - ky1) + + # 1. Centroid distance check + dist = float(np.hypot(cx - kcx, cy - kcy)) + if dedupe_radius_px > 0 and dist < dedupe_radius_px: + is_duplicate = True + break + + # 2. IoU and Nested Containment (IoA) check to eliminate box inside a box + ix1, iy1 = max(x1, kx1), max(y1, ky1) + ix2, iy2 = min(x2, kx2), min(y2, ky2) + inter = max(0, ix2 - ix1) * max(0, iy2 - iy1) + if inter > 0: + union = area_item + area_k - inter + iou = inter / float(union) if union > 0 else 0 + ioa = inter / float(min(area_item, area_k)) if min(area_item, area_k) > 0 else 0 + + if iou > iou_thresh or ioa > ioa_thresh: + is_duplicate = True + break + + if not is_duplicate: + kept.append(item) + + for i, item in enumerate(kept, start=1): + item["id"] = i + + return kept + + +def run_similarity_two_pass_detection( + model: YOLO, + img: np.ndarray, + classes: list[int], + conf_threshold: float, + iou_threshold: float, + min_box_area_px: int, + device: str | int = "cpu", + imgsz: int = 640, + crop_padding_ratio: float = 0.50, +) -> list[dict]: + """2x Detection Strategy: + Pass 1: Detect primary anchor chickens from the model. + Similarity Search: Extract visual feature templates from Pass 1 detections to search for similar carcass patterns. + Pass 2 / Dynamic Accuracy: If candidates fall below Pass 1 confidence or initial pass yields 0, automatically adapt confidence threshold. + """ + img_h, img_w = img.shape[:2] + + # Pass 1: Primary detection pass + pass1_results = model.predict( + source=img, + classes=classes, + conf=conf_threshold, + iou=iou_threshold, + imgsz=imgsz, + device=device, + verbose=False, + ) + + pass1_boxes = pass1_results[0].boxes + raw_detections: list[dict] = [] + templates: list[np.ndarray] = [] + + # Adaptive Step 1: If 0 detections found in Pass 1, automatically adjust accuracy/confidence down + if pass1_boxes is None or len(pass1_boxes) == 0: + adaptive_conf = max(0.18, conf_threshold * 0.60) + print(f"[mortality] Initial scan found 0 detections. Auto-adjusting confidence down to {adaptive_conf:.2f}...") + pass1_results = model.predict( + source=img, + classes=classes, + conf=adaptive_conf, + iou=iou_threshold, + imgsz=imgsz, + device=device, + verbose=False, + ) + pass1_boxes = pass1_results[0].boxes + if pass1_boxes is None or len(pass1_boxes) == 0: + return [] + + # Collect Pass 1 detections & extract high-confidence chicken templates + for p1_box in pass1_boxes: + p1_xyxy = p1_box.xyxy[0].cpu().numpy().astype(int) + p1_conf = float(p1_box.conf[0].cpu().item()) + x1, y1, x2, y2 = p1_xyxy + area = (x2 - x1) * (y2 - y1) + if area >= min_box_area_px: + raw_detections.append({ + "id": 0, + "box": [int(x1), int(y1), int(x2), int(y2)], + "confidence": round(p1_conf, 4), + "area": int(area), + "pass": 1, + }) + if p1_conf >= 0.50 and (x2 - x1) >= 30 and (y2 - y1) >= 30: + crop_tmpl = img[y1:y2, x1:x2] + templates.append(crop_tmpl) + + # Pass 2: Feature Similarity & Targeted Local Refinement + pass2_conf = max(0.18, conf_threshold * 0.70) + + # Compute template similarity map if templates are available + if templates: + gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + sim_map = np.zeros((img_h, img_w), dtype=np.float32) + + for tmpl in templates[:4]: # Top templates + gray_tmpl = cv2.cvtColor(tmpl, cv2.COLOR_BGR2GRAY) + th, tw = gray_tmpl.shape + if th > 10 and tw > 10 and th <= img_h and tw <= img_w: + res_map = cv2.matchTemplate(gray_img, gray_tmpl, cv2.TM_CCOEFF_NORMED) + padded_map = np.pad(res_map, ((0, img_h - res_map.shape[0]), (0, img_w - res_map.shape[1])), mode='constant') + sim_map = np.maximum(sim_map, padded_map) + + # Re-examine candidate regions with Pass 2 refinement + for p1_box in pass1_boxes: + p1_xyxy = p1_box.xyxy[0].cpu().numpy().astype(int) + x1, y1, x2, y2 = p1_xyxy + + bw = x2 - x1 + bh = y2 - y1 + pad_x = int(bw * crop_padding_ratio) + 20 + pad_y = int(bh * crop_padding_ratio) + 20 + + cx1 = max(0, x1 - pad_x) + cy1 = max(0, y1 - pad_y) + cx2 = min(img_w, x2 + pad_x) + cy2 = min(img_h, y2 + pad_y) + + crop = img[cy1:cy2, cx1:cx2] + if crop.size == 0 or crop.shape[0] < 20 or crop.shape[1] < 20: + continue + + pass2_results = model.predict( + source=crop, + classes=classes, + conf=pass2_conf, + iou=iou_threshold, + imgsz=imgsz, + device=device, + verbose=False, + ) + + p2_boxes = pass2_results[0].boxes + if p2_boxes is not None and len(p2_boxes) > 0: + for p2_box in p2_boxes: + rx1, ry1, rx2, ry2 = p2_box.xyxy[0].cpu().numpy().astype(int) + rconf = float(p2_box.conf[0].cpu().item()) + + gx1 = cx1 + rx1 + gy1 = cy1 + ry1 + gx2 = cx1 + rx2 + gy2 = cy1 + ry2 + gw = gx2 - gx1 + gh = gy2 - gy1 + garea = gw * gh + + aspect_ratio = gw / float(gh) if gh > 0 else 0 + if 0.35 <= aspect_ratio <= 2.8 and garea >= min_box_area_px: + raw_detections.append({ + "id": 0, + "box": [int(gx1), int(gy1), int(gx2), int(gy2)], + "confidence": round(rconf, 4), + "area": int(garea), + "pass": 2, + }) + + return raw_detections + + +def run_mortality_count( + input_path: str | Path | None = None, + output_dir: str | Path | None = None, + model_path: str | Path | None = None, + conf_threshold: float | None = None, + iou_threshold: float | None = None, + min_box_area_px: int | None = None, + dedupe_radius_px: float | None = None, + two_pass: bool | None = None, + device: str | int | None = None, + classes: list[int] | None = None, + imgsz: int | None = None, + config_path: str | Path | None = DEFAULT_CONFIG_PATH, + date: str | None = None, +) -> dict: + config_path = resolve_path(config_path) if config_path else DEFAULT_CONFIG_PATH + cfg = load_mortality_config(config_path) if config_path else {} + + # 2. CLI / function arguments override YAML config defaults if specified + raw_input_path = input_path if input_path is not None else cfg.get("input_dir", DEFAULT_MORTALITY_DIR) + raw_output_dir = output_dir if output_dir is not None else cfg.get("output_dir", None) + raw_model_path = model_path if model_path is not None else cfg.get("model_path", DEFAULT_MODEL_PATH) + + input_path = resolve_path(raw_input_path) + output_dir = resolve_path(raw_output_dir) if raw_output_dir else None + model_path = resolve_path(raw_model_path) + conf_threshold = conf_threshold if conf_threshold is not None else float(cfg.get("conf", 0.35)) + iou_threshold = iou_threshold if iou_threshold is not None else float(cfg.get("iou", 0.45)) + min_box_area_px = min_box_area_px if min_box_area_px is not None else int(cfg.get("min_box_area_px", 2500)) + dedupe_radius_px = dedupe_radius_px if dedupe_radius_px is not None else float(cfg.get("dedupe_radius_px", 30.0)) + two_pass = two_pass if two_pass is not None else bool(cfg.get("two_pass", True)) + device = device if device is not None else cfg.get("device", "0") + classes = classes if classes is not None else cfg.get("classes", [0]) + imgsz = imgsz if imgsz is not None else int(cfg.get("imgsz", 640)) + + input_p = Path(input_path) + + # Check for date subfolder if date is provided and input_path is a directory + if date and input_p.is_dir() and (input_p / date).is_dir(): + input_p = input_p / date + + if input_p.is_file(): + image_files = [input_p] + target_out_dir = Path(output_dir) if output_dir else input_p.parent + elif input_p.is_dir(): + target_out_dir = Path(output_dir) if output_dir else (input_p / date if (date and not output_dir and not input_p.name == date) else input_p) + valid_exts = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tiff"} + # Filter out images already prefixed with output_ + image_files = sorted([ + f for f in input_p.iterdir() + if f.is_file() and f.suffix.lower() in valid_exts and not f.name.startswith("output_") + ]) + else: + raise FileNotFoundError(f"Input path not found: {input_path}") + + target_out_dir.mkdir(parents=True, exist_ok=True) + + if not image_files: + print(f"[mortality] No input images found at: {input_path}") + return {"total_images": 0, "results": []} + + mode_str = "2x Similarity & Adaptive Accuracy Refinement" if two_pass else "Single-Pass Direct" + print(f"[mortality] Mode: {mode_str}") + print(f"[mortality] Loading model from: {model_path} (device: {device})") + print(f"[mortality] Config: classes={classes}, conf={conf_threshold}, iou={iou_threshold}, min_area={min_box_area_px}px, dedupe_radius={dedupe_radius_px}px") + + model = YOLO(str(model_path), task="detect") + results_summary = [] + + for img_file in image_files: + print(f"[mortality] Processing: {img_file.name}...") + img = cv2.imread(str(img_file)) + if img is None: + print(f"[mortality] Failed to read image: {img_file}") + continue + + try: + if two_pass: + raw_detections = run_similarity_two_pass_detection( + model=model, + img=img, + classes=classes, + conf_threshold=conf_threshold, + iou_threshold=iou_threshold, + min_box_area_px=min_box_area_px, + device=device, + imgsz=imgsz, + ) + else: + results = model.predict( + source=img, + classes=classes, + conf=conf_threshold, + iou=iou_threshold, + imgsz=imgsz, + device=device, + verbose=False, + ) + res = results[0] + boxes = res.boxes + raw_detections = [] + if boxes is not None: + for box in boxes: + xyxy = box.xyxy[0].cpu().numpy().astype(int) + conf = float(box.conf[0].cpu().item()) + x1, y1, x2, y2 = xyxy + area = (x2 - x1) * (y2 - y1) + if area >= min_box_area_px: + raw_detections.append({ + "id": 0, + "box": [int(x1), int(y1), int(x2), int(y2)], + "confidence": round(conf, 4), + "area": int(area), + "pass": 1, + }) + except Exception as exc: + if "CUDA" in str(exc) or "out of memory" in str(exc): + print(f"[mortality] CUDA OOM encountered. Falling back to CPU for {img_file.name}...") + if two_pass: + raw_detections = run_similarity_two_pass_detection( + model=model, + img=img, + classes=classes, + conf_threshold=conf_threshold, + iou_threshold=iou_threshold, + min_box_area_px=min_box_area_px, + device="cpu", + imgsz=imgsz, + ) + else: + results = model.predict( + source=img, + classes=classes, + conf=conf_threshold, + iou=iou_threshold, + imgsz=imgsz, + device="cpu", + verbose=False, + ) + res = results[0] + boxes = res.boxes + raw_detections = [] + if boxes is not None: + for box in boxes: + xyxy = box.xyxy[0].cpu().numpy().astype(int) + conf = float(box.conf[0].cpu().item()) + x1, y1, x2, y2 = xyxy + area = (x2 - x1) * (y2 - y1) + if area >= min_box_area_px: + raw_detections.append({ + "id": 0, + "box": [int(x1), int(y1), int(x2), int(y2)], + "confidence": round(conf, 4), + "area": int(area), + "pass": 1, + }) + else: + raise exc + + # Apply spatial deduplication to suppress duplicate boxes on a single chicken + filtered_detections = deduplicate_boxes(raw_detections, dedupe_radius_px=dedupe_radius_px) + count = len(filtered_detections) + + # Draw detections + for det in filtered_detections: + idx = det["id"] + conf = det["confidence"] + x1, y1, x2, y2 = det["box"] + + # Draw bright green bounding box around chicken carcass + cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) + + # Draw label badge (#1, #2...) with confidence + label = f"#{idx} ({conf:.2f})" + (tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1) + cv2.rectangle(img, (x1, max(0, y1 - th - 6)), (x1 + tw + 4, y1), (0, 200, 0), -1) + cv2.putText(img, label, (x1 + 2, max(th, y1 - 4)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1) + + # Draw banner at top of image displaying total carcass count + banner_height = 60 + h, w, _ = img.shape + cv2.rectangle(img, (0, 0), (w, banner_height), (0, 0, 0), -1) + + banner_text = f"TOTAL CHICKEN CARCASSES: {count}" + cv2.putText(img, banner_text, (20, 42), cv2.FONT_HERSHEY_SIMPLEX, 1.1, (0, 255, 255), 3) + + out_image_path = target_out_dir / f"output_{img_file.name}" + cv2.imwrite(str(out_image_path), img) + + print(f"[mortality] -> {img_file.name}: Count = {count} (Saved: {out_image_path})") + + results_summary.append({ + "input_image": img_file.name, + "output_image": out_image_path.name, + "count": count, + "output_path": str(out_image_path), + "detections": filtered_detections, + }) + + total_mortality_count = sum(item["count"] for item in results_summary) + report_path = target_out_dir / "mortality_report.json" + report_data = { + "date": date or datetime.now().strftime("%Y-%m-%d"), + "generated_at": datetime.now(timezone.utc).isoformat(), + "mode": "similarity_two_pass" if two_pass else "single_pass", + "config_file": str(config_path) if config_path else None, + "model_path": str(model_path), + "device": str(device), + "classes": classes, + "conf_threshold": conf_threshold, + "iou_threshold": iou_threshold, + "min_box_area_px": min_box_area_px, + "dedupe_radius_px": dedupe_radius_px, + "total_images": len(image_files), + "total_mortality_count": total_mortality_count, + "results": results_summary, + } + with open(report_path, "w") as f: + json.dump(report_data, f, indent=2) + + print(f"[mortality] Summary report saved to: {report_path} (Total Carcasses: {total_mortality_count} across {len(image_files)} images)") + return report_data diff --git a/src/chicken_counter/motion.py b/src/chicken_counter/motion.py old mode 100755 new mode 100644 diff --git a/src/chicken_counter/overlay.py b/src/chicken_counter/overlay.py old mode 100755 new mode 100644 diff --git a/src/chicken_counter/pipeline.py b/src/chicken_counter/pipeline.py old mode 100755 new mode 100644 index 7757b0a..c217678 --- a/src/chicken_counter/pipeline.py +++ b/src/chicken_counter/pipeline.py @@ -110,6 +110,8 @@ def build_pipeline( track_buffer=config.tracker.track_buffer, min_box_area_px=config.detection.min_box_area_px, validate_while_inside=config.detection.validate_while_inside, + dedupe_radius_px=config.detection.dedupe_radius_px, + dedupe_frames=config.detection.dedupe_frames, verbose=config.performance.verbose, ) motion_detector = BackwardMotionDetector(config.motion, config.roi, verbose=config.performance.verbose) @@ -372,28 +374,31 @@ def _consume_result( def _write_stream_frame(shm_dir: str, camera_id: str, frame: np.ndarray, result: FrameResult, *, run_date: str = "") -> None: - cam_dir = Path(shm_dir) / f"chicken_counter_{camera_id}" - cam_dir.mkdir(parents=True, exist_ok=True) + 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) + 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) + 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: diff --git a/src/chicken_counter/report.py b/src/chicken_counter/report.py old mode 100755 new mode 100644 index eda39ca..d681c7e --- a/src/chicken_counter/report.py +++ b/src/chicken_counter/report.py @@ -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 diff --git a/src/chicken_counter/tracking.py b/src/chicken_counter/tracking.py old mode 100755 new mode 100644 index 99e2dfc..df6dddb --- a/src/chicken_counter/tracking.py +++ b/src/chicken_counter/tracking.py @@ -9,15 +9,26 @@ import numpy as np from ultralytics import YOLO from chicken_counter.config import CameraConfig +from chicken_counter.engine_utils import ensure_compatible_model from chicken_counter.types import TrackObservation class DetectionTracker: def __init__(self, config: CameraConfig) -> None: self.config = config - model_path = Path(config.detection.model_path) + + # Ensure model is compatible on this machine / GPU, recompiling .engine from .pt if needed + validated_model_path = ensure_compatible_model( + config.detection.model_path, + device=config.detection.device, + imgsz=config.detection.imgsz, + config_file_path=getattr(config, "config_path", None), + ) + config.detection.model_path = validated_model_path + + model_path = Path(validated_model_path) self.model_kind = model_path.suffix.lower().lstrip(".") or "unknown" - self.model = YOLO(config.detection.model_path, task="detect") + self.model = YOLO(validated_model_path, task="detect") self.tracker_config_path = str(Path(config.tracker.tracker_config_path)) self.verbose = config.performance.verbose self._infer_count = 0 @@ -140,6 +151,114 @@ class DetectionTracker: return tracks + def infer_batch( + self, + frames: list[np.ndarray], + *, + crop_rects: list[tuple[int, int, int, int] | None] | None = None, + ) -> list[list[TrackObservation]]: + if not frames: + return [] + + sources = [] + offsets = [] + for i, frame in enumerate(frames): + crop = crop_rects[i] if crop_rects and i < len(crop_rects) else None + if crop is not None: + x1, y1, x2, y2 = crop + sources.append(frame[y1:y2, x1:x2]) + offsets.append((x1, y1)) + else: + sources.append(frame) + offsets.append((0, 0)) + + track_kwargs: dict = { + "source": sources, + "persist": self.config.tracker.persist, + "tracker": self.tracker_config_path, + "conf": self.config.detection.conf, + "iou": self.config.detection.iou, + "classes": self.config.detection.classes, + "imgsz": self.config.detection.imgsz, + "verbose": False, + "device": self.config.detection.device, + } + if self.model_kind != "engine" and self.config.performance.half: + track_kwargs["half"] = True + + if self.verbose: + t_start = time.monotonic() + + try: + results = self.model.track(**track_kwargs) + except Exception: + results = [] + for src in sources: + kw = dict(track_kwargs) + kw["source"] = src + res = self.model.track(**kw) + if res: + results.append(res[0]) + + if self.verbose: + t_track = time.monotonic() + self._infer_count += 1 + + batch_tracks: list[list[TrackObservation]] = [] + for idx, result in enumerate(results): + offset_x, offset_y = offsets[idx] + crop_rect = crop_rects[idx] if crop_rects and idx < len(crop_rects) else None + boxes = result.boxes + if boxes is None or boxes.id is None: + batch_tracks.append([]) + continue + + ids = boxes.id.int().cpu().numpy() + classes = boxes.cls.int().cpu().numpy() + confidences = boxes.conf.cpu().numpy() + xyxy = boxes.xyxy.int().cpu().numpy() + + mask_polygons = None + if result.masks is not None and result.masks.xy is not None: + mask_polygons = result.masks.xy + + tracks: list[TrackObservation] = [] + for index in range(len(boxes)): + track_id = int(ids[index]) + class_id = int(classes[index]) + confidence = float(confidences[index]) + bbox = xyxy[index] + x1 = int(bbox[0]) + offset_x + y1 = int(bbox[1]) + offset_y + x2 = int(bbox[2]) + offset_x + y2 = int(bbox[3]) + offset_y + centroid = ((x1 + x2) // 2, (y1 + y2) // 2) + + if crop_rect is not None and not self._centroid_in_rect(centroid, crop_rect): + continue + + polygon = None + if mask_polygons is not None: + poly = np.asarray(mask_polygons[index], dtype=np.float64).copy() + if poly.ndim == 2 and poly.shape[0] >= 3: + poly[:, 0] += offset_x + poly[:, 1] += offset_y + polygon = poly + + tracks.append( + TrackObservation( + track_id=track_id, + class_id=class_id, + confidence=confidence, + bbox_xyxy=(x1, y1, x2, y2), + centroid=centroid, + mask_polygon_xy=polygon, + ) + ) + batch_tracks.append(tracks) + + return batch_tracks + @staticmethod def _centroid_in_rect( centroid: tuple[int, int], diff --git a/src/chicken_counter/types.py b/src/chicken_counter/types.py old mode 100755 new mode 100644 diff --git a/src/chicken_counter/video_writer.py b/src/chicken_counter/video_writer.py old mode 100755 new mode 100644 index 6b593b1..f03c9fb --- a/src/chicken_counter/video_writer.py +++ b/src/chicken_counter/video_writer.py @@ -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 diff --git a/start_dashboard.sh b/start_dashboard.sh new file mode 100755 index 0000000..902412a --- /dev/null +++ b/start_dashboard.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Portable dashboard launcher — resolves all paths relative to this script's directory. +# Works regardless of where the project folder is located or its name. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PYTHONPATH="$SCRIPT_DIR/src" + +# Use project-local venv if available, otherwise fall back to system python3 +if [ -f "$SCRIPT_DIR/venv/bin/python" ]; then + PYTHON="$SCRIPT_DIR/venv/bin/python" +else + PYTHON="python3" +fi + +DB_PATH="${DB_PATH:-$SCRIPT_DIR/db/chicken_counts.db}" +PORT="${PORT:-8080}" +DATE_ARG="${DATE_ARG:-}" + +CMD=("$PYTHON" "$SCRIPT_DIR/dashboard.py" --port "$PORT" --db "$DB_PATH") + +# Auto-discover mortality directories (any folder containing mortality_report.json) +# Override with MORTALITY_DIRS="dir1,dir2" env variable or let it auto-discover +if [ -n "${MORTALITY_DIRS:-}" ]; then + IFS=',' read -ra MDIRS <<< "$MORTALITY_DIRS" + for mdir in "${MDIRS[@]}"; do + CMD+=(--mortality-dir "$mdir") + done +else + # Default: check adjacent VIDEOS folder for any mortality subdirectory + DEFAULT_MORTALITY="$SCRIPT_DIR/../VIDEOS/cycle7/kandang-atas/mortality" + if [ -d "$DEFAULT_MORTALITY" ]; then + CMD+=(--mortality-dir "$(realpath "$DEFAULT_MORTALITY")") + fi +fi + +if [ -n "$DATE_ARG" ]; then + CMD+=(--date "$DATE_ARG") +fi + +echo "[dashboard] Starting at http://0.0.0.0:${PORT} db=${DB_PATH}" +exec "${CMD[@]}" diff --git a/templates/index.html b/templates/index.html index 3d121c6..f75b750 100644 --- a/templates/index.html +++ b/templates/index.html @@ -12,7 +12,7 @@ body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:h #top-bar .dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin:0 6px} #top-bar .dot.online{background:#80dc5a;box-shadow:0 0 4px #80dc5a} #top-bar .dot.offline{background:#555} -#main{flex:1;display:flex} +#main{flex:1;display:flex;overflow:hidden} #frame-area{flex:1;background:#000;display:flex;align-items:center;justify-content:center;position:relative} #frame-area img{max-width:100%;max-height:100%;object-fit:contain} #frame-area .overlay{position:absolute;top:12px;left:12px;pointer-events:none} @@ -20,7 +20,7 @@ body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:h #frame-area .overlay .tag.live{background:#1a3a2a;color:#80dc5a} #frame-area .overlay .tag.off{background:#333;color:#888} #frame-area .overlay .count{font-size:36px;font-weight:900;color:#fff;text-shadow:0 0 16px rgba(0,0,0,.8)} -#sidebar{width:280px;background:#12121a;padding:16px;overflow-y:auto;flex-shrink:0;display:flex;flex-direction:column;gap:12px} +#sidebar{width:290px;background:#12121a;padding:16px;overflow-y:auto;flex-shrink:0;display:flex;flex-direction:column;gap:12px} #sidebar .stat{padding:12px;background:#16161e;border-radius:8px} #sidebar .stat label{display:block;font-size:9px;color:#555;text-transform:uppercase;letter-spacing:1px;margin-bottom:2px} #sidebar .stat .val{font-size:22px;font-weight:700} @@ -32,10 +32,27 @@ body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:h #sidebar .cam-list .cam-row.act{background:#1a3a2a;color:#80dc5a;font-weight:700} #sidebar .cam-list .cam-row .cam-total{font-size:11px;color:#666} #sidebar .cam-list .cam-row.act .cam-total{color:#5a9a4a} -#db-panel{padding:12px;background:#16161e;border-radius:8px;font-size:11px;max-height:200px;overflow-y:auto} -#db-panel h3{font-size:10px;color:#555;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px} +#db-panel, #mort-panel{padding:12px;background:#16161e;border-radius:8px;font-size:11px} +#db-panel h3, #mort-panel h3{font-size:10px;color:#555;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;display:flex;justify-content:space-between;align-items:center} #db-panel .db-row{display:flex;justify-content:space-between;padding:2px 0;color:#888} #db-panel .db-row .db-total{color:#aaa;font-weight:600} + +/* Mortality UI */ +.mort-badge{background:#2a1518;color:#ff6b81;border:1px solid #ff4757;font-weight:700;padding:2px 6px;border-radius:4px;font-size:10px} +.mort-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:6px} +.mort-card{background:#0e0e14;border-radius:6px;overflow:hidden;cursor:pointer;border:1px solid #222;transition:all .15s;text-align:left} +.mort-card:hover{border-color:#ff4757;transform:translateY(-1px)} +.mort-card img{width:100%;height:65px;object-fit:cover;display:block;background:#000} +.mort-card .info{padding:4px 6px;display:flex;justify-content:space-between;align-items:center;font-size:10px;color:#888} +.mort-card .info .cnt{color:#ff9f43;font-weight:700} + +/* Fullscreen Image Modal */ +#mort-modal{display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,.88);z-index:999;align-items:center;justify-content:center;flex-direction:column;backdrop-filter:blur(4px)} +#mort-modal.active{display:flex} +#mort-modal img{max-width:90vw;max-height:85vh;border-radius:8px;border:1px solid #333;box-shadow:0 0 32px rgba(0,0,0,.9);object-fit:contain} +#mort-modal .close-btn{position:absolute;top:20px;right:28px;font-size:32px;color:#aaa;cursor:pointer;background:none;border:none;line-height:1;transition:color .15s} +#mort-modal .close-btn:hover{color:#fff} +#mort-modal .caption{margin-top:12px;font-size:13px;color:#eee;background:#16161e;padding:6px 14px;border-radius:6px;border:1px solid #333} @@ -60,11 +77,30 @@ body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:h
--
--
+ + + +

📊 History

+ + +
+ +Mortality detection preview +
+
+ diff --git a/test_run.ds b/test_run.ds old mode 100755 new mode 100644 index 5fe4786..794b9ed --- a/test_run.ds +++ b/test_run.ds @@ -1,6 +1,6 @@ #!/bin/bash -alias chicken-counter='PYTHONPATH=/media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/src /media/jetson/DATA/karung-sukawarna/venv/bin/python -m chicken_counter.cli' +alias chicken-counter='PYTHONPATH=/home/asus/.Codes/chicken-counting-sukawarna-det/src python3 -m chicken_counter.cli' # Declare the array my_array=("2026-06-10" "2026-06-11" "2026-06-12" "2026-06-13" "2026-06-14" "2026-06-15" "2026-06-16" "2026-06-17" "2026-06-18" "2026-06-19") diff --git a/test_run.sh b/test_run.sh index 40f953d..9d5188f 100755 --- a/test_run.sh +++ b/test_run.sh @@ -1,15 +1,33 @@ #!/bin/bash -set -euo pipefail +set -u -export PYTHONPATH=/media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/src -PYTHON=/media/jetson/DATA/karung-sukawarna/venv/bin/python +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PYTHONPATH="$SCRIPT_DIR/src" +# Use project-local venv if available +if [ -f "$SCRIPT_DIR/venv/bin/python" ]; then + PYTHON="$SCRIPT_DIR/venv/bin/python" +else + PYTHON="python3" +fi dates=( "2026-06-10" "2026-06-11" "2026-06-12" "2026-06-13" "2026-06-14" "2026-06-15" "2026-06-16" "2026-06-17" "2026-06-18" "2026-06-19" ) +cameras=("CC1" "CC2" "CC3" "CC4") + +# Maximum parallel video jobs (default: 4) +MAX_JOBS="${MAX_JOBS:-4}" +echo "=== Starting per-video parallel processing with MAX_JOBS=$MAX_JOBS ===" for date in "${dates[@]}"; do - echo "=== Processing $date ===" - $PYTHON -m chicken_counter.cli batch --config configs/cycle7_batch.yaml --date "$date" --no-video + echo "=== Starting batch for date: $date ===" + for cam in "${cameras[@]}"; do + echo "=== Launching $date ($cam) ===" + $PYTHON -m chicken_counter.cli batch --config configs/cycle7_batch_optimized.yaml --date "$date" --camera "$cam" --no-video & + done + wait || true + echo "=== Date $date completed ===" done + +echo "=== All video processing jobs completed ===" diff --git a/test_run_hybrid.sh b/test_run_hybrid.sh new file mode 100755 index 0000000..65cca7f --- /dev/null +++ b/test_run_hybrid.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PYTHONPATH="$SCRIPT_DIR/src" + +# Use project-local venv if available +if [ -f "$SCRIPT_DIR/venv/bin/python" ]; then + PYTHON="$SCRIPT_DIR/venv/bin/python" +else + PYTHON="python3" +fi + +# Allow passing a specific date as $1 (e.g. ./test_run_hybrid.sh 2026-06-18) +# Or default to all dates 2026-06-10 through 2026-06-19 +if [ -n "${1:-}" ]; then + dates=("$1") +else + dates=( + "2026-06-10" "2026-06-11" "2026-06-12" "2026-06-13" "2026-06-14" + "2026-06-15" "2026-06-16" "2026-06-17" "2026-06-18" "2026-06-19" + ) +fi + +echo "=== Starting Hybrid Execution Mode (Threaded CPU + Batched GPU) ===" + +for date in "${dates[@]}"; do + echo "=== Running hybrid batch for date: $date ===" + $PYTHON -m chicken_counter.cli batch \ + --config configs/cycle7_batch_optimized.yaml \ + --date "$date" \ + --mode hybrid \ + --no-video +done + +echo "=== All hybrid batch processing completed ===" diff --git a/test_run_mortality.sh b/test_run_mortality.sh new file mode 100755 index 0000000..4b16a81 --- /dev/null +++ b/test_run_mortality.sh @@ -0,0 +1,49 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PYTHONPATH="$SCRIPT_DIR/src" + +if [ -f "$SCRIPT_DIR/venv/bin/python" ]; then + PYTHON="$SCRIPT_DIR/venv/bin/python" +else + PYTHON="python3" +fi + +CONFIG_FILE="${CONFIG_FILE:-configs/mortality_config.yaml}" +INPUT_PATH="${1:-}" +OUTPUT_DIR="${2:-}" + +echo "=== Starting Whole-Image Mortality Chicken Detection ===" +echo "Config File: $CONFIG_FILE" +if [ -n "$INPUT_PATH" ]; then + echo "Input Path Override: $INPUT_PATH" +fi +if [ -n "$OUTPUT_DIR" ]; then + echo "Output Dir Override: $OUTPUT_DIR" +fi + +CMD=($PYTHON -m chicken_counter.cli mortality --config "$CONFIG_FILE") + +if [ -n "$INPUT_PATH" ]; then + CMD+=(--input "$INPUT_PATH") +fi +if [ -n "$OUTPUT_DIR" ]; then + CMD+=(--output-dir "$OUTPUT_DIR") +fi +if [ -n "${CONF:-}" ]; then + CMD+=(--conf "$CONF") +fi +if [ -n "${IOU:-}" ]; then + CMD+=(--iou "$IOU") +fi +if [ -n "${MIN_AREA:-}" ]; then + CMD+=(--min-area "$MIN_AREA") +fi +if [ -n "${DEDUPE_RADIUS:-}" ]; then + CMD+=(--dedupe-radius "$DEDUPE_RADIUS") +fi + +"${CMD[@]}" + +echo "=== Mortality Detection Completed ===" diff --git a/test_run_tensor_batch.sh b/test_run_tensor_batch.sh new file mode 100755 index 0000000..494b6fe --- /dev/null +++ b/test_run_tensor_batch.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PYTHONPATH="$SCRIPT_DIR/src" + +# Use project-local venv if available +if [ -f "$SCRIPT_DIR/venv/bin/python" ]; then + PYTHON="$SCRIPT_DIR/venv/bin/python" +else + PYTHON="python3" +fi + +# Allow passing a specific date as $1 (e.g. ./test_run_tensor_batch.sh 2026-06-18) +# Or default to all dates 2026-06-10 through 2026-06-19 +if [ -n "${1:-}" ]; then + dates=("$1") +else + dates=( + "2026-06-10" "2026-06-11" "2026-06-12" "2026-06-13" "2026-06-14" + "2026-06-15" "2026-06-16" "2026-06-17" "2026-06-18" "2026-06-19" + ) +fi + +echo "=== Starting Tensor Batching processing ===" + +for date in "${dates[@]}"; do + echo "=== Running tensor batch for date: $date ===" + $PYTHON -m chicken_counter.cli batch \ + --config configs/cycle7_batch_optimized.yaml \ + --date "$date" \ + --mode tensor_batching \ + --no-video +done + +echo "=== All tensor batch processing completed ===" diff --git a/test_run_video_2026-05-23.sh b/test_run_video_2026-05-23.sh new file mode 100755 index 0000000..3d5e9a4 --- /dev/null +++ b/test_run_video_2026-05-23.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PYTHONPATH="$SCRIPT_DIR/src" + +# Use project-local venv if available +if [ -f "$SCRIPT_DIR/venv/bin/python" ]; then + PYTHON="$SCRIPT_DIR/venv/bin/python" +else + PYTHON="python3" +fi + +dates=( + "2026-05-23" +) +cameras=("CC1" "CC2" "CC3" "CC4") + +# Maximum parallel video jobs (default: 4) +MAX_JOBS="${MAX_JOBS:-4}" +echo "=== Starting per-video parallel processing with video output for 2026-05-23 (MAX_JOBS=$MAX_JOBS) ===" + +for date in "${dates[@]}"; do + echo "=== Starting batch for date: $date ===" + for cam in "${cameras[@]}"; do + echo "=== Launching $date ($cam) ===" + $PYTHON -m chicken_counter.cli batch --config configs/cycle7_batch_optimized.yaml --date "$date" --camera "$cam" --output-subdir output_video & + done + wait || true + echo "=== Date $date completed ===" +done + +echo "=== All video processing jobs completed ===" diff --git a/test_run_video_2026-05-24.sh b/test_run_video_2026-05-24.sh new file mode 100755 index 0000000..9ac3c3d --- /dev/null +++ b/test_run_video_2026-05-24.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PYTHONPATH="$SCRIPT_DIR/src" +PYTHON="python3" + +dates=( + "2026-05-24" +) +cameras=("CC1" "CC2" "CC3" "CC4") + +# Maximum parallel video jobs (default: 4) +MAX_JOBS="${MAX_JOBS:-4}" +echo "=== Starting per-video parallel processing with video output for 2026-05-24 (MAX_JOBS=$MAX_JOBS) ===" + +for date in "${dates[@]}"; do + for cam in "${cameras[@]}"; do + echo "=== Launching $date ($cam) ===" + $PYTHON -m chicken_counter.cli batch --config configs/cycle7_batch_optimized.yaml --date "$date" --camera "$cam" --output-subdir output_video & + + # Throttle active background jobs to MAX_JOBS + while [ "$(jobs -r -p | wc -l)" -ge "$MAX_JOBS" ]; do + sleep 1 + done + done +done + +wait +echo "=== All video processing jobs completed ===" diff --git a/test_run_video_2026-06-06.sh b/test_run_video_2026-06-06.sh new file mode 100755 index 0000000..38d4308 --- /dev/null +++ b/test_run_video_2026-06-06.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PYTHONPATH="$SCRIPT_DIR/src" + +# Use project-local venv if available +if [ -f "$SCRIPT_DIR/venv/bin/python" ]; then + PYTHON="$SCRIPT_DIR/venv/bin/python" +else + PYTHON="python3" +fi + +dates=( + "2026-06-06" +) +cameras=("CC1" "CC2" "CC3" "CC4") + +# Maximum parallel video jobs (default: 4) +MAX_JOBS="${MAX_JOBS:-4}" +echo "=== Starting per-video parallel processing with video output for 2026-06-06 (MAX_JOBS=$MAX_JOBS) ===" + +for date in "${dates[@]}"; do + for cam in "${cameras[@]}"; do + echo "=== Launching $date ($cam) ===" + $PYTHON -m chicken_counter.cli batch --config configs/cycle7_batch_optimized.yaml --date "$date" --camera "$cam" --output-subdir output_video & + + # Throttle active background jobs to MAX_JOBS + while [ "$(jobs -r -p | wc -l)" -ge "$MAX_JOBS" ]; do + sleep 1 + done + done +done + +wait +echo "=== All video processing jobs completed ===" diff --git a/test_run_video_2026-06-16.sh b/test_run_video_2026-06-16.sh new file mode 100755 index 0000000..6038e5c --- /dev/null +++ b/test_run_video_2026-06-16.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PYTHONPATH="$SCRIPT_DIR/src" +PYTHON="python3" + +dates=( + "2026-06-16" +) +cameras=("CC1" "CC2" "CC3" "CC4") + +# Maximum parallel video jobs (default: 4) +MAX_JOBS="${MAX_JOBS:-4}" +echo "=== Starting per-video parallel processing with video output for 2026-06-16 (MAX_JOBS=$MAX_JOBS) ===" + +for date in "${dates[@]}"; do + for cam in "${cameras[@]}"; do + echo "=== Launching $date ($cam) ===" + $PYTHON -m chicken_counter.cli batch --config configs/cycle7_batch_optimized.yaml --date "$date" --camera "$cam" --output-subdir output_video & + + # Throttle active background jobs to MAX_JOBS + while [ "$(jobs -r -p | wc -l)" -ge "$MAX_JOBS" ]; do + sleep 1 + done + done +done + +wait +echo "=== All video processing jobs completed ===" diff --git a/test_run_video_2026-06-18.sh b/test_run_video_2026-06-18.sh new file mode 100755 index 0000000..592d9a7 --- /dev/null +++ b/test_run_video_2026-06-18.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PYTHONPATH="$SCRIPT_DIR/src" +PYTHON="python3" + +dates=( + "2026-06-18" +) +cameras=("CC1" "CC2" "CC3" "CC4") + +# Maximum parallel video jobs (default: 4) +MAX_JOBS="${MAX_JOBS:-4}" +echo "=== Starting per-video parallel processing with video output for 2026-06-18 (MAX_JOBS=$MAX_JOBS) ===" + +for date in "${dates[@]}"; do + for cam in "${cameras[@]}"; do + echo "=== Launching $date ($cam) ===" + $PYTHON -m chicken_counter.cli batch --config configs/cycle7_batch_optimized.yaml --date "$date" --camera "$cam" --output-subdir output_video & + + # Throttle active background jobs to MAX_JOBS + while [ "$(jobs -r -p | wc -l)" -ge "$MAX_JOBS" ]; do + sleep 1 + done + done +done + +wait +echo "=== All video processing jobs completed ===" diff --git a/tests/__pycache__/test_tracking.cpython-312.pyc b/tests/__pycache__/test_tracking.cpython-312.pyc deleted file mode 100755 index b5177ff..0000000 Binary files a/tests/__pycache__/test_tracking.cpython-312.pyc and /dev/null differ diff --git a/tests/test_counting.py b/tests/test_counting.py new file mode 100644 index 0000000..9807f8a --- /dev/null +++ b/tests/test_counting.py @@ -0,0 +1,112 @@ +"""Tests for CountingZone validation and double-count suppression.""" + +from __future__ import annotations + +import sys +import unittest +from unittest.mock import MagicMock + +# counting.py imports cv2; stub it so unit tests run without OpenCV installed. +if "cv2" not in sys.modules: + cv2_stub = MagicMock() + cv2_stub.pointPolygonTest = MagicMock(return_value=1.0) + sys.modules["cv2"] = cv2_stub + +import numpy as np + +from chicken_counter.config import GateConfig, RoiConfig +from chicken_counter.counting import CountingZone +from chicken_counter.types import TrackObservation + + +def _zone(**kwargs) -> CountingZone: + defaults = dict( + roi=RoiConfig( + points=[(0, 0), (200, 0), (200, 200), (0, 200)], + min_overlap_ratio=0.0, + ), + gate=GateConfig(), + trail_length=10, + track_buffer=75, + min_box_area_px=0, + validate_while_inside=True, + dedupe_radius_px=64, + dedupe_frames=40, + ) + defaults.update(kwargs) + return CountingZone(**defaults) + + +def _track( + track_id: int, + centroid: tuple[int, int] = (100, 100), + bbox: tuple[int, int, int, int] | None = None, +) -> TrackObservation: + cx, cy = centroid + if bbox is None: + bbox = (cx - 20, cy - 20, cx + 20, cy + 20) + return TrackObservation( + track_id=track_id, + class_id=0, + confidence=0.9, + bbox_xyxy=bbox, + centroid=centroid, + ) + + +class CountingDedupeTests(unittest.TestCase): + def setUp(self) -> None: + # Treat every centroid as inside the ROI polygon for these unit tests. + sys.modules["cv2"].pointPolygonTest = MagicMock(return_value=1.0) + + def test_counts_unique_track_once(self) -> None: + zone = _zone() + events = zone.update([_track(1)], frame_index=1) + self.assertEqual(len(events), 1) + self.assertEqual(zone.total_entered_count, 1) + + events = zone.update([_track(1)], frame_index=2) + self.assertEqual(len(events), 0) + self.assertEqual(zone.total_entered_count, 1) + + def test_suppresses_nearby_id_switch_double_count(self) -> None: + zone = _zone(dedupe_radius_px=64, dedupe_frames=40) + events = zone.update([_track(1, (100, 100))], frame_index=10) + self.assertEqual(len(events), 1) + self.assertEqual(zone.total_entered_count, 1) + + # New track ID near the prior count should reuse sequence, not increment. + events = zone.update([_track(2, (110, 105))], frame_index=20) + self.assertEqual(len(events), 0) + self.assertEqual(zone.total_entered_count, 1) + self.assertTrue(zone.is_validated(2)) + self.assertEqual(zone.sequence_number_for(2), 1) + + def test_allows_distant_second_bird(self) -> None: + zone = _zone(dedupe_radius_px=64, dedupe_frames=40) + zone.update([_track(1, (40, 40))], frame_index=10) + events = zone.update([_track(2, (160, 160))], frame_index=15) + self.assertEqual(len(events), 1) + self.assertEqual(zone.total_entered_count, 2) + + def test_allows_recount_after_dedupe_window(self) -> None: + zone = _zone(dedupe_radius_px=64, dedupe_frames=10) + zone.update([_track(1, (100, 100))], frame_index=10) + events = zone.update([_track(2, (100, 100))], frame_index=30) + self.assertEqual(len(events), 1) + self.assertEqual(zone.total_entered_count, 2) + + def test_dedupe_disabled_when_radius_zero(self) -> None: + zone = _zone(dedupe_radius_px=0, dedupe_frames=40) + zone.update([_track(1, (100, 100))], frame_index=10) + events = zone.update([_track(2, (100, 100))], frame_index=12) + self.assertEqual(len(events), 1) + self.assertEqual(zone.total_entered_count, 2) + + def test_counting_polygon_is_numpy_array(self) -> None: + zone = _zone() + self.assertIsInstance(zone._counting_polygon, np.ndarray) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tracking.py b/tests/test_tracking.py deleted file mode 100755 index 34a7c07..0000000 --- a/tests/test_tracking.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Tests for mask polygon handling in DetectionTracker.""" - -from __future__ import annotations - -import unittest - -import numpy as np - -from chicken_counter.types import TrackObservation - - -class TrackObservationMaskTests(unittest.TestCase): - def test_track_observation_accepts_mask_polygon(self) -> None: - poly = np.array([[10.0, 20.0], [30.0, 20.0], [30.0, 40.0]], dtype=np.float64) - track = TrackObservation( - track_id=1, - class_id=0, - confidence=0.9, - bbox_xyxy=(10, 20, 30, 40), - centroid=(20, 30), - mask_polygon_xy=poly, - ) - self.assertIsNotNone(track.mask_polygon_xy) - self.assertEqual(track.mask_polygon_xy.shape, (3, 2)) - - def test_crop_offset_translation_pattern(self) -> None: - """Mirrors tracking.infer crop offset applied to mask polygons.""" - offset_x, offset_y = 100, 50 - local = np.array([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], dtype=np.float64) - full = local.copy() - full[:, 0] += offset_x - full[:, 1] += offset_y - self.assertAlmostEqual(float(full[0, 0]), 100.0) - self.assertAlmostEqual(float(full[0, 1]), 50.0) - self.assertAlmostEqual(float(full[2, 0]), 110.0) - self.assertAlmostEqual(float(full[2, 1]), 60.0) - - -if __name__ == "__main__": - unittest.main()