38 changed files with 652 additions and 75581 deletions

No files matched your search

-3
View File
@@ -226,6 +226,3 @@ output/
*.db-shm *.db-shm
*.db-wal *.db-wal
*.2026* *.2026*
.vscode/
configs/config_backup_folder/
configs/cycle7_batch.yaml.*
-73611
View File
File diff suppressed because it is too large. Load diff
+33 -133
View File
@@ -185,156 +185,56 @@ Mortality endpoints require the dashboard to be started with `--mortality-dir <p
If multiple images are captured in a single day (e.g. morning and afternoon scans), the pipeline processes all images in the input directory, generates marked output JPEGs (`output_<name>.jpg`), and aggregates the daily total carcass count into `total_mortality_count`. If multiple images are captured in a single day (e.g. morning and afternoon scans), the pipeline processes all images in the input directory, generates marked output JPEGs (`output_<name>.jpg`), and aggregates the daily total carcass count into `total_mortality_count`.
### `GET /api/mortality/coops`
Returns a summary of all detected coops with their covered floors and mortality figures:
```json
[
{
"coop": "K1",
"covered_floors": ["K1-L1", "K1-L2", "K1-L3"],
"total_carcasses": 12,
"total_scans": 3,
"latest_date": "2026-06-18",
"latest_count": 4
},
{
"coop": "K2",
"covered_floors": ["K2-L1", "K2-L2", "K2-L3"],
"total_carcasses": 8,
"total_scans": 2,
"latest_date": "2026-06-18",
"latest_count": 8
}
]
```
### `GET /api/mortality/coop/<coop_id>`
Returns all mortality reports recorded for a specific coop (e.g. `GET /api/mortality/coop/K1`).
### `GET /api/mortality/latest` ### `GET /api/mortality/latest`
Returns the most recent `mortality_report.json` across all coop directories, enriched with `coop` and `covered_floors`: Returns the most recently modified `mortality_report.json` across all registered mortality directories.
```json ```json
{ {
"date": "2026-06-18", "date": "2026-07-09",
"coop": "K1", "mode": "similarity_two_pass",
"location": "K1", "model_path": "...",
"covered_floors": ["K1-L1", "K1-L2", "K1-L3"], "conf_threshold": 0.7,
"total_mortality_count": 19, "iou_threshold": 0.8,
"total_images": 1, "total_images": 2,
"_dir": "/path/to/VIDEOS/cycle7/K1/mortality",
"results": [...]
}
```
### `GET /api/mortality/history`
Returns all `mortality_report.json` files from all registered coop directories, sorted newest first. Each report contains `coop`, `covered_floors`, `total_mortality_count`, and `total_images`.
### `GET /api/mortality/date/<YYYY-MM-DD>`
Returns all mortality scans and total carcass counts recorded on a specific date:
```json
{
"date": "2026-06-18",
"total_mortality_count": 35, "total_mortality_count": 35,
"total_images": 2, "_dir": "/path/to/mortality",
"reports": [...],
"results": [...]
}
```
### `POST /api/mortality/capture`
Snaps a high-resolution still frame directly from the dedicated stationary inspection camera for a specific coop, runs YOLO carcass detection, saves the image into `VIDEOS/cycle7/<coop>/mortality/<date>/capture_XX_<timestamp>.jpg`, and returns the detection count.
**Content-Type**: `application/json` or `multipart/form-data` or Query Parameters
**Request Parameters**:
* `coop` (string, required): Coop identifier (e.g. `K1`, `K2`, `kandang-atas`).
* `date` (string, optional): Date in `YYYY-MM-DD` format (defaults to today).
* `source` (string, optional): Override camera RTSP stream / device URL. If omitted, uses the configured camera in `configs/mortality_config.yaml`.
* `conf` (float, optional): Confidence threshold override.
**Response (`200 OK`)**:
```json
{
"status": "success",
"message": "Captured photo #2 from K1 camera (12 carcasses)",
"coop": "K1",
"date": "2026-08-20",
"camera_source": "rtsp://admin:admin@192.168.1.101:554/live",
"captured_file": "capture_02_20260820_103000.jpg",
"batch_count": 12,
"total_images": 2,
"total_mortality_count": 30,
"results": [...]
}
```
### `GET /api/mortality/camera/preview?coop=<coop_id>`
Returns a live JPEG snapshot frame from that coop's stationary camera so workers can verify chicken positioning before capturing.
```
GET /api/mortality/camera/preview?coop=K1
→ Content-Type: image/jpeg
```
### `GET /api/mortality/cameras`
Returns the map of all registered stationary cameras per coop.
```json
{
"cameras": {
"K1": "rtsp://admin:admin@192.168.1.101:554/live",
"K2": "rtsp://admin:admin@192.168.1.102:554/live",
"K3": "rtsp://admin:admin@192.168.1.103:554/live",
"K4": "rtsp://admin:admin@192.168.1.104:554/live",
"K5": "rtsp://admin:admin@192.168.1.105:554/live"
}
}
```
### `POST /api/mortality/upload`
Uploads one or more mortality photos from disk for a specific coop, runs YOLO carcass detection immediately, saves results into `VIDEOS/cycle7/<coop>/mortality/<date>/`, and returns detection results.
**Content-Type**: `multipart/form-data`
**Form Fields**:
* `coop` (string, required): Coop identifier (e.g. `K1`, `K2`, `kandang-atas`).
* `date` (string, optional): Date in `YYYY-MM-DD` format (defaults to current date).
* `conf` (float, optional): Confidence threshold override (e.g. `0.7`).
* `two_pass` (boolean, optional): Set `true` for 2-pass refinement.
* `images` (file/binary, repeatable): 1 or more image files (`.jpg`, `.jpeg`, `.png`).
**Response (`200 OK`)**:
```json
{
"status": "success",
"message": "Processed 2 image(s) for K1",
"coop": "K1",
"date": "2026-08-20",
"total_images": 2,
"total_mortality_count": 29,
"results": [ "results": [
{ {
"input_image": "batch_1.jpg", "input_image": "scan_01.jpg",
"output_image": "output_batch_1.jpg", "output_image": "output_scan_01.jpg",
"count": 19, "count": 19,
"output_path": "/path/to/VIDEOS/cycle7/K1/mortality/2026-08-20/output_batch_1.jpg", "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": [...] "detections": [...]
} }
] ]
} }
``` ```
### `POST /api/mortality/scan` ### `GET /api/mortality/history`
Triggers immediate re-scanning of an existing coop directory: Returns all `mortality_report.json` files from all registered directories, sorted newest first. Each report contains `total_mortality_count` (grand total across all images in that run) and `total_images`.
### `GET /api/mortality/date/<YYYY-MM-DD>`
Returns all mortality scans and total carcass counts recorded on a specific date:
**Request Body (`application/json`)**:
```json ```json
{ {
"coop": "K1", "date": "2026-07-09",
"date": "2026-08-20" "total_mortality_count": 35,
"total_images": 2,
"reports": [...],
"results": [...]
} }
``` ```
+1 -36
View File
@@ -2,38 +2,9 @@
All notable changes to the `chicken-counting-sukawarna-det` project are documented in this file. All notable changes to the `chicken-counting-sukawarna-det` project are documented in this file.
## [Unreleased] - 2026-08-20 ## [Unreleased] - 2026-08-19
### 🚀 Features & Architectural Improvements
- **Hierarchical Per-Coop & Flexible Cage/Floor Discovery (`discovery.py`)**:
- Implemented dynamic farm discovery engine (`discover_farm_structure`, `get_coop_for_location`, `resolve_floor_video_dir`).
- Added support for hierarchical coop folders (`VIDEOS/cycle7/<Coop>/[L1..Ln, mortality]`) where all floors and mortality sit on the same level per coop.
- Fully dynamic site layout discovery without hardcoding assumptions about numbers of coops (e.g. K1..K5 or custom names) or floors per coop (e.g. 2 floors vs 3 floors).
- **Multi-Coop Mortality Pipeline & Batch Runner (`mortality.py`, `cli.py`, `run_mortality_all.sh`)**:
- Added coop and covered floor metadata resolution in `run_mortality_count(...)` and `mortality_report.json`.
- Created `run_all_coop_mortality(...)` and `./run_mortality_all.sh` to batch-process mortality detection across all discovered coops with summary reports.
- Added CLI flags `--coop <ID>` and `--all-coops` to `chicken-counter mortality`.
- **Dedicated Stationary Inspection Camera Capture & Live API (`mortality.py`, `dashboard.py`, `templates/index.html`)**:
- Added support for dedicated per-coop stationary cameras (RTSP IP cameras, HTTP snapshots, and USB cameras) configured in `configs/mortality_config.yaml`.
- Added `POST /api/mortality/capture` endpoint to grab high-res still frames from a coop's stationary camera, timestamp and save them into `VIDEOS/cycle7/<Coop>/mortality/<YYYY-MM-DD>/`, and run YOLO carcass detection.
- Added `GET /api/mortality/camera/preview` to stream live snapshot previews to workers in the webapp before snapping.
- Added `GET /api/mortality/cameras` to list all registered coop stationary camera sources.
- Web UI Mortality Inspector: Integrated live camera preview, one-click **"📸 Snap & Count Batch from Camera"** button, and support for multi-batch counting per coop per day with cumulative totals.
- Added fallback manual file upload tab for disk-based images.
- **Dynamic Floor Config Synthesis & Sync Tool (`config.py`, `discovery.py`, `cli.py`)**:
- Implemented `load_or_synthesize_floor_config` allowing `chicken-counter batch --floor <ID>` to dynamically synthesize settings from `cycle7_batch_optimized.yaml` in memory without requiring a physical YAML file.
- Implemented `sync_floor_configs` and CLI command `chicken-counter sync-configs` to automatically scan `VIDEOS/` and generate boilerplate floor YAML configs for newly added coops/floors.
- Updated `run_all_coops.sh` to dynamically discover all floors directly from `VIDEOS/` or `configs/floor_config/` and seamlessly run physical YAMLs or virtual in-memory configs.
- **Unit Test Suite (`tests/test_flexible_discovery.py`)**:
- Added automated test cases validating dynamic coop and floor extraction, arbitrary site layout adaptation, path resolution, config synchronization, virtual floor config synthesis, and dated mortality discovery.
## [1.1.0] - 2026-08-19
### 🐛 Bug Fixes ### 🐛 Bug Fixes
- **Multi-Camera Tracker State Isolation (`tracking.py` & `batch_runner.py`)**:
- Implemented per-camera independent tracker instances keyed by `stream_id` in `DetectionTracker`.
- Fixed tracker state bleed and track ID jumping in `tensor_batching` and multi-camera batch modes when camera frames are processed or when active camera sets shrink.
- Added unit test suite `tests/test_tracking.py` covering multi-stream tracker isolation.
- **Multi-Floor Script Syntax (`run_all_coops.sh`)**: - **Multi-Floor Script Syntax (`run_all_coops.sh`)**:
- Resolved fatal `syntax error: unexpected end of file` caused by missing `done` in floor configuration discovery loop. - Resolved fatal `syntax error: unexpected end of file` caused by missing `done` in floor configuration discovery loop.
- Expanded config discovery pattern from `K*-L*.yaml` to `*.yaml` to support custom named coops (e.g. `kandang-atas.yaml`). - Expanded config discovery pattern from `K*-L*.yaml` to `*.yaml` to support custom named coops (e.g. `kandang-atas.yaml`).
@@ -46,12 +17,6 @@ All notable changes to the `chicken-counting-sukawarna-det` project are document
- Configured `pip install -e .` in environment setup to resolve `chicken_counter` imports and enable automatic unit test discovery without manual `PYTHONPATH` prefixes. - Configured `pip install -e .` in environment setup to resolve `chicken_counter` imports and enable automatic unit test discovery without manual `PYTHONPATH` prefixes.
### ⚡ Performance & Optimization ### ⚡ Performance & Optimization
- **Secondary Database Indexing (`dashboard.py`, `batch_runner.py`, `store_results.py`)**:
- Added `idx_batch_date`, `idx_batch_camera`, and `idx_batch_location` indexes on `batch_runs` to accelerate single camera, location, and date historical queries.
- **Atomic Config Persistence (`dashboard.py`)**:
- Added `_config_write_lock` and atomic temporary file replacement (`os.replace`) in `_persist_cycle_start_date` to prevent race conditions or half-written YAML reads.
- **Streaming Socket Keepalive (`dashboard.py`)**:
- Added an idle keepalive heartbeat (`b"--frame\r\n\r\n"`) every 3 seconds to immediately detect and terminate disconnected MJPEG streaming threads during pipeline idle periods.
- **Disk Traversal Caching in Live Dashboard (`dashboard.py`)**: - **Disk Traversal Caching in Live Dashboard (`dashboard.py`)**:
- Replaced repetitive `Path.rglob()` scans on every `/api/mortality/*` endpoint with a thread-safe 15-second in-memory TTL cache (`_MORTALITY_CACHE_TTL = 15.0s`). - Replaced repetitive `Path.rglob()` scans on every `/api/mortality/*` endpoint with a thread-safe 15-second in-memory TTL cache (`_MORTALITY_CACHE_TTL = 15.0s`).
- Added $O(1)$ memory index lookup for serving annotated images (`/api/mortality/image/<filename>`), eliminating full filesystem traversals across 30GB+ video datasets. - Added $O(1)$ memory index lookup for serving annotated images (`/api/mortality/image/<filename>`), eliminating full filesystem traversals across 30GB+ video datasets.
+17 -56
View File
@@ -95,80 +95,41 @@ Place input videos under the `VIDEOS` folder adjacent to the project following t
Then run: Then run:
```bash ```bash
# Run all coops & floors dynamically for a specific date # Run all coops (10 floors: K1-L1 to K5-L2) for a specific date
./run_all_coops.sh 2026-06-18 ./run_all_coops.sh 2026-06-18
# Run a specific floor using its config file # Run a specific floor (e.g. Kandang 1 Lantai 1)
PYTHONPATH=src venv/bin/python -m chicken_counter.cli batch \ PYTHONPATH=src venv/bin/python -m chicken_counter.cli batch \
--config configs/floor_config/K1-L1.yaml \ --config configs/floor_config/K1-L1.yaml \
--date 2026-06-18 --date 2026-06-18
# Run directly on ANY discovered floor (virtual config — zero YAML required!) # Run archive scripts in test_run_folder
PYTHONPATH=src venv/bin/python -m chicken_counter.cli batch \ ./test_run_folder/test_run.sh
--floor K6-L1 \ ./test_run_folder/test_run_tensor_batch.sh
--date 2026-06-18 ./test_run_folder/test_run_hybrid.sh
# Automatically scan VIDEOS and generate boilerplate YAMLs for any newly added floors
PYTHONPATH=src venv/bin/python -m chicken_counter.cli sync-configs
``` ```
Output is saved in `../VIDEOS/cycle7/<coop>/<floor>/<date>/output/`. Output is saved in `../VIDEOS/cycle7/kandang-atas/2026-06-18/output/`.
--- ---
### B — Mortality Detection (Per-Coop Carcass Photo Scanning) ### B — Mortality Detection (Carcass Photo Scanning)
#### Method 1: Stationary Inspection Camera Capture (Recommended for Farm Workers) Place input photos in `../VIDEOS/cycle7/kandang-atas/mortality/`.
Each coop (or central mortality table) is equipped with a dedicated stationary camera (RTSP stream or USB camera) mounted above the inspection area:
1. **Open the Webapp**: From any phone, tablet, or terminal on the farm local network, navigate to `http://<JETSON-IP>:8080`.
2. **Open Mortality Inspector**: Under the sidebar **💀 Mortality Scans**, tap **`📷 Upload / Capture`**.
3. **Select Coop & Inspect View**:
- Choose the coop (e.g. `K1`).
- The live preview from that coop's stationary camera displays on screen so the worker can verify the chickens are spread out properly on the table.
4. **Capture Batch 1**:
- Tap **`📸 Snap & Count Batch from Camera`**.
- The system grabs the frame from the camera, runs YOLO carcass detection, and shows the count (e.g. *"Batch 1: 18 carcasses detected"*).
5. **Capture Subsequent Batches (if needed)**:
- Clear the table and place the next batch of chickens.
- Tap **`📸 Snap & Count Next Batch`**.
- The system automatically captures `capture_02_...jpg`, runs detection, and increments the daily total: *"Total for K1 Today: 30 carcasses across 2 batches"*.
6. All high-res captures and output detection overlays are saved permanently in `VIDEOS/cycle7/<Coop>/mortality/<YYYY-MM-DD>/`.
#### Camera Configuration (`configs/mortality_config.yaml`)
Map each coop to its RTSP stream URL, HTTP snapshot URL, or USB device index:
```yaml
mortality:
cameras:
K1: "rtsp://admin:admin@192.168.1.101:554/live"
K2: "rtsp://admin:admin@192.168.1.102:554/live"
K3: "rtsp://admin:admin@192.168.1.103:554/live"
K4: "rtsp://admin:admin@192.168.1.104:554/live"
K5: "rtsp://admin:admin@192.168.1.105:554/live"
```
#### Method 2: Command Line Batch Run
Place input photos inside each coop's mortality directory (e.g. `../VIDEOS/cycle7/K1/mortality/`, `../VIDEOS/cycle7/K2/mortality/`, etc.).
```bash ```bash
# Run mortality detection across ALL discovered coops (K1..K5, etc.) # Run with default config
./run_mortality_all.sh ./test_run_mortality.sh
# Run for a specific coop # Override confidence threshold
./run_mortality_all.sh K1 CONF=0.65 ./test_run_mortality.sh
# or via CLI:
PYTHONPATH=src venv/bin/python -m chicken_counter.cli mortality --coop K1
# Run across all coops for a specific date # Run on a specific image
./run_mortality_all.sh 2026-06-18 ./test_run_mortality.sh /path/to/photo.jpg
# Run directly on a specific image file
PYTHONPATH=src venv/bin/python -m chicken_counter.cli mortality -i /path/to/photo.jpg
``` ```
Output annotated images are saved as `output_<original_name>.jpg` in each coop's mortality directory. Output annotated images are saved as `output_<original_name>.jpg` in the same directory.
A `mortality_report.json` is generated for each coop, tagging the coop name and all covered floors (e.g., `["K1-L1", "K1-L2", "K1-L3"]`). A `mortality_report.json` is also saved there with full detection data.
#### Key config options in `configs/mortality_config.yaml` #### Key config options in `configs/mortality_config.yaml`
+94
View File
@@ -0,0 +1,94 @@
batch:
root_dir: /home/asus/.Codes/VIDEOS/cycle7/kandang-atas
camera_glob: "kandang_*_camera_{num}_*.mp4"
output_subdir: output
compress_max_mb: 200
delete_intermediate: false
checkpoint_every_n_frames: 3000
defaults:
detection:
model_path: /home/asus/.Codes/chicken-counting-sukawarna-det/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine
classes: [0]
ignored_classes: [1, 2]
conf: 0.35
iou: 0.55
imgsz: 640
device: "0"
min_box_area_px: 3000
validate_while_inside: true
detection_zone:
enabled: true
buffer_above_px: 250
buffer_below_px: 250
show_in_overlay: true
tracker:
tracker_config_path: /home/asus/.Codes/chicken-counting-sukawarna-det/configs/trackers/botsort_chicken.yaml
persist: true
track_buffer: 75
gate:
mode: two_line
lines_y: [420, 730]
direction: bottom_to_up
motion:
enabled: true
axis: vertical
forward_sign: 1.0
ema_alpha: 0.2
reverse_enter_threshold: -1.5
reverse_exit_threshold: -0.5
debounce_frames: 12
min_features: 60
max_corners: 80
stride_frames: 2
flow_scale: 0.5
quality_level: 0.01
min_distance: 8
block_radius: 6
overlay:
show_boxes: true
show_track_trails: false
trail_length: 20
show_center_marker: true
show_track_ring: false
count_anchor: [780, 120]
inside_box_only: true
pending_blink: true
pending_colors:
- [255, 255, 0]
- [0, 255, 255]
display:
show_window: false
encoder: auto
output_bitrate_kbps: 4000
codec_preference: [avc1, mp4v, H264]
performance:
half: false
overlay_buffer_reuse: true
inference_stride: 2
stream:
enabled: true
shm_dir: /dev/shm
interval_frames: 5
feedback:
enabled: true
every_n_frames: 3000
save_images: true
log_to_terminal: true
roi:
inset_left_px: 60
inset_right_px: 60
inset_top_px: 0
inset_bottom_px: 0
min_overlap_ratio: 0.30
cameras:
CC2:
camera_num: 2
count_anchor: [900, 120]
roi:
points:
- [20, 380]
- [1880, 380]
- [1880, 720]
- [20, 720]
+121
View File
@@ -0,0 +1,121 @@
batch:
root_dir: /home/asus/.Codes/VIDEOS/cycle7/kandang-atas
camera_glob: "kandang_*_camera_{num}_*.mp4"
output_subdir: output
compress_max_mb: 200
delete_intermediate: false
checkpoint_every_n_frames: 3000
defaults:
detection:
model_path: /home/asus/.Codes/chicken-counting-sukawarna-det/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine
classes: [0]
ignored_classes: [1, 2]
conf: 0.35
iou: 0.55
imgsz: 640
device: "0"
min_box_area_px: 3000
validate_while_inside: true
detection_zone:
enabled: true
buffer_above_px: 250
buffer_below_px: 250
show_in_overlay: true
tracker:
tracker_config_path: /home/asus/.Codes/chicken-counting-sukawarna-det/configs/trackers/botsort_chicken.yaml
persist: true
track_buffer: 75
gate:
mode: two_line
lines_y: [420, 730]
direction: bottom_to_up
motion:
enabled: true
axis: vertical
forward_sign: 1.0
ema_alpha: 0.2
reverse_enter_threshold: -1.5
reverse_exit_threshold: -0.5
debounce_frames: 12
min_features: 60
max_corners: 80
stride_frames: 2
flow_scale: 0.5
quality_level: 0.01
min_distance: 8
block_radius: 6
overlay:
show_boxes: true
show_track_trails: false
trail_length: 20
show_center_marker: true
show_track_ring: false
count_anchor: [780, 120]
inside_box_only: true
pending_blink: true
pending_colors:
- [255, 255, 0]
- [0, 255, 255]
display:
show_window: false
encoder: auto
output_bitrate_kbps: 4000
codec_preference: [avc1, mp4v, H264]
performance:
half: false
overlay_buffer_reuse: true
inference_stride: 2
stream:
enabled: true
shm_dir: /dev/shm
interval_frames: 5
feedback:
enabled: true
every_n_frames: 3000
save_images: true
log_to_terminal: true
roi:
inset_left_px: 60
inset_right_px: 60
inset_top_px: 0
inset_bottom_px: 0
min_overlap_ratio: 0.30
cameras:
CC1:
camera_num: 1
count_anchor: [780, 120]
roi:
points:
- [250, 330]
- [1650, 330]
- [1650, 720]
- [250, 720]
CC2:
camera_num: 2
count_anchor: [900, 120]
roi:
points:
- [20, 380]
- [1880, 380]
- [1880, 720]
- [20, 720]
CC3:
camera_num: 3
count_anchor: [900, 120]
roi:
points:
- [20, 330]
- [1880, 330]
- [1880, 720]
- [20, 720]
CC4:
camera_num: 4
count_anchor: [700, 120]
roi:
points:
- [50, 330]
- [1450, 330]
- [1450, 720]
- [50, 720]
+121
View File
@@ -0,0 +1,121 @@
batch:
root_dir: /home/asus/.Codes/VIDEOS/cycle7/kandang-atas
camera_glob: "kandang_*_camera_{num}_*.mp4"
output_subdir: output
compress_max_mb: 200
delete_intermediate: false
checkpoint_every_n_frames: 3000
defaults:
detection:
model_path: /home/asus/.Codes/chicken-counting-sukawarna-det/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine
classes: [0]
ignored_classes: [1, 2]
conf: 0.35
iou: 0.55
imgsz: 640
device: "0"
min_box_area_px: 3000
validate_while_inside: true
detection_zone:
enabled: true
buffer_above_px: 250
buffer_below_px: 250
show_in_overlay: true
tracker:
tracker_config_path: /home/asus/.Codes/chicken-counting-sukawarna-det/configs/trackers/botsort_chicken.yaml
persist: true
track_buffer: 75
gate:
mode: two_line
lines_y: [420, 730]
direction: bottom_to_up
motion:
enabled: true
axis: vertical
forward_sign: 1.0
ema_alpha: 0.2
reverse_enter_threshold: -1.5
reverse_exit_threshold: -0.5
debounce_frames: 12
min_features: 60
max_corners: 80
stride_frames: 2
flow_scale: 0.5
quality_level: 0.01
min_distance: 8
block_radius: 6
overlay:
show_boxes: true
show_track_trails: false
trail_length: 20
show_center_marker: true
show_track_ring: false
count_anchor: [780, 120]
inside_box_only: true
pending_blink: true
pending_colors:
- [255, 255, 0]
- [0, 255, 255]
display:
show_window: false
encoder: auto
output_bitrate_kbps: 4000
codec_preference: [avc1, mp4v, H264]
performance:
half: false
overlay_buffer_reuse: true
inference_stride: 2
stream:
enabled: true
shm_dir: /dev/shm
interval_frames: 5
feedback:
enabled: true
every_n_frames: 3000
save_images: true
log_to_terminal: true
roi:
inset_left_px: 60
inset_right_px: 60
inset_top_px: 0
inset_bottom_px: 0
min_overlap_ratio: 0.30
cameras:
CC1:
camera_num: 1
count_anchor: [780, 120]
roi:
points:
- [250, 330]
- [1650, 330]
- [1650, 720]
- [250, 720]
CC2:
camera_num: 2
count_anchor: [900, 120]
roi:
points:
- [20, 380]
- [1880, 380]
- [1880, 720]
- [20, 720]
CC3:
camera_num: 3
count_anchor: [900, 120]
roi:
points:
- [20, 330]
- [1880, 330]
- [1880, 720]
- [20, 720]
CC4:
camera_num: 4
count_anchor: [700, 120]
roi:
points:
- [50, 330]
- [1450, 330]
- [1450, 720]
- [50, 720]
+1 -1
View File
@@ -4,7 +4,7 @@ extends: ../cycle7_batch_optimized.yaml
batch: batch:
location: "K1-L1" location: "K1-L1"
root_dir: "../VIDEOS/cycle7/K1/L1" root_dir: "../VIDEOS/cycle7/K1-L1"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors) # Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras: # cameras:
+1 -1
View File
@@ -4,7 +4,7 @@ extends: ../cycle7_batch_optimized.yaml
batch: batch:
location: "K1-L2" location: "K1-L2"
root_dir: "../VIDEOS/cycle7/K1/L2" root_dir: "../VIDEOS/cycle7/K1-L2"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors) # Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras: # cameras:
+1 -1
View File
@@ -4,4 +4,4 @@ extends: ../cycle7_batch_optimized.yaml
batch: batch:
location: "K1-L3" location: "K1-L3"
root_dir: "../VIDEOS/cycle7/K1/L3" root_dir: "../VIDEOS/cycle7/K1-L3"
+1 -1
View File
@@ -4,7 +4,7 @@ extends: ../cycle7_batch_optimized.yaml
batch: batch:
location: "K2-L1" location: "K2-L1"
root_dir: "../VIDEOS/cycle7/K2/L1" root_dir: "../VIDEOS/cycle7/K2-L1"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors) # Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras: # cameras:
+1 -1
View File
@@ -4,7 +4,7 @@ extends: ../cycle7_batch_optimized.yaml
batch: batch:
location: "K2-L2" location: "K2-L2"
root_dir: "../VIDEOS/cycle7/K2/L2" root_dir: "../VIDEOS/cycle7/K2-L2"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors) # Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras: # cameras:
+1 -1
View File
@@ -4,4 +4,4 @@ extends: ../cycle7_batch_optimized.yaml
batch: batch:
location: "K2-L3" location: "K2-L3"
root_dir: "../VIDEOS/cycle7/K2/L3" root_dir: "../VIDEOS/cycle7/K2-L3"
+1 -1
View File
@@ -4,7 +4,7 @@ extends: ../cycle7_batch_optimized.yaml
batch: batch:
location: "K3-L1" location: "K3-L1"
root_dir: "../VIDEOS/cycle7/K3/L1" root_dir: "../VIDEOS/cycle7/K3-L1"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors) # Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras: # cameras:
+1 -1
View File
@@ -4,7 +4,7 @@ extends: ../cycle7_batch_optimized.yaml
batch: batch:
location: "K3-L2" location: "K3-L2"
root_dir: "../VIDEOS/cycle7/K3/L2" root_dir: "../VIDEOS/cycle7/K3-L2"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors) # Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras: # cameras:
+1 -1
View File
@@ -4,4 +4,4 @@ extends: ../cycle7_batch_optimized.yaml
batch: batch:
location: "K3-L3" location: "K3-L3"
root_dir: "../VIDEOS/cycle7/K3/L3" root_dir: "../VIDEOS/cycle7/K3-L3"
+1 -1
View File
@@ -4,7 +4,7 @@ extends: ../cycle7_batch_optimized.yaml
batch: batch:
location: "K4-L1" location: "K4-L1"
root_dir: "../VIDEOS/cycle7/K4/L1" root_dir: "../VIDEOS/cycle7/K4-L1"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors) # Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras: # cameras:
+1 -1
View File
@@ -4,7 +4,7 @@ extends: ../cycle7_batch_optimized.yaml
batch: batch:
location: "K4-L2" location: "K4-L2"
root_dir: "../VIDEOS/cycle7/K4/L2" root_dir: "../VIDEOS/cycle7/K4-L2"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors) # Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras: # cameras:
+1 -1
View File
@@ -4,7 +4,7 @@ extends: ../cycle7_batch_optimized.yaml
batch: batch:
location: "K5-L1" location: "K5-L1"
root_dir: "../VIDEOS/cycle7/K5/L1" root_dir: "../VIDEOS/cycle7/K5-L1"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors) # Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras: # cameras:
+1 -1
View File
@@ -4,7 +4,7 @@ extends: ../cycle7_batch_optimized.yaml
batch: batch:
location: "K5-L2" location: "K5-L2"
root_dir: "../VIDEOS/cycle7/K5/L2" root_dir: "../VIDEOS/cycle7/K5-L2"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors) # Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras: # cameras:
+2 -14
View File
@@ -1,10 +1,7 @@
# Configuration for Mortality Chicken Carcass Detection & Counting # Configuration for Mortality Chicken Carcass Detection & Counting
mortality: mortality:
# Base directory for multi-coop auto-discovery (K1..K5 and arbitrary coops) input_dir: "../VIDEOS/cycle7/kandang-atas/mortality"
coops_root_dir: "../VIDEOS/cycle7" output_dir: null # null = save output_<File Name> in input directory
# Default single-coop input directory (or override via --coop K1 / --all-coops)
input_dir: "../VIDEOS/cycle7/K1/mortality"
output_dir: null # null = save output_<File Name> in each coop's mortality directory
model_path: "models/chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt" model_path: "models/chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt"
device: "0" # "0" for GPU acceleration, or "cpu" device: "0" # "0" for GPU acceleration, or "cpu"
conf: 0.7 conf: 0.7
@@ -14,12 +11,3 @@ mortality:
two_pass: false # Enable Union 2x Detect & Refine crop pipeline 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) classes: [0] # Class 0 = chicken ONLY; ignores background/text/equipment (classes 1 not-chicken, 2 half-chicken)
imgsz: 640 imgsz: 640
# Dedicated stationary camera streams per coop (RTSP URL, HTTP snapshot URL, or USB device index)
cameras:
K1: "rtsp://admin:admin@192.168.1.101:554/live"
K2: "rtsp://admin:admin@192.168.1.102:554/live"
K3: "rtsp://admin:admin@192.168.1.103:554/live"
K4: "rtsp://admin:admin@192.168.1.104:554/live"
K5: "rtsp://admin:admin@192.168.1.105:554/live"
kandang-atas: "rtsp://admin:admin@192.168.1.100:554/live"
+12 -408
View File
@@ -6,7 +6,6 @@ from __future__ import annotations
import argparse import argparse
import json import json
import mimetypes import mimetypes
import os
import re import re
import sqlite3 import sqlite3
import threading import threading
@@ -17,20 +16,6 @@ from pathlib import Path
from socketserver import ThreadingMixIn from socketserver import ThreadingMixIn
from urllib.parse import parse_qs, unquote, urlparse from urllib.parse import parse_qs, unquote, urlparse
from chicken_counter.discovery import (
DEFAULT_VIDEOS_ROOT,
discover_all_mortality_dirs,
discover_farm_structure,
get_coop_for_location,
)
from chicken_counter.mortality import (
DEFAULT_CONFIG_PATH,
capture_camera_snapshot,
get_camera_source_for_coop,
load_mortality_config,
run_mortality_count,
)
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True daemon_threads = True
@@ -42,7 +27,6 @@ TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
_thread_local = threading.local() _thread_local = threading.local()
_db_lock = threading.Lock() _db_lock = threading.Lock()
_config_write_lock = threading.Lock()
_db_path = "" _db_path = ""
_mortality_dirs: list[Path] = [] _mortality_dirs: list[Path] = []
_mortality_cache_lock = threading.Lock() _mortality_cache_lock = threading.Lock()
@@ -67,15 +51,13 @@ _cycle_start_date: str = _load_initial_cycle_start_date()
def _persist_cycle_start_date(new_date: str) -> bool: def _persist_cycle_start_date(new_date: str) -> bool:
"""Update in-memory cycle_start_date and save to config YAML files atomically.""" """Update in-memory cycle_start_date and save to config YAML files."""
global _cycle_start_date global _cycle_start_date
updated_any = False
with _config_write_lock:
_cycle_start_date = new_date _cycle_start_date = new_date
updated_any = False
for cfg_name in ("cycle7_batch_optimized.yaml", "cycle7_batch.yaml"): for cfg_name in ("cycle7_batch_optimized.yaml", "cycle7_batch.yaml"):
cfg_path = Path(__file__).resolve().parent / "configs" / cfg_name cfg_path = Path(__file__).resolve().parent / "configs" / cfg_name
if cfg_path.exists(): if cfg_path.exists():
try:
content = cfg_path.read_text(encoding="utf-8") content = cfg_path.read_text(encoding="utf-8")
pattern = r"^([ \t]*cycle_start_date:[ \t]*)(?:['\"]?)([^'\"\r\n#]+)(?:['\"]?)([ \t]*(?:#.*)?)$" pattern = r"^([ \t]*cycle_start_date:[ \t]*)(?:['\"]?)([^'\"\r\n#]+)(?:['\"]?)([ \t]*(?:#.*)?)$"
@@ -90,12 +72,8 @@ def _persist_cycle_start_date(new_date: str) -> bool:
new_content, count = re.subn(pattern, replacer, content, count=1, flags=re.MULTILINE) new_content, count = re.subn(pattern, replacer, content, count=1, flags=re.MULTILINE)
if count > 0: if count > 0:
tmp_path = cfg_path.with_suffix(f".tmp.{os.getpid()}") cfg_path.write_text(new_content, encoding="utf-8")
tmp_path.write_text(new_content, encoding="utf-8")
os.replace(tmp_path, cfg_path)
updated_any = True updated_any = True
except Exception as err:
print(f"[dashboard] ⚠️ Error atomically updating {cfg_name}: {err}")
print(f"[dashboard] 📅 Updated cycle_start_date to: {new_date} (persisted in configs: {updated_any})") print(f"[dashboard] 📅 Updated cycle_start_date to: {new_date} (persisted in configs: {updated_any})")
return updated_any return updated_any
@@ -141,9 +119,6 @@ def _init_db(db_path: str) -> None:
source_video TEXT NOT NULL DEFAULT '', source_video TEXT NOT NULL DEFAULT '',
generated_at TEXT NOT NULL DEFAULT '', generated_at TEXT NOT NULL DEFAULT '',
UNIQUE(date, location, camera_id))""") UNIQUE(date, location, camera_id))""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_batch_date ON batch_runs (date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_batch_camera ON batch_runs (camera_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_batch_location ON batch_runs (location)")
conn.commit() conn.commit()
conn.close() conn.close()
@@ -158,41 +133,6 @@ def _discover_cameras(shm_dir):
return cameras return cameras
def _parse_multipart(rfile, headers) -> tuple[dict[str, str], list[tuple[str, bytes]]]:
"""Parse multipart/form-data payload into fields dict and list of (filename, file_bytes)."""
import email
content_type = headers.get("content-type", "")
content_length = int(headers.get("content-length", 0))
if not content_length:
return {}, []
raw_data = rfile.read(content_length)
msg_bytes = b"Content-Type: " + content_type.encode("utf-8") + b"\r\n\r\n" + raw_data
msg = email.message_from_bytes(msg_bytes)
fields: dict[str, str] = {}
files: list[tuple[str, bytes]] = []
for part in msg.walk():
cd = part.get("Content-Disposition", "")
if "form-data" not in cd:
continue
name_m = re.search(r'name="([^"]+)"', cd)
filename_m = re.search(r'filename="([^"]+)"', cd)
if filename_m:
fn = Path(filename_m.group(1)).name
payload = part.get_payload(decode=True)
if fn and payload is not None:
files.append((fn, payload))
elif name_m:
val = part.get_payload(decode=True)
if val is not None:
fields[name_m.group(1)] = val.decode("utf-8", errors="replace").strip()
return fields, files
class DashboardHandler(SimpleHTTPRequestHandler): class DashboardHandler(SimpleHTTPRequestHandler):
shm_dir = DEFAULT_SHM_DIR shm_dir = DEFAULT_SHM_DIR
poll_ms = 1000 poll_ms = 1000
@@ -215,14 +155,6 @@ class DashboardHandler(SimpleHTTPRequestHandler):
except (BrokenPipeError, ConnectionResetError): except (BrokenPipeError, ConnectionResetError):
pass pass
def do_OPTIONS(self):
self.send_response(200)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
self.send_header("Content-Length", "0")
self.end_headers()
def do_POST(self): def do_POST(self):
try: try:
parsed = urlparse(self.path) parsed = urlparse(self.path)
@@ -230,210 +162,9 @@ class DashboardHandler(SimpleHTTPRequestHandler):
if path == "/api/config/cycle_start_date": if path == "/api/config/cycle_start_date":
self._handle_set_cycle_start_date() self._handle_set_cycle_start_date()
return return
if path == "/api/mortality/upload":
self._handle_mortality_upload()
return
if path == "/api/mortality/capture":
self._handle_mortality_capture()
return
if path == "/api/mortality/scan":
self._handle_mortality_scan()
return
self._send_error(404) self._send_error(404)
except (BrokenPipeError, ConnectionResetError): except (BrokenPipeError, ConnectionResetError):
pass pass
except Exception as err:
self._send_json({"status": "error", "message": str(err)}, status_code=500)
def _handle_mortality_capture(self):
content_type = self.headers.get("content-type", "")
coop = "K1"
date_str = datetime.now().strftime("%Y-%m-%d")
source = None
conf = None
two_pass = False
if "multipart/form-data" in content_type:
fields, _ = _parse_multipart(self.rfile, self.headers)
coop = fields.get("coop", "").strip() or "K1"
date_str = fields.get("date", "").strip() or date_str
source = fields.get("source", "").strip() or None
if fields.get("conf"):
conf = float(fields.get("conf"))
two_pass = fields.get("two_pass", "false").lower() in ("true", "1", "yes")
elif "application/json" in content_type:
length = int(self.headers.get("content-length", 0))
body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
try:
payload = json.loads(body)
coop = payload.get("coop", "").strip() or "K1"
date_str = payload.get("date", "").strip() or date_str
source = payload.get("source", "").strip() or None
if payload.get("conf"):
conf = float(payload.get("conf"))
two_pass = bool(payload.get("two_pass", False))
except Exception:
pass
else:
parsed = urlparse(self.path)
q = parse_qs(parsed.query)
if "coop" in q and q["coop"]:
coop = q["coop"][0].strip()
if "date" in q and q["date"]:
date_str = q["date"][0].strip()
if "source" in q and q["source"]:
source = q["source"][0].strip()
cam_source = source or get_camera_source_for_coop(coop)
if not cam_source:
self._send_json({
"status": "error",
"message": f"No stationary camera configured for Coop {coop}. Please set camera RTSP/device in configs/mortality_config.yaml.",
"coop": coop,
}, status_code=400)
return
print(f"[dashboard] 📷 Grabbing snapshot from stationary camera ({cam_source}) for Coop {coop} ({date_str})...")
frame = capture_camera_snapshot(cam_source)
if frame is None:
self._send_json({
"status": "error",
"message": f"Failed to connect or capture frame from camera: {cam_source}",
"coop": coop,
"camera_source": str(cam_source),
}, status_code=502)
return
target_dir = DEFAULT_VIDEOS_ROOT / "cycle7" / coop / "mortality" / date_str
target_dir.mkdir(parents=True, exist_ok=True)
existing_captures = list(target_dir.glob("capture_*.jpg"))
next_num = len(existing_captures) + 1
timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"capture_{next_num:02d}_{timestamp_str}.jpg"
dest_path = target_dir / filename
import cv2
cv2.imwrite(str(dest_path), frame)
print(f"[dashboard] 💾 Saved camera capture #{next_num}: {dest_path}")
try:
report_data = run_mortality_count(
input_path=target_dir,
date=date_str,
coop=coop,
conf_threshold=conf,
two_pass=two_pass,
)
with _mortality_cache_lock:
global _mortality_reports_mtime
_mortality_reports_mtime = 0.0
latest_det = next((r for r in report_data.get("results", []) if r.get("input_image") == filename), None)
self._send_json({
"status": "success",
"message": f"Captured photo #{next_num} from {coop} camera ({latest_det.get('count', 0) if latest_det else 0} carcasses)",
"coop": coop,
"date": date_str,
"camera_source": str(cam_source),
"captured_file": filename,
"batch_count": latest_det.get("count", 0) if latest_det else 0,
"total_images": report_data.get("total_images", next_num),
"total_mortality_count": report_data.get("total_mortality_count", 0),
"results": report_data.get("results", []),
"report": report_data,
})
except Exception as e:
self._send_json({
"status": "error",
"message": f"Detection error on capture: {str(e)}",
"coop": coop,
"captured_file": filename,
}, status_code=500)
def _handle_mortality_upload(self):
fields, files = _parse_multipart(self.rfile, self.headers)
if not files:
self._send_json({"status": "error", "message": "No image files uploaded."}, status_code=400)
return
coop = fields.get("coop", "").strip() or "K1"
date_str = fields.get("date", "").strip() or datetime.now().strftime("%Y-%m-%d")
conf_str = fields.get("conf", "").strip()
conf = float(conf_str) if conf_str else None
two_pass = fields.get("two_pass", "false").lower() in ("true", "1", "yes")
# Target directory: VIDEOS/cycle7/<coop>/mortality/<date>/
target_dir = DEFAULT_VIDEOS_ROOT / "cycle7" / coop / "mortality" / date_str
target_dir.mkdir(parents=True, exist_ok=True)
saved_files = []
for orig_name, file_bytes in files:
clean_name = re.sub(r"[^a-zA-Z0-9_.-]", "_", Path(orig_name).name)
if not clean_name:
clean_name = f"scan_{int(time.time()*1000)}.jpg"
dest = target_dir / clean_name
dest.write_bytes(file_bytes)
saved_files.append(dest)
print(f"[dashboard] 📥 Received {len(saved_files)} mortality photo(s) for Coop {coop} ({date_str}) in {target_dir}")
try:
report_data = run_mortality_count(
input_path=target_dir,
date=date_str,
coop=coop,
conf_threshold=conf,
two_pass=two_pass,
)
# Invalidate cached report paths to reflect new report immediately
with _mortality_cache_lock:
global _mortality_reports_mtime
_mortality_reports_mtime = 0.0
self._send_json({
"status": "success",
"message": f"Processed {len(saved_files)} image(s) for {coop}",
"coop": coop,
"date": date_str,
"total_images": report_data.get("total_images", len(saved_files)),
"total_mortality_count": report_data.get("total_mortality_count", 0),
"results": report_data.get("results", []),
"report": report_data,
})
except Exception as e:
self._send_json({
"status": "error",
"message": f"Detection error: {str(e)}",
"coop": coop,
"saved_files": [f.name for f in saved_files],
}, status_code=500)
def _handle_mortality_scan(self):
length = int(self.headers.get("content-length", 0))
body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
try:
payload = json.loads(body)
except Exception:
payload = {}
coop = payload.get("coop", "K1").strip()
date_str = payload.get("date", "").strip() or datetime.now().strftime("%Y-%m-%d")
target_dir = DEFAULT_VIDEOS_ROOT / "cycle7" / coop / "mortality"
if (target_dir / date_str).is_dir():
target_dir = target_dir / date_str
if not target_dir.is_dir():
self._send_json({"status": "error", "message": f"Mortality directory not found: {target_dir}"}, status_code=404)
return
report_data = run_mortality_count(input_path=target_dir, date=date_str, coop=coop)
with _mortality_cache_lock:
global _mortality_reports_mtime
_mortality_reports_mtime = 0.0
self._send_json({"status": "success", "report": report_data})
def _handle(self): def _handle(self):
parsed = urlparse(self.path) parsed = urlparse(self.path)
@@ -568,14 +299,9 @@ class DashboardHandler(SimpleHTTPRequestHandler):
self.send_header("Cache-Control", "no-cache") self.send_header("Cache-Control", "no-cache")
self.end_headers() self.end_headers()
last_mtime = 0.0 last_mtime = 0
last_write_time = time.time()
keepalive_interval = 3.0
try: try:
while True: while True:
now = time.time()
frame_written = False
try: try:
mtime = frame_path.stat().st_mtime mtime = frame_path.stat().st_mtime
if mtime != last_mtime: if mtime != last_mtime:
@@ -588,19 +314,10 @@ class DashboardHandler(SimpleHTTPRequestHandler):
data + b"\r\n" data + b"\r\n"
) )
self.wfile.flush() self.wfile.flush()
last_write_time = now
frame_written = True
except (FileNotFoundError, OSError): except (FileNotFoundError, OSError):
pass pass
# If idle (no new frames), send a lightweight keepalive ping to detect disconnected clients
if not frame_written and (now - last_write_time) >= keepalive_interval:
self.wfile.write(b"--frame\r\n\r\n")
self.wfile.flush()
last_write_time = now
time.sleep(0.1) time.sleep(0.1)
except (BrokenPipeError, ConnectionResetError, OSError): except (BrokenPipeError, ConnectionResetError):
pass pass
def _handle_shm(self, path): def _handle_shm(self, path):
@@ -681,57 +398,27 @@ class DashboardHandler(SimpleHTTPRequestHandler):
def _handle_mortality(self, path: str) -> None: def _handle_mortality(self, path: str) -> None:
"""Serve mortality detection results. """Serve mortality detection results.
GET /api/mortality/coops - Summary list of all detected coops with mortality data.
GET /api/mortality/coop/<coop_id> - Mortality reports filtered by specific coop (e.g. K1).
GET /api/mortality/latest - Most recent mortality_report.json across all dirs. 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/history - List of all mortality reports found (newest first).
GET /api/mortality/date/<date> - Mortality breakdown for a specific date (YYYY-MM-DD). GET /api/mortality/date/<date> - Mortality breakdown for a specific date (YYYY-MM-DD).
GET /api/mortality/image/<name> - Serve an output_*.jpg annotated image by filename. GET /api/mortality/image/<name> - Serve an output_*.jpg annotated image by filename.
""" """
global _mortality_dirs
if not _mortality_dirs: if not _mortality_dirs:
_mortality_dirs = list(discover_all_mortality_dirs().values()) self._send_json({"error": "No mortality directory configured. Start dashboard with --mortality-dir."})
if not _mortality_dirs:
self._send_json({"error": "No mortality directory configured or detected. Start dashboard with --mortality-dir."})
return return
def _infer_coop_and_floors(data: dict, report_path: Path) -> tuple[str, list[str]]: def _enrich_report(data: dict) -> dict:
coop = data.get("coop") or data.get("location")
if not coop:
for part in reversed(report_path.parts):
if part != "mortality_report.json" and part.lower() != "mortality" and not re.match(r"^\d{4}-\d{2}-\d{2}$", part):
coop = get_coop_for_location(part)
break
coop = coop or "kandang-atas"
floors = data.get("covered_floors")
if not floors:
farm_struct = discover_farm_structure()
floors = farm_struct.get_covered_floors(coop)
return coop, floors
def _enrich_report(data: dict, report_path: Path) -> dict:
coop, floors = _infer_coop_and_floors(data, report_path)
data["coop"] = coop
data["location"] = coop
data["covered_floors"] = floors
if "total_mortality_count" not in data and "results" in data: 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"]) data["total_mortality_count"] = sum(r.get("count", 0) for r in data["results"])
for res in data.get("results", []):
res["coop"] = coop
res["covered_floors"] = floors
return data return data
def _find_report_paths(force_refresh: bool = False) -> list[Path]: def _find_report_paths(force_refresh: bool = False) -> list[Path]:
global _mortality_reports_cache, _mortality_reports_mtime, _mortality_image_cache, _mortality_dirs global _mortality_reports_cache, _mortality_reports_mtime, _mortality_image_cache
now = time.time() now = time.time()
with _mortality_cache_lock: with _mortality_cache_lock:
if not force_refresh and (now - _mortality_reports_mtime) < _MORTALITY_CACHE_TTL and _mortality_reports_cache: if not force_refresh and (now - _mortality_reports_mtime) < _MORTALITY_CACHE_TTL and _mortality_reports_cache:
return list(_mortality_reports_cache) return list(_mortality_reports_cache)
if not _mortality_dirs:
_mortality_dirs = list(discover_all_mortality_dirs().values())
found = [] found = []
img_cache: dict[str, Path] = {} img_cache: dict[str, Path] = {}
for mdir in _mortality_dirs: for mdir in _mortality_dirs:
@@ -739,6 +426,7 @@ class DashboardHandler(SimpleHTTPRequestHandler):
if p.is_dir(): if p.is_dir():
for report in p.rglob("mortality_report.json"): for report in p.rglob("mortality_report.json"):
found.append(report) found.append(report)
# Index images in the same directory as the report
for img in report.parent.glob("output_*.jpg"): for img in report.parent.glob("output_*.jpg"):
img_cache[img.name] = img img_cache[img.name] = img
for img in report.parent.glob("output_*.png"): for img in report.parent.glob("output_*.png"):
@@ -785,89 +473,6 @@ class DashboardHandler(SimpleHTTPRequestHandler):
return img_path return img_path
return None return None
# --- /api/mortality/cameras ---
if path == "/api/mortality/cameras":
cfg = load_mortality_config(DEFAULT_CONFIG_PATH)
cameras = cfg.get("cameras", {})
self._send_json({"cameras": cameras})
return
# --- /api/mortality/camera/preview ---
if path == "/api/mortality/camera/preview":
parsed = urlparse(self.path)
q = parse_qs(parsed.query)
coop = q.get("coop", ["K1"])[0].strip()
cam_source = q.get("source", [""])[0].strip() or get_camera_source_for_coop(coop)
if not cam_source:
self._send_error(404)
return
frame = capture_camera_snapshot(cam_source, timeout_sec=3.0, warmup_frames=2)
if frame is None:
self._send_error(502)
return
import cv2
ret, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
if not ret:
self._send_error(500)
return
data = buf.tobytes()
self.send_response(200)
self.send_header("Content-Type", "image/jpeg")
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(data)
return
# --- /api/mortality/coops ---
if path == "/api/mortality/coops":
coops_summary: dict[str, dict] = {}
for report_path in _find_report_paths():
try:
data = json.loads(report_path.read_text(encoding="utf-8"))
enriched = _enrich_report(data, report_path)
cid = enriched.get("coop", "unknown")
if cid not in coops_summary:
coops_summary[cid] = {
"coop": cid,
"covered_floors": enriched.get("covered_floors", [cid]),
"total_carcasses": 0,
"total_scans": 0,
"latest_date": enriched.get("date", ""),
"latest_count": enriched.get("total_mortality_count", 0),
}
coops_summary[cid]["total_carcasses"] += enriched.get("total_mortality_count", 0)
coops_summary[cid]["total_scans"] += len(enriched.get("results", []))
if enriched.get("date", "") >= coops_summary[cid]["latest_date"]:
coops_summary[cid]["latest_date"] = enriched.get("date", "")
coops_summary[cid]["latest_count"] = enriched.get("total_mortality_count", 0)
except (json.JSONDecodeError, OSError):
pass
self._send_json(sorted(list(coops_summary.values()), key=lambda x: x["coop"]))
return
# --- /api/mortality/coop/<coop_id> ---
prefix_coop = "/api/mortality/coop/"
if path.startswith(prefix_coop):
target_coop = path[len(prefix_coop):].upper()
coop_reports = []
for report_path in _find_report_paths():
try:
data = json.loads(report_path.read_text(encoding="utf-8"))
enriched = _enrich_report(data, report_path)
if enriched.get("coop", "").upper() == target_coop:
enriched["_dir"] = str(report_path.parent)
enriched["_report_mtime"] = report_path.stat().st_mtime
coop_reports.append(enriched)
except (json.JSONDecodeError, OSError):
pass
coop_reports.sort(key=lambda x: x.get("_report_mtime", 0), reverse=True)
self._send_json(coop_reports)
return
# --- /api/mortality/history --- # --- /api/mortality/history ---
if path == "/api/mortality/history": if path == "/api/mortality/history":
results = [] results = []
@@ -876,7 +481,7 @@ class DashboardHandler(SimpleHTTPRequestHandler):
data = json.loads(report_path.read_text(encoding="utf-8")) data = json.loads(report_path.read_text(encoding="utf-8"))
data["_dir"] = str(report_path.parent) data["_dir"] = str(report_path.parent)
data["_report_mtime"] = report_path.stat().st_mtime data["_report_mtime"] = report_path.stat().st_mtime
results.append(_enrich_report(data, report_path)) results.append(_enrich_report(data))
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
pass pass
results.sort(key=lambda x: x.get("_report_mtime", 0), reverse=True) results.sort(key=lambda x: x.get("_report_mtime", 0), reverse=True)
@@ -894,11 +499,10 @@ class DashboardHandler(SimpleHTTPRequestHandler):
latest_mtime = mtime latest_mtime = mtime
latest = json.loads(report_path.read_text(encoding="utf-8")) latest = json.loads(report_path.read_text(encoding="utf-8"))
latest["_dir"] = str(report_path.parent) latest["_dir"] = str(report_path.parent)
latest = _enrich_report(latest, report_path)
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
pass pass
if latest: if latest:
self._send_json(latest) self._send_json(_enrich_report(latest))
else: else:
self._send_json({"error": "No mortality report found."}) self._send_json({"error": "No mortality report found."})
return return
@@ -917,7 +521,7 @@ class DashboardHandler(SimpleHTTPRequestHandler):
data = json.loads(report_path.read_text(encoding="utf-8")) 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)) 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: if report_date == target_date or report_path.parent.name == target_date:
enriched = _enrich_report(data, report_path) enriched = _enrich_report(data)
total_day_carcasses += enriched.get("total_mortality_count", 0) total_day_carcasses += enriched.get("total_mortality_count", 0)
total_day_images += enriched.get("total_images", 0) total_day_images += enriched.get("total_images", 0)
all_results.extend(enriched.get("results", [])) all_results.extend(enriched.get("results", []))
Binary file not shown.
+74
View File
@@ -0,0 +1,74 @@
#!/bin/bash
# Master runner for 5-coop / 10-floor daily processing
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PYTHONPATH="$SCRIPT_DIR/src"
if [ -f "$SCRIPT_DIR/venv/bin/python" ]; then
PYTHON="$SCRIPT_DIR/venv/bin/python"
else
PYTHON="python3"
fi
DATE="${1:-$(date +%Y-%m-%d)}"
FILTER="${2:-}" # Optional filter like 'K1', 'K2', 'K1-L3', or leave empty for all
MODE="${3:-parallel_processes}"
# Auto-discover all configured floors in configs/floor_config/ (sorted naturally)
CONFIGS=()
for cfg in $(ls -1 "$SCRIPT_DIR/configs/floor_config/"*.yaml 2>/dev/null | sort -V); do
base_name="$(basename "$cfg" .yaml)"
if [ -n "$FILTER" ] && [[ "$base_name" != *"$FILTER"* ]]; then
continue
fi
CONFIGS+=("configs/floor_config/$(basename "$cfg")")
done
# Startup check: Ensure sibling VIDEOS directory exists
VIDEOS_BASE="$SCRIPT_DIR/../VIDEOS"
if [ ! -d "$VIDEOS_BASE" ]; then
echo "[setup] 📁 Sibling directory '$VIDEOS_BASE' not found; creating directory skeleton..."
mkdir -p "$VIDEOS_BASE/cycle7"
fi
echo "================================================================="
echo " Starting Multi-Floor Batch Processing for Date: $DATE"
if [ -n "$FILTER" ]; then
echo " Filter Applied: $FILTER"
fi
echo " Discovered Floors (${#CONFIGS[@]} total): ${CONFIGS[*]}"
echo " Execution Mode: $MODE"
echo "================================================================="
SUCCESS_COUNT=0
SKIPPED_COUNT=0
for config in "${CONFIGS[@]}"; do
if [ -f "$SCRIPT_DIR/$config" ]; then
floor_name="$(basename "$config" .yaml)"
video_day_dir="$SCRIPT_DIR/../VIDEOS/cycle7/$floor_name/$DATE"
# If videos don't exist for this floor/date on this machine, skip cleanly
if [ ! -d "$video_day_dir" ] && [ ! -d "$SCRIPT_DIR/../VIDEOS/cycle7/$floor_name" ]; then
echo "⏭️ [SKIPPED] $floor_name: No video directory found at ../VIDEOS/cycle7/$floor_name/$DATE"
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
continue
fi
echo ""
echo ">>> [PROCESSING] Location Config: $config <<<"
if $PYTHON -m chicken_counter.cli batch --config "$config" --date "$DATE" --mode "$MODE"; then
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
else
echo "⚠️ [SKIPPED / ERROR] $config for date $DATE (video not found or error occurred)."
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
fi
fi
done
echo ""
echo "================================================================="
echo " Multi-Floor Batch Summary: $SUCCESS_COUNT completed, $SKIPPED_COUNT skipped."
echo " Database: db/chicken_counts.db"
echo "================================================================="
-44
View File
@@ -1,44 +0,0 @@
#!/bin/bash
# Master runner for batch mortality detection across all coops
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PYTHONPATH="$SCRIPT_DIR/src"
if [ -f "$SCRIPT_DIR/venv/bin/python" ]; then
PYTHON="$SCRIPT_DIR/venv/bin/python"
else
PYTHON="python3"
fi
DATE_OR_COOP="${1:-}" # Optional: date (e.g. 2026-06-18) or coop (e.g. K1) or empty for all
CONFIG="${2:-configs/mortality_config.yaml}"
VIDEOS_BASE="$SCRIPT_DIR/../VIDEOS"
echo "================================================================="
echo " Starting Batch Multi-Coop Mortality Detection"
if [ -n "$DATE_OR_COOP" ]; then
echo " Target Filter: $DATE_OR_COOP"
fi
echo " Base Videos: $VIDEOS_BASE"
echo "================================================================="
if [[ "$DATE_OR_COOP" =~ ^[Kk][0-9]+$ ]] || [[ "$DATE_OR_COOP" =~ ^kandang.* ]]; then
# Run specific coop
echo ">>> Running mortality for coop: $DATE_OR_COOP <<<"
$PYTHON -m chicken_counter.cli mortality --config "$CONFIG" --coop "$DATE_OR_COOP" --videos-root "$VIDEOS_BASE"
elif [[ "$DATE_OR_COOP" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
# Run all coops for specific date
echo ">>> Running all coops for date: $DATE_OR_COOP <<<"
$PYTHON -m chicken_counter.cli mortality --config "$CONFIG" --all-coops --date "$DATE_OR_COOP" --videos-root "$VIDEOS_BASE"
else
# Run all coops for all available mortality folders
echo ">>> Running all discovered coop mortality folders <<<"
$PYTHON -m chicken_counter.cli mortality --config "$CONFIG" --all-coops --videos-root "$VIDEOS_BASE"
fi
echo ""
echo "================================================================="
echo " Batch Mortality Run Completed."
echo "================================================================="
+1 -2
View File
@@ -6,7 +6,6 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from chicken_counter.config import BatchSettings from chicken_counter.config import BatchSettings
from chicken_counter.discovery import resolve_floor_video_dir
@dataclass @dataclass
@@ -18,7 +17,7 @@ class CameraDiscoveryResult:
def discover_camera_videos(day_dir: Path, settings: BatchSettings) -> CameraDiscoveryResult: def discover_camera_videos(day_dir: Path, settings: BatchSettings) -> CameraDiscoveryResult:
if not day_dir.is_dir(): if not day_dir.is_dir():
# Startup check: Auto-create skeleton root_dir if missing # Startup check: Auto-create skeleton root_dir if missing
root_p = resolve_floor_video_dir(settings.batch.root_dir, settings.batch.location) root_p = Path(settings.batch.root_dir)
if not root_p.exists(): if not root_p.exists():
root_p.mkdir(parents=True, exist_ok=True) root_p.mkdir(parents=True, exist_ok=True)
print(f"[batch] 📁 Created missing video root directory: {root_p}") print(f"[batch] 📁 Created missing video root directory: {root_p}")
+5 -18
View File
@@ -9,7 +9,6 @@ from pathlib import Path
from chicken_counter.batch_discovery import CameraDiscoveryResult, 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.compress import compress_video_to_target
from chicken_counter.config import BatchSettings, CameraConfig, CameraPreset, build_camera_config_from_batch from chicken_counter.config import BatchSettings, CameraConfig, CameraPreset, build_camera_config_from_batch
from chicken_counter.discovery import resolve_floor_video_dir
from chicken_counter.engine_utils import ensure_compatible_model from chicken_counter.engine_utils import ensure_compatible_model
from chicken_counter.overlay import draw_overlay 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.pipeline import PipelineArtifacts, _consume_result, _write_stream_frame, build_pipeline, run_pipeline
@@ -42,9 +41,6 @@ def _store_to_db(report_path: Path, location: str, db_path: str) -> None:
source_video TEXT NOT NULL DEFAULT '', source_video TEXT NOT NULL DEFAULT '',
generated_at TEXT NOT NULL DEFAULT '', generated_at TEXT NOT NULL DEFAULT '',
UNIQUE(date, location, camera_id))""") UNIQUE(date, location, camera_id))""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_batch_date ON batch_runs (date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_batch_camera ON batch_runs (camera_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_batch_location ON batch_runs (location)")
date = report["date"] date = report["date"]
for camera_id, entry in report["cameras"].items(): for camera_id, entry in report["cameras"].items():
@@ -87,8 +83,7 @@ def run_tensor_batched_daily_batch(
settings.batch.cycle_start_date = cycle_start_date settings.batch.cycle_start_date = cycle_start_date
run_date = date or date_type.today().isoformat() run_date = date or date_type.today().isoformat()
floor_root = resolve_floor_video_dir(settings.batch.root_dir, settings.batch.location) day_dir = Path(settings.batch.root_dir) / run_date
day_dir = floor_root / run_date
output_dir = day_dir / settings.batch.output_subdir output_dir = day_dir / settings.batch.output_subdir
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
@@ -198,10 +193,7 @@ def run_tensor_batched_daily_batch(
break break
if frame_index % inference_stride == 0 or any(not last_batch_tracks[active_cams[i][0]] for i in current_active): if frame_index % inference_stride == 0 or any(not last_batch_tracks[active_cams[i][0]] for i in current_active):
current_stream_ids = [active_cams[idx][0] for idx in current_active] batch_tracks_list = shared_tracker.infer_batch(current_frames, crop_rects=current_crops)
batch_tracks_list = shared_tracker.infer_batch(
current_frames, stream_ids=current_stream_ids, crop_rects=current_crops
)
for i, idx in enumerate(current_active): for i, idx in enumerate(current_active):
cam_id = active_cams[idx][0] cam_id = active_cams[idx][0]
last_batch_tracks[cam_id] = batch_tracks_list[i] last_batch_tracks[cam_id] = batch_tracks_list[i]
@@ -307,8 +299,7 @@ def run_hybrid_daily_batch(
settings.batch.cycle_start_date = cycle_start_date settings.batch.cycle_start_date = cycle_start_date
run_date = date or date_type.today().isoformat() run_date = date or date_type.today().isoformat()
floor_root = resolve_floor_video_dir(settings.batch.root_dir, settings.batch.location) day_dir = Path(settings.batch.root_dir) / run_date
day_dir = floor_root / run_date
output_dir = day_dir / settings.batch.output_subdir output_dir = day_dir / settings.batch.output_subdir
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
@@ -464,10 +455,7 @@ def run_hybrid_daily_batch(
# 2. Batched GPU inference (Synchronous on main thread) # 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): if frame_index % inference_stride == 0 or any(not last_batch_tracks[active_cams[i][0]] for i in current_active_now):
current_stream_ids = [active_cams[idx][0] for idx in current_active_now] batch_tracks_list = shared_tracker.infer_batch(current_frames, crop_rects=current_crops)
batch_tracks_list = shared_tracker.infer_batch(
current_frames, stream_ids=current_stream_ids, crop_rects=current_crops
)
for i, idx in enumerate(current_active_now): for i, idx in enumerate(current_active_now):
cam_id = active_cams[idx][0] cam_id = active_cams[idx][0]
last_batch_tracks[cam_id] = batch_tracks_list[i] last_batch_tracks[cam_id] = batch_tracks_list[i]
@@ -595,8 +583,7 @@ def run_daily_batch(
) )
run_date = date or date_type.today().isoformat() run_date = date or date_type.today().isoformat()
floor_root = resolve_floor_video_dir(settings.batch.root_dir, settings.batch.location) day_dir = Path(settings.batch.root_dir) / run_date
day_dir = floor_root / run_date
output_dir = day_dir / settings.batch.output_subdir output_dir = day_dir / settings.batch.output_subdir
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
+5 -59
View File
@@ -5,15 +5,8 @@ from __future__ import annotations
import argparse import argparse
from chicken_counter.batch_runner import run_daily_batch from chicken_counter.batch_runner import run_daily_batch
from chicken_counter.config import load_batch_config, load_camera_config, load_or_synthesize_floor_config from chicken_counter.config import load_batch_config, load_camera_config
from chicken_counter.discovery import discover_farm_structure, sync_floor_configs from chicken_counter.mortality import DEFAULT_CONFIG_PATH, DEFAULT_MORTALITY_DIR, DEFAULT_MODEL_PATH, run_mortality_count
from chicken_counter.mortality import (
DEFAULT_CONFIG_PATH,
DEFAULT_MORTALITY_DIR,
DEFAULT_MODEL_PATH,
run_all_coop_mortality,
run_mortality_count,
)
from chicken_counter.pipeline import run_pipeline from chicken_counter.pipeline import run_pipeline
@@ -28,15 +21,7 @@ def build_parser() -> argparse.ArgumentParser:
run_parser.add_argument("--progress-bar", action="store_true", help="Show a terminal progress bar.") run_parser.add_argument("--progress-bar", action="store_true", help="Show a terminal progress bar.")
batch_parser = subparsers.add_parser("batch", help="Run the daily Cycle7 multi-camera batch.") batch_parser = subparsers.add_parser("batch", help="Run the daily Cycle7 multi-camera batch.")
batch_parser.add_argument( batch_parser.add_argument("--config", required=True, help="Path to batch config YAML/JSON.")
"--config",
"--floor",
"--location",
dest="config",
required=True,
help="Path to batch config YAML/JSON or floor identifier (e.g. K1-L1, K6-L2).",
)
batch_parser.add_argument("--base-config", default=None, help="Base config to extend when using floor identifier.")
batch_parser.add_argument( batch_parser.add_argument(
"--date", "--date",
help="Processing date folder in YYYY-MM-DD format. Defaults to today.", help="Processing date folder in YYYY-MM-DD format. Defaults to today.",
@@ -55,17 +40,9 @@ def build_parser() -> argparse.ArgumentParser:
) )
batch_parser.add_argument("--progress-bar", action="store_true", help="Show a terminal progress bar.") batch_parser.add_argument("--progress-bar", action="store_true", help="Show a terminal progress bar.")
sync_parser = subparsers.add_parser("sync-configs", help="Auto-generate missing configs/floor_config/*.yaml files from VIDEOS.")
sync_parser.add_argument("--videos-root", default=None, help="Base VIDEOS directory.")
sync_parser.add_argument("--configs-dir", default=None, help="Destination configs directory.")
sync_parser.add_argument("--dry-run", action="store_true", help="Preview generated configs without writing files.")
mortality_parser = subparsers.add_parser("mortality", help="Run whole-image mortality chicken detection on photo(s).") mortality_parser = 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("--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("--input", "-i", default=None, help="Path to image file or directory containing images.")
mortality_parser.add_argument("--coop", default=None, help="Specific coop/cage ID to process (e.g. K1, K2).")
mortality_parser.add_argument("--all-coops", action="store_true", help="Batch-process mortality for ALL discovered coops.")
mortality_parser.add_argument("--videos-root", default=None, help="Base VIDEOS directory (defaults to ../VIDEOS).")
mortality_parser.add_argument("--output-dir", "-o", default=None, help="Directory to save annotated images and report.") mortality_parser.add_argument("--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("--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("--conf", type=float, default=None, help="Confidence threshold (overrides YAML config).")
@@ -85,39 +62,9 @@ def main() -> None:
parser = build_parser() parser = build_parser()
args = parser.parse_args() args = parser.parse_args()
if args.command == "sync-configs":
sync_floor_configs(
videos_root=args.videos_root,
configs_dir=args.configs_dir,
dry_run=args.dry_run,
)
return
if args.command == "mortality": if args.command == "mortality":
if getattr(args, "all_coops", False):
run_all_coop_mortality(
videos_root=args.videos_root,
date=getattr(args, "date", None),
config_path=args.config,
conf_threshold=args.conf,
iou_threshold=args.iou,
model_path=args.model_path,
two_pass=args.two_pass,
)
return
input_path = args.input
target_coop = getattr(args, "coop", None)
if not input_path and target_coop:
farm_struct = discover_farm_structure(args.videos_root)
coop_info = farm_struct.get_coop(target_coop)
if coop_info and coop_info.mortality_dir and coop_info.mortality_dir.is_dir():
input_path = str(coop_info.mortality_dir)
else:
print(f"[mortality] ⚠️ Mortality directory for coop {target_coop} not found.")
run_mortality_count( run_mortality_count(
input_path=input_path, input_path=args.input,
output_dir=args.output_dir, output_dir=args.output_dir,
model_path=args.model_path, model_path=args.model_path,
conf_threshold=args.conf, conf_threshold=args.conf,
@@ -127,12 +74,11 @@ def main() -> None:
two_pass=args.two_pass, two_pass=args.two_pass,
config_path=args.config, config_path=args.config,
date=getattr(args, "date", None), date=getattr(args, "date", None),
coop=target_coop,
) )
return return
if args.command == "batch": if args.command == "batch":
settings = load_or_synthesize_floor_config(args.config, base_config_path=getattr(args, "base_config", None)) settings = load_batch_config(args.config)
if getattr(args, "output_subdir", None): if getattr(args, "output_subdir", None):
settings.batch.output_subdir = args.output_subdir settings.batch.output_subdir = args.output_subdir
if getattr(args, "cycle_start_date", None): if getattr(args, "cycle_start_date", None):
-35
View File
@@ -425,41 +425,6 @@ def load_batch_config(path: str | Path) -> BatchSettings:
) )
def load_or_synthesize_floor_config(
floor_or_path: str | Path,
base_config_path: str | Path | None = None,
) -> BatchSettings:
"""Load an existing floor config YAML or dynamically synthesize one from the base config."""
p = Path(floor_or_path)
if p.is_file():
return load_batch_config(p)
# Check if a matching config exists in configs/floor_config/
floor_str = str(floor_or_path).strip()
candidate_yaml = PROJECT_ROOT / "configs" / "floor_config" / f"{floor_str}.yaml"
if candidate_yaml.is_file():
return load_batch_config(candidate_yaml)
# Synthesize in-memory BatchSettings
base_cfg = Path(base_config_path).resolve() if base_config_path else (PROJECT_ROOT / "configs" / "cycle7_batch_optimized.yaml")
if not base_cfg.is_file():
raise FileNotFoundError(f"Base batch config not found: {base_cfg}")
base_settings = load_batch_config(base_cfg)
# Dynamic floor synthesis
from chicken_counter.discovery import get_coop_for_location
coop = get_coop_for_location(floor_str)
floor_sub = floor_str.replace(f"{coop}-", "").replace(f"{coop}_", "")
synth_root = PROJECT_ROOT.parent / "VIDEOS" / "cycle7" / coop / floor_sub
# Override location and root_dir
base_settings.batch.location = floor_str
base_settings.batch.root_dir = str(synth_root.resolve())
base_settings.config_path = f"[virtual:{floor_str}]"
return base_settings
def build_camera_config_from_batch( def build_camera_config_from_batch(
settings: BatchSettings, settings: BatchSettings,
camera_id: str, camera_id: str,
-284
View File
@@ -1,284 +0,0 @@
"""Discovery engine for flexible, dynamic coop and floor detection across poultry farm sites."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_VIDEOS_ROOT = (PROJECT_ROOT.parent / "VIDEOS").resolve()
DEFAULT_CONFIGS_DIR = (PROJECT_ROOT / "configs" / "floor_config").resolve()
# Patterns for coop & floor extraction
COOP_FLOOR_PATTERN = re.compile(
r"^(?P<coop>[Kk]\d+|kandang[a-zA-Z0-9_-]*?)[-_]?(?P<floor>[Ll]\d+|floor\d+)?$",
re.IGNORECASE,
)
FLOOR_SUBDIR_PATTERN = re.compile(r"^(?:[Ll]|floor)[-_]?(?P<num>\d+)$", re.IGNORECASE)
@dataclass
class CoopInfo:
"""Represents a detected coop/cage (e.g. K1, K2) with its constituent floors and mortality folder."""
coop_id: str
coop_dir: Path | None = None
floors: dict[str, Path] = field(default_factory=dict) # floor_id (e.g. "K1-L1" or "L1") -> path
mortality_dir: Path | None = None
@property
def covered_floors(self) -> list[str]:
"""Return list of canonical floor names (e.g. ['K1-L1', 'K1-L2', 'K1-L3'])."""
if not self.floors:
return [self.coop_id]
names = []
for fid in sorted(self.floors.keys()):
if fid.upper() == self.coop_id.upper() or fid.startswith(f"{self.coop_id}-"):
names.append(fid)
elif fid.upper().startswith("L") or fid.lower().startswith("floor"):
names.append(f"{self.coop_id}-{fid.upper()}")
else:
names.append(f"{self.coop_id}-{fid}")
return sorted(list(set(names)))
@dataclass
class FarmStructure:
"""Complete dynamic farm structure of coops and floors detected on this site."""
coops: dict[str, CoopInfo] = field(default_factory=dict)
videos_root: Path | None = None
def get_coop(self, coop_id: str) -> CoopInfo | None:
"""Get CoopInfo by coop_id (case-insensitive)."""
for cid, info in self.coops.items():
if cid.upper() == coop_id.upper():
return info
return None
def get_covered_floors(self, coop_id: str) -> list[str]:
"""Return all floor IDs associated with the given coop_id."""
coop = self.get_coop(coop_id)
if coop:
return coop.covered_floors
return [coop_id]
def all_mortality_dirs(self) -> dict[str, Path]:
"""Return mapping of coop_id -> mortality directory path for all coops with mortality folders."""
result: dict[str, Path] = {}
for coop_id, coop in self.coops.items():
if coop.mortality_dir and coop.mortality_dir.is_dir():
result[coop_id] = coop.mortality_dir
return result
def get_coop_for_location(location: str) -> str:
"""Extract parent coop ID from a floor or location string.
Examples:
'K1-L1' -> 'K1'
'K2_L3' -> 'K2'
'K3/L2' -> 'K3'
'kandang-atas' -> 'kandang-atas'
'K4' -> 'K4'
"""
loc = location.replace("\\", "/").strip()
if "/" in loc:
parts = loc.split("/")
return parts[0]
m = COOP_FLOOR_PATTERN.match(loc)
if m and m.group("coop"):
coop_name = m.group("coop")
# Normalize K1, k1 -> K1
if re.match(r"^[Kk]\d+$", coop_name):
return coop_name.upper()
return coop_name
if "-" in loc:
return loc.split("-")[0]
if "_" in loc:
return loc.split("_")[0]
return loc
def resolve_floor_video_dir(
root_dir: str | Path,
location: str | None = None,
) -> Path:
"""Resolve the video directory for a floor, checking hierarchical (K1/L1) and flat (K1-L1) paths."""
p = Path(root_dir)
if p.is_dir():
return p
# If not found directly, try smart fallback resolution
loc = location or p.name
coop = get_coop_for_location(loc)
floor_part = loc.replace(f"{coop}-", "").replace(f"{coop}_", "")
candidates = [
p,
p.parent / coop / floor_part,
p.parent / coop / f"{coop}-{floor_part}",
p.parent / f"{coop}-{floor_part}",
]
for cand in candidates:
if cand.is_dir():
return cand
# Default to original path if none exist yet
return p
def discover_farm_structure(
videos_root: Path | str | None = None,
configs_dir: Path | str | None = None,
) -> FarmStructure:
"""Dynamically discover all coops, floors, and mortality directories on the local machine.
Searches:
1. Hierarchical coop folders: `VIDEOS/<cycle>/<coop>/[L1..Ln, mortality]`
2. Legacy/flat floor folders: `VIDEOS/<cycle>/<coop>-<floor>/`
3. Config files: `configs/floor_config/<coop>-<floor>.yaml`
"""
v_root = Path(videos_root).resolve() if videos_root else DEFAULT_VIDEOS_ROOT
c_dir = Path(configs_dir).resolve() if configs_dir else DEFAULT_CONFIGS_DIR
coops: dict[str, CoopInfo] = {}
def _get_or_create_coop(cid: str, cdir: Path | None = None) -> CoopInfo:
# Canonical key
key = cid.upper() if re.match(r"^[Kk]\d+$", cid) else cid
if key not in coops:
coops[key] = CoopInfo(coop_id=key, coop_dir=cdir)
elif cdir and not coops[key].coop_dir:
coops[key].coop_dir = cdir
return coops[key]
# 1. Scan VIDEOS directory hierarchy
if v_root.is_dir():
search_roots = [v_root]
for sub in v_root.iterdir():
if sub.is_dir() and (sub.name.startswith("cycle") or sub.name.startswith("202")):
search_roots.append(sub)
for root in search_roots:
if not root.is_dir():
continue
for item in sorted(root.iterdir()):
if not item.is_dir() or item.name.startswith("."):
continue
# Case A: Hierarchical Coop Directory (e.g. K1, K2, kandang-atas)
coop_name = item.name
# Check if it has floor subfolders (L1, L2) or mortality folder
has_mortality = (item / "mortality").is_dir()
has_floors = False
for subitem in item.iterdir():
if not subitem.is_dir():
continue
if subitem.name == "mortality":
coop_obj = _get_or_create_coop(coop_name, item)
coop_obj.mortality_dir = subitem
elif FLOOR_SUBDIR_PATTERN.match(subitem.name) or re.match(r"^[Kk]\d+-[Ll]\d+$", subitem.name):
has_floors = True
coop_obj = _get_or_create_coop(coop_name, item)
floor_canonical = f"{coop_obj.coop_id}-{subitem.name.upper()}" if not subitem.name.startswith(coop_obj.coop_id) else subitem.name
coop_obj.floors[floor_canonical] = subitem
if has_mortality or has_floors:
_get_or_create_coop(coop_name, item)
# Case B: Flat floor directory (e.g. K1-L1, K2-L3)
m = COOP_FLOOR_PATTERN.match(item.name)
if m and m.group("coop") and m.group("floor"):
c_id = m.group("coop")
coop_obj = _get_or_create_coop(c_id)
floor_name = item.name.upper() if re.match(r"^[Kk]\d+-[Ll]\d+$", item.name) else item.name
if floor_name not in coop_obj.floors:
coop_obj.floors[floor_name] = item
# 2. Augment with configured floor YAMLs in configs/floor_config/
if c_dir.is_dir():
for cfg_file in sorted(c_dir.glob("*.yaml")):
stem = cfg_file.stem
coop_id = get_coop_for_location(stem)
coop_obj = _get_or_create_coop(coop_id)
if stem not in coop_obj.floors:
# Store placeholder floor reference
coop_obj.floors[stem] = cfg_file
# 3. Auto-populate mortality_dir if present in coop_dir
for coop_obj in coops.values():
if not coop_obj.mortality_dir and coop_obj.coop_dir:
mort_cand = coop_obj.coop_dir / "mortality"
if mort_cand.is_dir():
coop_obj.mortality_dir = mort_cand
return FarmStructure(coops=coops, videos_root=v_root)
def discover_all_mortality_dirs(videos_root: Path | str | None = None) -> dict[str, Path]:
"""Find all mortality directories grouped by coop_id."""
structure = discover_farm_structure(videos_root)
return structure.all_mortality_dirs()
def sync_floor_configs(
videos_root: Path | str | None = None,
configs_dir: Path | str | None = None,
base_config_rel: str = "../cycle7_batch_optimized.yaml",
dry_run: bool = False,
) -> list[Path]:
"""Scan VIDEOS for all discovered floors and auto-create missing configs/floor_config/*.yaml files."""
v_root = Path(videos_root).resolve() if videos_root else DEFAULT_VIDEOS_ROOT
c_dir = Path(configs_dir).resolve() if configs_dir else DEFAULT_CONFIGS_DIR
c_dir.mkdir(parents=True, exist_ok=True)
structure = discover_farm_structure(videos_root=v_root, configs_dir=c_dir)
created: list[Path] = []
for coop_id, coop_info in sorted(structure.coops.items()):
for floor_name in coop_info.covered_floors:
yaml_path = c_dir / f"{floor_name}.yaml"
if yaml_path.exists():
continue
coop = get_coop_for_location(floor_name)
floor_sub = floor_name.replace(f"{coop}-", "").replace(f"{coop}_", "")
content = f"""# Floor Configuration for {floor_name}
# Inherits all default settings, tracker options, model path, and stage thresholds from cycle7_batch_optimized.yaml
extends: {base_config_rel}
batch:
location: "{floor_name}"
root_dir: "../VIDEOS/cycle7/{coop}/{floor_sub}"
# Optional per-camera overrides (only uncomment and specify if this floor requires custom ROI or anchors)
# cameras:
# CC1:
# roi:
# points:
# - [250, 330]
# - [1650, 330]
# - [1650, 720]
# - [250, 720]
"""
if not dry_run:
yaml_path.write_text(content, encoding="utf-8")
print(f"[sync-configs] 📄 Generated missing floor config: {yaml_path.name}")
else:
print(f"[sync-configs] (dry-run) Would generate: {yaml_path.name}")
created.append(yaml_path)
if not created:
print("[sync-configs] ✅ All discovered floors already have configuration YAMLs.")
else:
print(f"[sync-configs] ✨ Synchronized {len(created)} floor config(s).")
return created
+1 -146
View File
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import json import json
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -12,8 +11,6 @@ import numpy as np
import yaml import yaml
from ultralytics import YOLO from ultralytics import YOLO
from chicken_counter.discovery import discover_farm_structure, get_coop_for_location
PROJECT_ROOT = Path(__file__).resolve().parents[2] 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_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_MORTALITY_DIR = str((PROJECT_ROOT.parent / "VIDEOS" / "cycle7" / "kandang-atas" / "mortality").resolve())
@@ -41,60 +38,6 @@ def load_mortality_config(config_path: str | Path) -> dict:
return data.get("mortality", data) return data.get("mortality", data)
def get_camera_source_for_coop(coop_id: str, config_path: str | Path | None = None) -> str | int | None:
"""Retrieve configured stationary camera source (RTSP URL, device index, or snapshot URL) for a coop."""
cfg = load_mortality_config(config_path or DEFAULT_CONFIG_PATH)
cameras = cfg.get("cameras", {})
if not cameras:
return None
if coop_id in cameras:
return cameras[coop_id]
for k, v in cameras.items():
if str(k).upper() == str(coop_id).upper():
return v
if "default" in cameras:
default_tmpl = str(cameras["default"])
num_m = re.search(r"\d+", coop_id)
coop_num = num_m.group(0) if num_m else "1"
return default_tmpl.format(coop=coop_id, coop_num=coop_num)
return None
def capture_camera_snapshot(
source: str | int,
timeout_sec: float = 5.0,
warmup_frames: int = 5,
) -> np.ndarray | None:
"""Capture a single clear still frame from a stationary RTSP stream, HTTP snapshot URL, or video device."""
if isinstance(source, str) and source.startswith(("http://", "https://")) and (source.endswith((".jpg", ".jpeg", ".png", "snapshot", "image")) or "snapshot" in source.lower()):
try:
import urllib.request
req = urllib.request.Request(source, headers={"User-Agent": "ChickenCounter/1.0"})
with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
arr = np.asarray(bytearray(resp.read()), dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if img is not None:
return img
except Exception as err:
print(f"[mortality] ⚠️ HTTP snapshot capture failed from {source}: {err}")
src_val = int(source) if isinstance(source, str) and source.isdigit() else source
cap = cv2.VideoCapture(src_val)
if not cap.isOpened():
print(f"[mortality] ⚠️ Failed to open camera source: {source}")
return None
try:
frame = None
for _ in range(max(1, warmup_frames)):
ret, tmp = cap.read()
if ret and tmp is not None:
frame = tmp
return frame
finally:
cap.release()
def deduplicate_boxes( def deduplicate_boxes(
boxes_list: list[dict], boxes_list: list[dict],
dedupe_radius_px: float = 30.0, dedupe_radius_px: float = 30.0,
@@ -303,7 +246,6 @@ def run_mortality_count(
imgsz: int | None = None, imgsz: int | None = None,
config_path: str | Path | None = DEFAULT_CONFIG_PATH, config_path: str | Path | None = DEFAULT_CONFIG_PATH,
date: str | None = None, date: str | None = None,
coop: str | None = None,
) -> dict: ) -> dict:
config_path = resolve_path(config_path) if config_path else DEFAULT_CONFIG_PATH config_path = resolve_path(config_path) if config_path else DEFAULT_CONFIG_PATH
cfg = load_mortality_config(config_path) if config_path else {} cfg = load_mortality_config(config_path) if config_path else {}
@@ -351,21 +293,7 @@ def run_mortality_count(
print(f"[mortality] No input images found at: {input_path}") print(f"[mortality] No input images found at: {input_path}")
return {"total_images": 0, "results": []} return {"total_images": 0, "results": []}
# Resolve parent coop and covered floors
inferred_coop = coop
if not inferred_coop:
for part in reversed(input_p.parts):
if part.lower() != "mortality" and not re.match(r"^\d{4}-\d{2}-\d{2}$", part):
inferred_coop = get_coop_for_location(part)
break
inferred_coop = inferred_coop or "kandang-atas"
farm_struct = discover_farm_structure()
covered_floors = farm_struct.get_covered_floors(inferred_coop)
covered_str = ", ".join(covered_floors)
mode_str = "2x Similarity & Adaptive Accuracy Refinement" if two_pass else "Single-Pass Direct" mode_str = "2x Similarity & Adaptive Accuracy Refinement" if two_pass else "Single-Pass Direct"
print(f"[mortality] Coop: {inferred_coop} (Covered Floors: {covered_str})")
print(f"[mortality] Mode: {mode_str}") print(f"[mortality] Mode: {mode_str}")
print(f"[mortality] Loading model from: {model_path} (device: {device})") 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") 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")
@@ -507,9 +435,6 @@ def run_mortality_count(
report_path = target_out_dir / "mortality_report.json" report_path = target_out_dir / "mortality_report.json"
report_data = { report_data = {
"date": date or datetime.now().strftime("%Y-%m-%d"), "date": date or datetime.now().strftime("%Y-%m-%d"),
"coop": inferred_coop,
"location": inferred_coop,
"covered_floors": covered_floors,
"generated_at": datetime.now(timezone.utc).isoformat(), "generated_at": datetime.now(timezone.utc).isoformat(),
"mode": "similarity_two_pass" if two_pass else "single_pass", "mode": "similarity_two_pass" if two_pass else "single_pass",
"config_file": str(config_path) if config_path else None, "config_file": str(config_path) if config_path else None,
@@ -527,75 +452,5 @@ def run_mortality_count(
with open(report_path, "w") as f: with open(report_path, "w") as f:
json.dump(report_data, f, indent=2) json.dump(report_data, f, indent=2)
print(f"[mortality] [{inferred_coop}] Summary report saved to: {report_path} (Total Carcasses: {total_mortality_count} across {len(image_files)} images)") print(f"[mortality] Summary report saved to: {report_path} (Total Carcasses: {total_mortality_count} across {len(image_files)} images)")
return report_data return report_data
def run_all_coop_mortality(
videos_root: str | Path | None = None,
date: str | None = None,
config_path: str | Path | None = DEFAULT_CONFIG_PATH,
conf_threshold: float | None = None,
iou_threshold: float | None = None,
model_path: str | Path | None = None,
device: str | int | None = None,
two_pass: bool | None = None,
) -> dict[str, dict]:
"""Batch-process mortality detection across all discovered coop mortality folders."""
farm_struct = discover_farm_structure(videos_root)
all_m_dirs = farm_struct.all_mortality_dirs()
if not all_m_dirs:
print(f"[mortality-batch] ⚠️ No coop mortality directories found under {farm_struct.videos_root}")
return {}
print(f"\n=================================================================")
print(f" Starting Batch Mortality Detection across {len(all_m_dirs)} Coops")
print(f" Discovered Coops: {', '.join(sorted(all_m_dirs.keys()))}")
if date:
print(f" Target Date: {date}")
print(f"=================================================================\n")
summary: dict[str, dict] = {}
total_carcasses_all = 0
total_images_all = 0
for coop_id in sorted(all_m_dirs.keys()):
mdir = all_m_dirs[coop_id]
covered = farm_struct.get_covered_floors(coop_id)
covered_str = ", ".join(covered)
print(f"\n>>> [MORTALITY] Processing Coop {coop_id} (Floors: {covered_str}) <<<")
print(f" Directory: {mdir}")
try:
res = run_mortality_count(
input_path=mdir,
config_path=config_path,
coop=coop_id,
date=date,
conf_threshold=conf_threshold,
iou_threshold=iou_threshold,
model_path=model_path,
device=device,
two_pass=two_pass,
)
summary[coop_id] = res
c_count = res.get("total_mortality_count", 0)
i_count = res.get("total_images", 0)
total_carcasses_all += c_count
total_images_all += i_count
print(f" ✅ Coop {coop_id} Done: {c_count} carcasses across {i_count} photos.")
except Exception as err:
print(f" ❌ Coop {coop_id} Error: {err}")
summary[coop_id] = {"error": str(err), "coop": coop_id}
print(f"\n=================================================================")
print(f" Batch Mortality Summary:")
for coop_id, res in summary.items():
if "error" in res:
print(f" {coop_id}: ERROR ({res['error']})")
else:
print(f" {coop_id} (Floors: {', '.join(farm_struct.get_covered_floors(coop_id))}): {res.get('total_mortality_count', 0)} carcasses ({res.get('total_images', 0)} images)")
print(f" TOTAL SITE MORTALITY: {total_carcasses_all} carcasses across {total_images_all} images")
print(f"=================================================================\n")
return summary
+139 -87
View File
@@ -1,16 +1,12 @@
"""Run YOLO detection and BoT-SORT tracking on each frame with per-camera tracker isolation.""" """Run YOLO detection and BoT-SORT tracking on each frame."""
from __future__ import annotations from __future__ import annotations
import time import time
from pathlib import Path from pathlib import Path
from typing import Any
import numpy as np import numpy as np
import yaml
from ultralytics import YOLO from ultralytics import YOLO
from ultralytics.trackers.track import TRACKER_MAP
from ultralytics.utils import IterableSimpleNamespace
from chicken_counter.config import CameraConfig from chicken_counter.config import CameraConfig
from chicken_counter.engine_utils import ensure_compatible_model from chicken_counter.engine_utils import ensure_compatible_model
@@ -36,10 +32,6 @@ class DetectionTracker:
self.tracker_config_path = str(Path(config.tracker.tracker_config_path)) self.tracker_config_path = str(Path(config.tracker.tracker_config_path))
self.verbose = config.performance.verbose self.verbose = config.performance.verbose
self._infer_count = 0 self._infer_count = 0
self._stream_trackers: dict[str, Any] = {}
self._tracker_cfg_obj: IterableSimpleNamespace | None = None
self._init_tracker_cfg()
print( print(
f"[model] loaded {self.model_kind} from {model_path} " f"[model] loaded {self.model_kind} from {model_path} "
f"(imgsz={config.detection.imgsz}, device={config.detection.device})" f"(imgsz={config.detection.imgsz}, device={config.detection.device})"
@@ -47,49 +39,7 @@ class DetectionTracker:
if self.model_kind == "engine": if self.model_kind == "engine":
print("[model] TensorRT engine active; runtime half flag is ignored") print("[model] TensorRT engine active; runtime half flag is ignored")
def _init_tracker_cfg(self) -> None: def reset_tracking(self) -> None:
try:
tracker_file = Path(self.tracker_config_path)
if tracker_file.exists():
with open(tracker_file, "r", encoding="utf-8") as f:
raw_cfg = yaml.safe_load(f) or {}
self._tracker_cfg_obj = IterableSimpleNamespace(**raw_cfg)
self._tracker_cfg_obj.device = self.config.detection.device
except Exception as exc:
if self.verbose:
print(f"[tracker] error reading tracker config: {exc}")
self._tracker_cfg_obj = None
def get_or_create_tracker(self, stream_id: str = "default") -> Any:
if stream_id not in self._stream_trackers:
if self._tracker_cfg_obj is not None and self._tracker_cfg_obj.tracker_type in TRACKER_MAP:
tracker_cls = TRACKER_MAP[self._tracker_cfg_obj.tracker_type]
self._stream_trackers[stream_id] = tracker_cls(args=self._tracker_cfg_obj)
else:
default_args = IterableSimpleNamespace(
tracker_type="botsort",
track_high_thresh=0.5,
track_low_thresh=0.1,
new_track_thresh=0.6,
track_buffer=self.config.tracker.track_buffer,
match_thresh=0.8,
fuse_score=True,
gmc_method="none",
proximity_thresh=0.5,
appearance_thresh=0.25,
with_reid=False,
model="auto",
device=self.config.detection.device,
)
self._stream_trackers[stream_id] = TRACKER_MAP["botsort"](args=default_args)
return self._stream_trackers[stream_id]
def reset_tracking(self, stream_id: str | None = None) -> None:
if stream_id:
if stream_id in self._stream_trackers:
del self._stream_trackers[stream_id]
else:
self._stream_trackers.clear()
if hasattr(self.model, "predictor"): if hasattr(self.model, "predictor"):
self.model.predictor = None self.model.predictor = None
@@ -97,25 +47,119 @@ class DetectionTracker:
self, self,
frame: np.ndarray, frame: np.ndarray,
*, *,
stream_id: str = "default",
crop_rect: tuple[int, int, int, int] | None = None, crop_rect: tuple[int, int, int, int] | None = None,
) -> list[TrackObservation]: ) -> list[TrackObservation]:
results = self.infer_batch([frame], stream_ids=[stream_id], crop_rects=[crop_rect]) offset_x = 0
return results[0] if results else [] offset_y = 0
source = frame
if crop_rect is not None:
x1, y1, x2, y2 = crop_rect
source = frame[y1:y2, x1:x2]
offset_x, offset_y = x1, y1
track_kwargs: dict = {
"source": source,
"persist": self.config.tracker.persist,
"tracker": self.tracker_config_path,
"conf": self.config.detection.conf,
"iou": self.config.detection.iou,
"classes": self.config.detection.classes,
"imgsz": self.config.detection.imgsz,
"verbose": False,
"device": self.config.detection.device,
}
if self.model_kind != "engine" and self.config.performance.half:
track_kwargs["half"] = True
if self.verbose:
t_start = time.monotonic()
results = self.model.track(**track_kwargs)
if self.verbose:
t_track = time.monotonic()
self._infer_count += 1
if not results:
if self.verbose:
print(f"[tracker #{self._infer_count}] no detections (infer={t_track - t_start:.1f}ms)")
return []
result = results[0]
boxes = result.boxes
if boxes is None or boxes.id is None:
return []
ids = boxes.id.int().cpu().numpy()
classes = boxes.cls.int().cpu().numpy()
confidences = boxes.conf.cpu().numpy()
xyxy = boxes.xyxy.int().cpu().numpy()
mask_polygons = None
if result.masks is not None and result.masks.xy is not None:
mask_polygons = result.masks.xy
if len(mask_polygons) != len(boxes):
raise RuntimeError(
f"Ultralytics box/mask count mismatch: {len(boxes)} boxes, "
f"{len(mask_polygons)} masks"
)
tracks: list[TrackObservation] = []
for index in range(len(boxes)):
track_id = int(ids[index])
class_id = int(classes[index])
confidence = float(confidences[index])
bbox = xyxy[index]
x1 = int(bbox[0]) + offset_x
y1 = int(bbox[1]) + offset_y
x2 = int(bbox[2]) + offset_x
y2 = int(bbox[3]) + offset_y
centroid = ((x1 + x2) // 2, (y1 + y2) // 2)
if crop_rect is not None and not self._centroid_in_rect(centroid, crop_rect):
continue
polygon = None
if mask_polygons is not None:
poly = np.asarray(mask_polygons[index], dtype=np.float64).copy()
if poly.ndim == 2 and poly.shape[0] >= 3:
poly[:, 0] += offset_x
poly[:, 1] += offset_y
polygon = poly
tracks.append(
TrackObservation(
track_id=track_id,
class_id=class_id,
confidence=confidence,
bbox_xyxy=(x1, y1, x2, y2),
centroid=centroid,
mask_polygon_xy=polygon,
)
)
if self.verbose:
unique_ids = sorted(set(t.track_id for t in tracks))
confs = [t.confidence for t in tracks] if tracks else [0]
print(
f"[tracker #{self._infer_count}] "
f"det={len(tracks)} unique={len(unique_ids)} "
f"conf=[{min(confs):.2f}..{max(confs):.2f}] "
f"ids={unique_ids[:10]}{'+' if len(unique_ids) > 10 else ''} "
f"infer={t_track - t_start:.1f}ms"
)
return tracks
def infer_batch( def infer_batch(
self, self,
frames: list[np.ndarray], frames: list[np.ndarray],
*, *,
stream_ids: list[str] | None = None,
crop_rects: list[tuple[int, int, int, int] | None] | None = None, crop_rects: list[tuple[int, int, int, int] | None] | None = None,
) -> list[list[TrackObservation]]: ) -> list[list[TrackObservation]]:
if not frames: if not frames:
return [] return []
if stream_ids is None:
stream_ids = [f"cam_{i}" for i in range(len(frames))]
sources = [] sources = []
offsets = [] offsets = []
for i, frame in enumerate(frames): for i, frame in enumerate(frames):
@@ -128,7 +172,10 @@ class DetectionTracker:
sources.append(frame) sources.append(frame)
offsets.append((0, 0)) offsets.append((0, 0))
predict_kwargs: dict = { track_kwargs: dict = {
"source": sources,
"persist": self.config.tracker.persist,
"tracker": self.tracker_config_path,
"conf": self.config.detection.conf, "conf": self.config.detection.conf,
"iou": self.config.detection.iou, "iou": self.config.detection.iou,
"classes": self.config.detection.classes, "classes": self.config.detection.classes,
@@ -137,19 +184,19 @@ class DetectionTracker:
"device": self.config.detection.device, "device": self.config.detection.device,
} }
if self.model_kind != "engine" and self.config.performance.half: if self.model_kind != "engine" and self.config.performance.half:
predict_kwargs["half"] = True track_kwargs["half"] = True
if self.verbose: if self.verbose:
t_start = time.monotonic() t_start = time.monotonic()
try: try:
results = self.model.predict(source=sources, **predict_kwargs) results = self.model.track(**track_kwargs)
except Exception: except Exception:
results = [] results = []
for src in sources: for src in sources:
kw = dict(predict_kwargs) kw = dict(track_kwargs)
kw["source"] = src kw["source"] = src
res = self.model.predict(**kw) res = self.model.track(**kw)
if res: if res:
results.append(res[0]) results.append(res[0])
@@ -159,48 +206,53 @@ class DetectionTracker:
batch_tracks: list[list[TrackObservation]] = [] batch_tracks: list[list[TrackObservation]] = []
for idx, result in enumerate(results): for idx, result in enumerate(results):
stream_id = stream_ids[idx] if idx < len(stream_ids) else f"stream_{idx}"
tracker = self.get_or_create_tracker(stream_id)
orig_src = sources[idx]
offset_x, offset_y = offsets[idx] offset_x, offset_y = offsets[idx]
crop_rect = crop_rects[idx] if crop_rects and idx < len(crop_rects) else None crop_rect = crop_rects[idx] if crop_rects and idx < len(crop_rects) else None
boxes = result.boxes boxes = result.boxes
if boxes is None or len(boxes) == 0: if boxes is None or boxes.id is None:
tracker.update(np.empty((0, 6)), orig_src)
batch_tracks.append([]) batch_tracks.append([])
continue continue
det_np = boxes.cpu().numpy() ids = boxes.id.int().cpu().numpy()
tracks_raw = tracker.update(det_np, orig_src) classes = boxes.cls.int().cpu().numpy()
confidences = boxes.conf.cpu().numpy()
xyxy = boxes.xyxy.int().cpu().numpy()
if len(tracks_raw) == 0: mask_polygons = None
batch_tracks.append([]) if result.masks is not None and result.masks.xy is not None:
continue mask_polygons = result.masks.xy
tracks: list[TrackObservation] = [] tracks: list[TrackObservation] = []
for row in tracks_raw: for index in range(len(boxes)):
# Format: [x1, y1, x2, y2, track_id, conf, cls, idx] track_id = int(ids[index])
x1 = int(row[0]) + offset_x class_id = int(classes[index])
y1 = int(row[1]) + offset_y confidence = float(confidences[index])
x2 = int(row[2]) + offset_x bbox = xyxy[index]
y2 = int(row[3]) + offset_y x1 = int(bbox[0]) + offset_x
track_id = int(row[4]) y1 = int(bbox[1]) + offset_y
conf = float(row[5]) if len(row) > 5 else float(self.config.detection.conf) x2 = int(bbox[2]) + offset_x
cls_id = int(row[6]) if len(row) > 6 else 0 y2 = int(bbox[3]) + offset_y
centroid = ((x1 + x2) // 2, (y1 + y2) // 2) centroid = ((x1 + x2) // 2, (y1 + y2) // 2)
if crop_rect is not None and not self._centroid_in_rect(centroid, crop_rect): if crop_rect is not None and not self._centroid_in_rect(centroid, crop_rect):
continue 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( tracks.append(
TrackObservation( TrackObservation(
track_id=track_id, track_id=track_id,
class_id=cls_id, class_id=class_id,
confidence=conf, confidence=confidence,
bbox_xyxy=(x1, y1, x2, y2), bbox_xyxy=(x1, y1, x2, y2),
centroid=centroid, centroid=centroid,
mask_polygon_xy=None, mask_polygon_xy=polygon,
) )
) )
batch_tracks.append(tracks) batch_tracks.append(tracks)
+1 -1
View File
@@ -23,7 +23,7 @@ CMD=("$PYTHON" "$SCRIPT_DIR/dashboard.py" --port "$PORT" --db "$DB_PATH")
VIDEOS_BASE="$SCRIPT_DIR/../VIDEOS" VIDEOS_BASE="$SCRIPT_DIR/../VIDEOS"
if [ ! -d "$VIDEOS_BASE" ]; then if [ ! -d "$VIDEOS_BASE" ]; then
echo "[dashboard] 📁 Sibling directory '$VIDEOS_BASE' not found; creating skeleton directory..." echo "[dashboard] 📁 Sibling directory '$VIDEOS_BASE' not found; creating skeleton directory..."
mkdir -p "$VIDEOS_BASE/cycle7/K1/mortality" "$VIDEOS_BASE/cycle7/K2/mortality" "$VIDEOS_BASE/cycle7/K3/mortality" "$VIDEOS_BASE/cycle7/K4/mortality" "$VIDEOS_BASE/cycle7/K5/mortality" mkdir -p "$VIDEOS_BASE/cycle7"
fi fi
# Auto-discover mortality directories (any folder containing mortality_report.json or mortality folders) # Auto-discover mortality directories (any folder containing mortality_report.json or mortality folders)
+2 -5
View File
@@ -33,10 +33,7 @@ CREATE TABLE IF NOT EXISTS batch_runs (
source_video TEXT NOT NULL DEFAULT '', source_video TEXT NOT NULL DEFAULT '',
generated_at TEXT NOT NULL DEFAULT '', generated_at TEXT NOT NULL DEFAULT '',
UNIQUE(date, location, camera_id) UNIQUE(date, location, camera_id)
); )
CREATE INDEX IF NOT EXISTS idx_batch_date ON batch_runs (date);
CREATE INDEX IF NOT EXISTS idx_batch_camera ON batch_runs (camera_id);
CREATE INDEX IF NOT EXISTS idx_batch_location ON batch_runs (location);
""" """
INSERT_SQL = """ INSERT_SQL = """
@@ -78,7 +75,7 @@ def store_report(report_path: str, location: str, db_path: str) -> None:
Path(db_path).parent.mkdir(parents=True, exist_ok=True) Path(db_path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.executescript(CREATE_TABLE) conn.execute(CREATE_TABLE)
rows = 0 rows = 0
for camera_id, entry in cameras.items(): for camera_id, entry in cameras.items():
+10 -406
View File
@@ -39,10 +39,6 @@ body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:h
/* Mortality UI */ /* 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-badge{background:#2a1518;color:#ff6b81;border:1px solid #ff4757;font-weight:700;padding:2px 6px;border-radius:4px;font-size:10px}
.mort-filter-btn{background:#0e0e14;border:1px solid #333;color:#888;border-radius:4px;padding:2px 6px;font-size:9px;cursor:pointer;font-weight:600;transition:all .15s}
.mort-filter-btn:hover{color:#fff;border-color:#555}
.mort-filter-btn.active{background:#ff4757;color:#fff;border-color:#ff4757}
.mort-coop-tag{display:inline-block;background:#33141a;color:#ff6b81;padding:1px 4px;border-radius:3px;font-size:9px;font-weight:700;margin-right:3px}
.mort-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:6px} .mort-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{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:hover{border-color:#ff4757;transform:translateY(-1px)}
@@ -50,42 +46,6 @@ body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:h
.mort-card .info{padding:4px 6px;display:flex;justify-content:space-between;align-items:center;font-size:10px;color:#888} .mort-card .info{padding:4px 6px;display:flex;justify-content:space-between;align-items:center;font-size:10px;color:#888}
.mort-card .info .cnt{color:#ff9f43;font-weight:700} .mort-card .info .cnt{color:#ff9f43;font-weight:700}
.mort-upload-btn{background:#2a1518;color:#ff6b81;border:1px solid #ff4757;font-weight:700;padding:2px 8px;border-radius:4px;font-size:10px;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:3px}
.mort-upload-btn:hover{background:#ff4757;color:#fff}
/* Upload & Capture Modal */
#mort-upload-modal{display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,.85);z-index:1000;align-items:center;justify-content:center;backdrop-filter:blur(4px)}
#mort-upload-modal.active{display:flex}
.mort-upload-box{background:#16161e;border:1px solid #333;border-radius:10px;width:90%;max-width:480px;padding:18px;box-shadow:0 8px 32px rgba(0,0,0,.8);position:relative;color:#ddd}
.mort-upload-box h2{font-size:15px;margin:0 0 10px 0;color:#fff;display:flex;align-items:center;gap:6px}
.mort-tab-row{display:flex;gap:6px;margin-bottom:12px;border-bottom:1px solid #282834;padding-bottom:8px}
.mort-tab-btn{background:none;border:none;color:#888;font-size:11px;font-weight:600;cursor:pointer;padding:4px 10px;border-radius:4px;transition:all .15s}
.mort-tab-btn:hover{color:#fff}
.mort-tab-btn.active{background:#2a1518;color:#ff6b81;border:1px solid #ff4757}
.mort-cam-preview-box{background:#0e0e14;border:1px solid #333;border-radius:6px;overflow:hidden;position:relative;height:180px;display:flex;align-items:center;justify-content:center;margin-bottom:10px}
.mort-cam-preview-box img{width:100%;height:100%;object-fit:contain;background:#000}
.mort-cam-preview-overlay{position:absolute;bottom:6px;right:8px;background:rgba(0,0,0,.75);padding:2px 8px;border-radius:4px;font-size:10px;color:#2ed573;border:1px solid #333}
.mort-capture-btn{width:100%;padding:10px;background:#ff4757;color:#fff;border:none;border-radius:6px;font-weight:700;font-size:13px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px;transition:background .15s}
.mort-capture-btn:hover{background:#ff6b81}
.mort-capture-btn:disabled{background:#333;color:#777;cursor:not-allowed}
.mort-upload-row{display:flex;gap:10px;margin-bottom:12px}
.mort-upload-field{flex:1}
.mort-upload-field label{display:block;font-size:10px;color:#888;margin-bottom:4px;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
.mort-upload-field select, .mort-upload-field input[type="date"]{width:100%;padding:6px 8px;background:#0e0e14;border:1px solid #333;border-radius:6px;color:#fff;font-size:11px;outline:none;box-sizing:border-box}
.mort-dropzone{border:2px dashed #444;border-radius:8px;padding:18px 12px;text-align:center;background:#0e0e14;cursor:pointer;transition:border-color .15s, background .15s}
.mort-dropzone:hover, .mort-dropzone.dragover{border-color:#ff4757;background:#1b1419}
.mort-dropzone-icon{font-size:26px;margin-bottom:4px;display:block}
.mort-dropzone-txt{font-size:12px;color:#aaa}
.mort-dropzone-sub{font-size:10px;color:#666;margin-top:2px}
.mort-preview-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(70px, 1fr));gap:6px;margin-top:10px;max-height:140px;overflow-y:auto}
.mort-preview-item{position:relative;border-radius:4px;overflow:hidden;border:1px solid #333;height:65px}
.mort-preview-item img{width:100%;height:100%;object-fit:cover}
.mort-preview-item .rm-btn{position:absolute;top:2px;right:2px;background:rgba(0,0,0,.7);color:#ff4757;border:none;border-radius:50%;width:16px;height:16px;font-size:10px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1}
.mort-submit-btn{width:100%;padding:9px;background:#ff4757;color:#fff;border:none;border-radius:6px;font-weight:700;font-size:12px;cursor:pointer;margin-top:12px;transition:background .15s}
.mort-submit-btn:hover{background:#ff6b81}
.mort-submit-btn:disabled{background:#333;color:#777;cursor:not-allowed}
.mort-upload-status{margin-top:10px;font-size:11px;text-align:center;display:none}
/* Fullscreen Image Modal */ /* 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{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.active{display:flex}
@@ -119,15 +79,11 @@ body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:h
<div class="cam-list" id="cam-list"></div> <div class="cam-list" id="cam-list"></div>
<!-- Mortality / Carcass Detection Panel --> <!-- Mortality / Carcass Detection Panel -->
<div class="mort-panel" id="mort-panel"> <div class="mort-panel" id="mort-panel" style="display:none">
<h3> <h3>
<span>&#x2620;&#xfe0f; Mortality Scans</span> <span>&#x2620;&#xfe0f; Mortality Scans</span>
<span style="display:flex;gap:5px;align-items:center">
<button class="mort-upload-btn" onclick="openMortUploadModal()">&#x1f4f7; Upload / Capture</button>
<span class="mort-badge" id="mort-header-badge">0</span> <span class="mort-badge" id="mort-header-badge">0</span>
</span>
</h3> </h3>
<div id="mort-coop-filters" style="display:flex;gap:4px;flex-wrap:wrap;margin:4px 0 6px 0"></div>
<div id="mort-summary-text" style="color:#777;font-size:10px;margin-bottom:4px"></div> <div id="mort-summary-text" style="color:#777;font-size:10px;margin-bottom:4px"></div>
<div class="mort-grid" id="mort-grid"></div> <div class="mort-grid" id="mort-grid"></div>
</div> </div>
@@ -138,71 +94,6 @@ body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:h
</div> </div>
</div> </div>
<!-- Modal for Capturing Mortality Photos from Stationary Camera or Uploading -->
<div id="mort-upload-modal" onclick="closeMortUploadModal(event)">
<div class="mort-upload-box" onclick="event.stopPropagation()">
<button class="close-btn" onclick="closeMortUploadModal(event)" style="top:10px;right:14px;font-size:24px">&times;</button>
<h2>&#x1f4f8; Mortality Inspection Camera</h2>
<div class="mort-tab-row">
<button class="mort-tab-btn active" id="tab-btn-cam" onclick="switchMortModalTab('cam')">&#x1f4f7; Stationary Camera</button>
<button class="mort-tab-btn" id="tab-btn-file" onclick="switchMortModalTab('file')">&#x1f4c1; File Upload</button>
</div>
<div class="mort-upload-row">
<div class="mort-upload-field">
<label for="mort-upload-coop">Target Coop / Cage</label>
<select id="mort-upload-coop" onchange="onMortCoopChanged()">
<option value="K1">K1 (L1, L2, L3)</option>
<option value="K2">K2 (L1, L2, L3)</option>
<option value="K3">K3 (L1, L2, L3)</option>
<option value="K4">K4 (L1, L2)</option>
<option value="K5">K5 (L1, L2)</option>
<option value="kandang-atas">kandang-atas</option>
</select>
</div>
<div class="mort-upload-field">
<label for="mort-upload-date">Date</label>
<input type="date" id="mort-upload-date">
</div>
</div>
<!-- Tab 1: Stationary Camera Capture (Default) -->
<div id="mort-cam-tab-content">
<div class="mort-cam-preview-box" id="mort-cam-preview-box">
<img id="mort-cam-live-img" src="" alt="Stationary Camera View" onerror="this.style.display='none';document.getElementById('mort-cam-offline-msg').style.display='block'">
<div id="mort-cam-offline-msg" style="display:none;color:#777;font-size:11px;text-align:center;padding:12px">
&#x1f4f7; Stationary Camera Preview<br><span style="color:#555;font-size:9px">Click Capture to snap high-res photo</span>
</div>
<div class="mort-cam-preview-overlay" id="mort-cam-status-overlay">&#x1f534; Camera Ready</div>
</div>
<button class="mort-capture-btn" id="mort-capture-btn" onclick="captureFromStationaryCamera()">
&#x1f4f8; Snap &amp; Count Batch from Camera
</button>
<div style="font-size:10px;color:#666;text-align:center;margin-top:6px">
Place batch of chickens on table &rarr; click Snap &rarr; clear table &rarr; repeat if needed
</div>
</div>
<!-- Tab 2: Manual File Upload -->
<div id="mort-file-tab-content" style="display:none">
<input type="file" id="mort-file-input" accept="image/*" multiple style="display:none" onchange="handleMortFilesSelected(event)">
<div class="mort-dropzone" id="mort-dropzone" onclick="document.getElementById('mort-file-input').click()">
<span class="mort-dropzone-icon">&#x1f4f8;</span>
<div class="mort-dropzone-txt">Browse or Drop Photos</div>
<div class="mort-dropzone-sub">Select multiple photos for this coop</div>
</div>
<div class="mort-preview-grid" id="mort-preview-grid"></div>
<button class="mort-submit-btn" id="mort-submit-btn" onclick="submitMortalityUpload()" disabled>
Count Mortalities (0 photos selected)
</button>
</div>
<div class="mort-upload-status" id="mort-upload-status"></div>
</div>
</div>
<!-- Modal for high-res mortality preview --> <!-- Modal for high-res mortality preview -->
<div id="mort-modal" onclick="closeMortModal(event)"> <div id="mort-modal" onclick="closeMortModal(event)">
<button class="close-btn" onclick="closeMortModal(event)">&times;</button> <button class="close-btn" onclick="closeMortModal(event)">&times;</button>
@@ -354,99 +245,31 @@ function loadHistory() {
} }
} }
var allMortalityData = [];
var selectedMortCoop = "ALL";
function loadMortality() { function loadMortality() {
fetch("/api/mortality/history").then(r => r.ok ? r.json() : []).then(reports => { fetch("/api/mortality/latest").then(r => r.ok ? r.json() : null).then(data => {
var panel = document.getElementById("mort-panel"); var panel = document.getElementById("mort-panel");
if (!reports || !reports.length) { if (!data || data.error || !data.results || !data.results.length) {
fetch("/api/mortality/latest").then(r => r.ok ? r.json() : null).then(latest => {
if (!latest || latest.error || !latest.results || !latest.results.length) {
panel.style.display = "none";
return;
}
allMortalityData = [latest];
renderMortalityUI();
}).catch(function() { panel.style.display = "none"; });
return;
}
allMortalityData = reports;
renderMortalityUI();
}).catch(function() {});
}
function renderMortalityUI() {
var panel = document.getElementById("mort-panel");
if (!allMortalityData || !allMortalityData.length) {
panel.style.display = "none"; panel.style.display = "none";
return; return;
} }
panel.style.display = "block"; panel.style.display = "block";
var totalCarcasses = data.total_mortality_count !== undefined ? data.total_mortality_count : data.results.reduce((acc, x) => acc + (x.count || 0), 0);
var coopsSet = {};
allMortalityData.forEach(function(rep) {
var c = rep.coop || rep.location || "kandang-atas";
coopsSet[c] = (coopsSet[c] || 0) + (rep.total_mortality_count || 0);
});
var coopsList = Object.keys(coopsSet).sort();
var filterDiv = document.getElementById("mort-coop-filters");
if (coopsList.length > 1) {
var fHtml = '<button class="mort-filter-btn' + (selectedMortCoop === 'ALL' ? ' active' : '') + '" onclick="setMortCoopFilter(\'ALL\')">ALL</button>';
coopsList.forEach(function(c) {
fHtml += '<button class="mort-filter-btn' + (selectedMortCoop === c ? ' active' : '') + '" onclick="setMortCoopFilter(\'' + c + '\')">' + c + '</button>';
});
filterDiv.innerHTML = fHtml;
filterDiv.style.display = "flex";
} else {
filterDiv.style.display = "none";
}
var filteredReports = selectedMortCoop === "ALL"
? allMortalityData
: allMortalityData.filter(function(r) { return (r.coop || r.location || "").toUpperCase() === selectedMortCoop.toUpperCase(); });
var totalCarcasses = 0;
var allItems = [];
filteredReports.forEach(function(rep) {
totalCarcasses += (rep.total_mortality_count || 0);
var cName = rep.coop || rep.location || "kandang-atas";
var cFloors = rep.covered_floors ? rep.covered_floors.join(", ") : "";
(rep.results || []).forEach(function(res) {
allItems.push({
item: res,
coop: cName,
floors: cFloors,
date: rep.date || ""
});
});
});
document.getElementById("mort-header-badge").textContent = totalCarcasses + " detected"; document.getElementById("mort-header-badge").textContent = totalCarcasses + " detected";
var summaryTxt = (selectedMortCoop === "ALL" ? "All Coops" : selectedMortCoop) + " \u2022 " + allItems.length + " scan(s)"; document.getElementById("mort-summary-text").textContent = (data.date ? data.date + " \u2022 " : "") + data.results.length + " photo(s) scanned";
document.getElementById("mort-summary-text").textContent = summaryTxt;
var grid = document.getElementById("mort-grid"); var grid = document.getElementById("mort-grid");
grid.innerHTML = allItems.map(function(obj) { grid.innerHTML = data.results.map(function(item) {
var item = obj.item;
var imgSrc = "/api/mortality/image/" + encodeURIComponent(item.output_image || item.input_image); var imgSrc = "/api/mortality/image/" + encodeURIComponent(item.output_image || item.input_image);
var title = (item.output_image || item.input_image || "scan").replace(/^output_/, ""); var title = (item.output_image || item.input_image || "scan").replace(/^output_/, "");
var count = item.count !== undefined ? item.count : (item.detections ? item.detections.length : 0); var count = item.count !== undefined ? item.count : (item.detections ? item.detections.length : 0);
var captionText = '[' + obj.coop + (obj.floors ? ' (Floors: ' + obj.floors + ')' : '') + '] ' + title + ' (' + count + ' carcasses)'; return '<div class="mort-card" onclick="openMortModal(\'' + imgSrc + '\', \'' + title + ' (' + count + ' carcasses)\')">' +
return '<div class="mort-card" onclick="openMortModal(\'' + imgSrc + '\', \'' + captionText.replace(/'/g, "\\'") + '\')">' +
'<img src="' + imgSrc + '" alt="' + title + '" loading="lazy" onerror="this.style.opacity=0.3">' + '<img src="' + imgSrc + '" alt="' + title + '" loading="lazy" onerror="this.style.opacity=0.3">' +
'<div class="info">' + '<div class="info">' +
'<span style="max-width:85px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + title + '">' + '<span style="max-width:70px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + title + '">' + title + '</span>' +
'<span class="mort-coop-tag">' + obj.coop + '</span>' + title + '</span>' +
'<span class="cnt">' + count + ' 💀</span>' + '<span class="cnt">' + count + ' 💀</span>' +
'</div></div>'; '</div></div>';
}).join(""); }).join("");
} }).catch(function() {});
function setMortCoopFilter(coop) {
selectedMortCoop = coop;
renderMortalityUI();
} }
function openMortModal(src, caption) { function openMortModal(src, caption) {
@@ -460,227 +283,8 @@ function closeMortModal(event) {
document.getElementById("mort-modal").classList.remove("active"); document.getElementById("mort-modal").classList.remove("active");
} }
/* Mortality Inspection Camera & Upload Logic */
var selectedUploadFiles = [];
var mortModalTab = "cam";
function switchMortModalTab(tab) {
mortModalTab = tab;
var btnCam = document.getElementById("tab-btn-cam");
var btnFile = document.getElementById("tab-btn-file");
var contentCam = document.getElementById("mort-cam-tab-content");
var contentFile = document.getElementById("mort-file-tab-content");
if (tab === "cam") {
if (btnCam) btnCam.classList.add("active");
if (btnFile) btnFile.classList.remove("active");
if (contentCam) contentCam.style.display = "block";
if (contentFile) contentFile.style.display = "none";
refreshMortCamPreview();
} else {
if (btnFile) btnFile.classList.add("active");
if (btnCam) btnCam.classList.remove("active");
if (contentFile) contentFile.style.display = "block";
if (contentCam) contentCam.style.display = "none";
}
}
function onMortCoopChanged() {
if (mortModalTab === "cam") {
refreshMortCamPreview();
}
}
function refreshMortCamPreview() {
var coop = document.getElementById("mort-upload-coop").value || "K1";
var img = document.getElementById("mort-cam-live-img");
var offlineMsg = document.getElementById("mort-cam-offline-msg");
var overlay = document.getElementById("mort-cam-status-overlay");
if (!img) return;
overlay.textContent = "Connecting to " + coop + " camera...";
overlay.style.color = "#ff9f43";
img.onload = function() {
img.style.display = "block";
offlineMsg.style.display = "none";
overlay.textContent = "🟢 " + coop + " Camera Live";
overlay.style.color = "#2ed573";
};
img.onerror = function() {
img.style.display = "none";
offlineMsg.style.display = "block";
overlay.textContent = "⚪ Stationary Camera Ready";
overlay.style.color = "#aaa";
};
img.src = "/api/mortality/camera/preview?coop=" + encodeURIComponent(coop) + "&_t=" + Date.now();
}
function captureFromStationaryCamera() {
var coop = document.getElementById("mort-upload-coop").value || "K1";
var date = document.getElementById("mort-upload-date").value || new Date().toISOString().split("T")[0];
var btn = document.getElementById("mort-capture-btn");
var statusDiv = document.getElementById("mort-upload-status");
btn.disabled = true;
btn.innerHTML = "⏳ Snapping &amp; Counting with YOLO...";
statusDiv.style.display = "block";
statusDiv.style.color = "#ff9f43";
statusDiv.textContent = "Capturing frame from " + coop + " stationary camera and counting carcasses...";
fetch("/api/mortality/capture", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ coop: coop, date: date })
}).then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === "success") {
statusDiv.style.color = "#2ed573";
statusDiv.innerHTML = "✅ <b>Batch #" + data.total_images + ": " + data.batch_count + " carcass(es)</b> detected!<br>" +
"<span style='color:#ff9f43'>Total for " + coop + " Today: <b>" + data.total_mortality_count + " carcasses</b> across " + data.total_images + " batch(es)</span>";
btn.disabled = false;
btn.innerHTML = "📸 Snap &amp; Count Next Batch";
loadMortality();
setTimeout(refreshMortCamPreview, 600);
} else {
statusDiv.style.color = "#ff4757";
statusDiv.textContent = "❌ Capture error: " + (data.message || "Failed to capture frame");
btn.disabled = false;
btn.innerHTML = "📸 Retry Capture";
}
}).catch(function(err) {
statusDiv.style.color = "#ff4757";
statusDiv.textContent = "❌ Network error: " + err.message;
btn.disabled = false;
btn.innerHTML = "📸 Retry Capture";
});
}
function openMortUploadModal() {
var modal = document.getElementById("mort-upload-modal");
var today = new Date().toISOString().split("T")[0];
document.getElementById("mort-upload-date").value = today;
var coopSelect = document.getElementById("mort-upload-coop");
if (allMortalityData && allMortalityData.length) {
var coopsSet = {};
allMortalityData.forEach(function(r) {
var c = r.coop || r.location;
if (c) coopsSet[c] = true;
});
["K1", "K2", "K3", "K4", "K5", "kandang-atas"].forEach(function(c) { coopsSet[c] = true; });
var list = Object.keys(coopsSet).sort();
if (list.length > 0) {
coopSelect.innerHTML = list.map(function(c) {
return '<option value="' + c + '">' + c + '</option>';
}).join("");
}
}
selectedUploadFiles = [];
renderUploadPreviews();
document.getElementById("mort-upload-status").style.display = "none";
modal.classList.add("active");
switchMortModalTab("cam");
}
function closeMortUploadModal(event) {
var modal = document.getElementById("mort-upload-modal");
modal.classList.remove("active");
}
function handleMortFilesSelected(event) {
var files = event.target.files;
if (!files || !files.length) return;
for (var i = 0; i < files.length; i++) {
selectedUploadFiles.push(files[i]);
}
renderUploadPreviews();
}
function removeUploadFile(idx) {
selectedUploadFiles.splice(idx, 1);
renderUploadPreviews();
}
function renderUploadPreviews() {
var grid = document.getElementById("mort-preview-grid");
var btn = document.getElementById("mort-submit-btn");
if (!selectedUploadFiles.length) {
grid.innerHTML = "";
btn.disabled = true;
btn.textContent = "Count Mortalities (0 photos selected)";
return;
}
btn.disabled = false;
btn.textContent = "Scan & Count Chickens (" + selectedUploadFiles.length + " photo" + (selectedUploadFiles.length > 1 ? "s" : "") + ")";
grid.innerHTML = selectedUploadFiles.map(function(file, idx) {
var url = URL.createObjectURL(file);
return '<div class="mort-preview-item">' +
'<img src="' + url + '" alt="preview">' +
'<button class="rm-btn" onclick="removeUploadFile(' + idx + ')">&times;</button>' +
'</div>';
}).join("");
}
function submitMortalityUpload() {
if (!selectedUploadFiles.length) return;
var coop = document.getElementById("mort-upload-coop").value || "K1";
var date = document.getElementById("mort-upload-date").value || new Date().toISOString().split("T")[0];
var btn = document.getElementById("mort-submit-btn");
var statusDiv = document.getElementById("mort-upload-status");
btn.disabled = true;
btn.textContent = "⏳ Running YOLO Detection...";
statusDiv.style.display = "block";
statusDiv.style.color = "#ff9f43";
statusDiv.textContent = "Uploading " + selectedUploadFiles.length + " photo(s) and counting carcasses...";
var formData = new FormData();
formData.append("coop", coop);
formData.append("date", date);
for (var i = 0; i < selectedUploadFiles.length; i++) {
formData.append("images", selectedUploadFiles[i]);
}
fetch("/api/mortality/upload", {
method: "POST",
body: formData
}).then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === "success") {
statusDiv.style.color = "#2ed573";
statusDiv.innerHTML = "✅ <b>" + data.total_mortality_count + " carcasses</b> detected across " + data.total_images + " photo(s) for <b>" + coop + "</b> (" + date + ")!";
btn.textContent = "✅ Scan Completed";
loadMortality();
setTimeout(function() {
closeMortUploadModal();
}, 2500);
} else {
statusDiv.style.color = "#ff4757";
statusDiv.textContent = "❌ Error: " + (data.message || "Failed to process photos");
btn.disabled = false;
btn.textContent = "Retry Scan";
}
}).catch(function(err) {
statusDiv.style.color = "#ff4757";
statusDiv.textContent = "❌ Network error: " + err.message;
btn.disabled = false;
btn.textContent = "Retry Scan";
});
}
document.addEventListener("keydown", function(e) { document.addEventListener("keydown", function(e) {
if (e.key === "Escape") { if (e.key === "Escape") closeMortModal();
closeMortModal();
closeMortUploadModal();
}
}); });
setTimeout(function() { if (activeCam) refreshNow(); }, 500); setTimeout(function() { if (activeCam) refreshNow(); }, 500);
-184
View File
@@ -1,184 +0,0 @@
"""Unit tests for dynamic coop, floor, and mortality discovery."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from chicken_counter.config import load_or_synthesize_floor_config
from chicken_counter.discovery import (
discover_all_mortality_dirs,
discover_farm_structure,
get_coop_for_location,
resolve_floor_video_dir,
sync_floor_configs,
)
class TestFlexibleDiscovery(unittest.TestCase):
def test_get_coop_for_location(self):
self.assertEqual(get_coop_for_location("K1-L1"), "K1")
self.assertEqual(get_coop_for_location("K2-L3"), "K2")
self.assertEqual(get_coop_for_location("k1-l2"), "K1")
self.assertEqual(get_coop_for_location("K3/L1"), "K3")
self.assertEqual(get_coop_for_location("K4_L2"), "K4")
self.assertEqual(get_coop_for_location("kandang-atas"), "kandang-atas")
self.assertEqual(get_coop_for_location("kandang-bawah"), "kandang-bawah")
self.assertEqual(get_coop_for_location("K5"), "K5")
def test_discovery_on_current_workspace(self):
structure = discover_farm_structure()
self.assertIsNotNone(structure)
# Check detected coops in workspace
self.assertIn("K1", structure.coops)
self.assertIn("K2", structure.coops)
self.assertIn("K3", structure.coops)
self.assertIn("K4", structure.coops)
self.assertIn("K5", structure.coops)
# Verify covered floors for K1 (3 floors) and K4 (2 floors)
k1_floors = structure.get_covered_floors("K1")
self.assertIn("K1-L1", k1_floors)
self.assertIn("K1-L2", k1_floors)
self.assertIn("K1-L3", k1_floors)
k4_floors = structure.get_covered_floors("K4")
self.assertIn("K4-L1", k4_floors)
self.assertIn("K4-L2", k4_floors)
# Verify mortality directories
mort_dirs = structure.all_mortality_dirs()
self.assertIn("K1", mort_dirs)
self.assertIn("K2", mort_dirs)
self.assertIn("K3", mort_dirs)
self.assertIn("K4", mort_dirs)
self.assertIn("K5", mort_dirs)
self.assertEqual(mort_dirs["K1"].name, "mortality")
self.assertEqual(mort_dirs["K1"].parent.name, "K1")
def test_flexible_discovery_arbitrary_site(self):
"""Test that discovery dynamically adapts to arbitrary site layouts with varying coops and floors."""
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir)
# Create arbitrary site layout:
# Coop A (Kandang-Utama) has 4 floors (L1..L4) + mortality
# Coop B (K9) has 1 floor (L1) + mortality
# Coop C (K10) has 2 floors (L1, L2) with no mortality folder yet
(tmp / "cycle1" / "Kandang-Utama" / "L1").mkdir(parents=True)
(tmp / "cycle1" / "Kandang-Utama" / "L2").mkdir(parents=True)
(tmp / "cycle1" / "Kandang-Utama" / "L3").mkdir(parents=True)
(tmp / "cycle1" / "Kandang-Utama" / "L4").mkdir(parents=True)
(tmp / "cycle1" / "Kandang-Utama" / "mortality").mkdir(parents=True)
(tmp / "cycle1" / "K9" / "L1").mkdir(parents=True)
(tmp / "cycle1" / "K9" / "mortality").mkdir(parents=True)
(tmp / "cycle1" / "K10" / "L1").mkdir(parents=True)
(tmp / "cycle1" / "K10" / "L2").mkdir(parents=True)
structure = discover_farm_structure(videos_root=tmp)
self.assertTrue("KANDANG-UTAMA" in structure.coops or "Kandang-Utama" in structure.coops)
coop_u = structure.get_coop("Kandang-Utama")
self.assertIsNotNone(coop_u)
self.assertEqual(len(coop_u.covered_floors), 4)
self.assertIsNotNone(coop_u.mortality_dir)
coop_k9 = structure.get_coop("K9")
self.assertIsNotNone(coop_k9)
self.assertEqual(len(coop_k9.covered_floors), 1)
self.assertIsNotNone(coop_k9.mortality_dir)
coop_k10 = structure.get_coop("K10")
self.assertIsNotNone(coop_k10)
self.assertEqual(len(coop_k10.covered_floors), 2)
self.assertIsNone(coop_k10.mortality_dir)
m_dirs = structure.all_mortality_dirs()
self.assertIn("K9", m_dirs)
self.assertTrue("Kandang-Utama" in m_dirs or "KANDANG-UTAMA" in m_dirs)
self.assertNotIn("K10", m_dirs)
def test_resolve_floor_video_dir(self):
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir)
# Create hierarchical directory
hier_dir = tmp / "cycle7" / "K1" / "L1"
hier_dir.mkdir(parents=True)
# Query using old flat path
flat_query = tmp / "cycle7" / "K1-L1"
resolved = resolve_floor_video_dir(flat_query, "K1-L1")
self.assertEqual(resolved, hier_dir)
def test_sync_floor_configs(self):
"""Test auto-generating floor YAML configs for newly discovered coops/floors."""
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir)
vid_dir = tmp / "VIDEOS" / "cycle7"
cfg_dir = tmp / "configs" / "floor_config"
# Create new coop K8 with 3 floors
(vid_dir / "K8" / "L1").mkdir(parents=True)
(vid_dir / "K8" / "L2").mkdir(parents=True)
(vid_dir / "K8" / "L3").mkdir(parents=True)
created = sync_floor_configs(videos_root=vid_dir.parent, configs_dir=cfg_dir)
self.assertEqual(len(created), 3)
# Check that files were written
self.assertTrue((cfg_dir / "K8-L1.yaml").exists())
self.assertTrue((cfg_dir / "K8-L2.yaml").exists())
self.assertTrue((cfg_dir / "K8-L3.yaml").exists())
content = (cfg_dir / "K8-L1.yaml").read_text()
self.assertIn('location: "K8-L1"', content)
self.assertIn('root_dir: "../VIDEOS/cycle7/K8/L1"', content)
# Running sync again should create 0 files
created_again = sync_floor_configs(videos_root=vid_dir.parent, configs_dir=cfg_dir)
self.assertEqual(len(created_again), 0)
def test_load_or_synthesize_floor_config(self):
"""Test virtual in-memory config synthesis without any YAML file."""
# Load virtual floor K9-L4 (no YAML exists)
settings = load_or_synthesize_floor_config("K9-L4")
self.assertEqual(settings.batch.location, "K9-L4")
self.assertTrue(settings.batch.root_dir.endswith("VIDEOS/cycle7/K9/L4"))
self.assertEqual(len(settings.cameras), 4)
self.assertIn("CC1", settings.cameras)
def test_dated_mortality_discovery(self):
"""Test that dated mortality subfolders (e.g. K1/mortality/2026-08-20/) are discovered."""
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir)
mort_day = tmp / "cycle7" / "K1" / "mortality" / "2026-08-20"
mort_day.mkdir(parents=True)
report = mort_day / "mortality_report.json"
report.write_text('{"date":"2026-08-20","total_mortality_count":15,"results":[]}')
structure = discover_farm_structure(videos_root=tmp)
self.assertIn("K1", structure.coops)
self.assertIsNotNone(structure.coops["K1"].mortality_dir)
def test_stationary_camera_config(self):
"""Test retrieving stationary camera sources for farm coops."""
from chicken_counter.mortality import capture_camera_snapshot, get_camera_source_for_coop
cam_k1 = get_camera_source_for_coop("K1")
self.assertIsNotNone(cam_k1)
self.assertTrue("192.168.1.101" in str(cam_k1) or "rtsp" in str(cam_k1))
cam_k5 = get_camera_source_for_coop("K5")
self.assertIsNotNone(cam_k5)
# Non-existent camera stream capture should return None gracefully without raising
invalid_cap = capture_camera_snapshot("rtsp://invalid-non-existent-host.local:554/live", timeout_sec=0.5, warmup_frames=1)
self.assertIsNone(invalid_cap)
if __name__ == "__main__":
unittest.main()
-36
View File
@@ -1,36 +0,0 @@
"""Tests for DetectionTracker multi-camera stream isolation."""
from __future__ import annotations
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from chicken_counter.config import load_camera_config
class MultiCameraTrackerIsolationTests(unittest.TestCase):
def test_independent_stream_trackers(self) -> None:
cfg = load_camera_config("configs/cameras/example_camera.yaml")
# Test get_or_create_tracker instantiates isolated instances for different stream IDs
with patch("chicken_counter.tracking.ensure_compatible_model", return_value=cfg.detection.model_path), \
patch("chicken_counter.tracking.YOLO"):
from chicken_counter.tracking import DetectionTracker
tracker_manager = DetectionTracker(cfg)
tr1 = tracker_manager.get_or_create_tracker("CC1")
tr2 = tracker_manager.get_or_create_tracker("CC2")
self.assertIsNotNone(tr1)
self.assertIsNotNone(tr2)
self.assertIsNot(tr1, tr2, "Trackers for CC1 and CC2 must be distinct objects")
# Resetting CC1 should not delete CC2 tracker
tracker_manager.reset_tracking("CC1")
self.assertNotIn("CC1", tracker_manager._stream_trackers)
self.assertIn("CC2", tracker_manager._stream_trackers)
if __name__ == "__main__":
unittest.main()