2 Commits
38 changed files with 680 additions and 75609 deletions

No files matched your search

-3
View File
@@ -226,6 +226,3 @@ output/
*.db-shm
*.db-wal
*.2026*
.vscode/
configs/config_backup_folder/
configs/cycle7_batch.yaml.*
-73611
View File
File diff suppressed because it is too large. Load diff
+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`.
### `GET /api/mortality/coops`
Returns a summary of all detected coops with their covered floors and mortality figures:
```json
[
{
"coop": "K1",
"covered_floors": ["K1-L1", "K1-L2", "K1-L3"],
"total_carcasses": 12,
"total_scans": 3,
"latest_date": "2026-06-18",
"latest_count": 4
},
{
"coop": "K2",
"covered_floors": ["K2-L1", "K2-L2", "K2-L3"],
"total_carcasses": 8,
"total_scans": 2,
"latest_date": "2026-06-18",
"latest_count": 8
}
]
```
### `GET /api/mortality/coop/<coop_id>`
Returns all mortality reports recorded for a specific coop (e.g. `GET /api/mortality/coop/K1`).
### `GET /api/mortality/latest`
Returns the most recent `mortality_report.json` across all coop directories, enriched with `coop` and `covered_floors`:
Returns the most recently modified `mortality_report.json` across all registered mortality directories.
```json
{
"date": "2026-06-18",
"coop": "K1",
"location": "K1",
"covered_floors": ["K1-L1", "K1-L2", "K1-L3"],
"total_mortality_count": 19,
"total_images": 1,
"_dir": "/path/to/VIDEOS/cycle7/K1/mortality",
"results": [...]
}
```
### `GET /api/mortality/history`
Returns all `mortality_report.json` files from all registered coop directories, sorted newest first. Each report contains `coop`, `covered_floors`, `total_mortality_count`, and `total_images`.
### `GET /api/mortality/date/<YYYY-MM-DD>`
Returns all mortality scans and total carcass counts recorded on a specific date:
```json
{
"date": "2026-06-18",
"date": "2026-07-09",
"mode": "similarity_two_pass",
"model_path": "...",
"conf_threshold": 0.7,
"iou_threshold": 0.8,
"total_images": 2,
"total_mortality_count": 35,
"total_images": 2,
"reports": [...],
"results": [...]
}
```
### `POST /api/mortality/capture`
Snaps a high-resolution still frame directly from the dedicated stationary inspection camera for a specific coop, runs YOLO carcass detection, saves the image into `VIDEOS/cycle7/<coop>/mortality/<date>/capture_XX_<timestamp>.jpg`, and returns the detection count.
**Content-Type**: `application/json` or `multipart/form-data` or Query Parameters
**Request Parameters**:
* `coop` (string, required): Coop identifier (e.g. `K1`, `K2`, `kandang-atas`).
* `date` (string, optional): Date in `YYYY-MM-DD` format (defaults to today).
* `source` (string, optional): Override camera RTSP stream / device URL. If omitted, uses the configured camera in `configs/mortality_config.yaml`.
* `conf` (float, optional): Confidence threshold override.
**Response (`200 OK`)**:
```json
{
"status": "success",
"message": "Captured photo #2 from K1 camera (12 carcasses)",
"coop": "K1",
"date": "2026-08-20",
"camera_source": "rtsp://admin:admin@192.168.1.101:554/live",
"captured_file": "capture_02_20260820_103000.jpg",
"batch_count": 12,
"total_images": 2,
"total_mortality_count": 30,
"results": [...]
}
```
### `GET /api/mortality/camera/preview?coop=<coop_id>`
Returns a live JPEG snapshot frame from that coop's stationary camera so workers can verify chicken positioning before capturing.
```
GET /api/mortality/camera/preview?coop=K1
→ Content-Type: image/jpeg
```
### `GET /api/mortality/cameras`
Returns the map of all registered stationary cameras per coop.
```json
{
"cameras": {
"K1": "rtsp://admin:admin@192.168.1.101:554/live",
"K2": "rtsp://admin:admin@192.168.1.102:554/live",
"K3": "rtsp://admin:admin@192.168.1.103:554/live",
"K4": "rtsp://admin:admin@192.168.1.104:554/live",
"K5": "rtsp://admin:admin@192.168.1.105:554/live"
}
}
```
### `POST /api/mortality/upload`
Uploads one or more mortality photos from disk for a specific coop, runs YOLO carcass detection immediately, saves results into `VIDEOS/cycle7/<coop>/mortality/<date>/`, and returns detection results.
**Content-Type**: `multipart/form-data`
**Form Fields**:
* `coop` (string, required): Coop identifier (e.g. `K1`, `K2`, `kandang-atas`).
* `date` (string, optional): Date in `YYYY-MM-DD` format (defaults to current date).
* `conf` (float, optional): Confidence threshold override (e.g. `0.7`).
* `two_pass` (boolean, optional): Set `true` for 2-pass refinement.
* `images` (file/binary, repeatable): 1 or more image files (`.jpg`, `.jpeg`, `.png`).
**Response (`200 OK`)**:
```json
{
"status": "success",
"message": "Processed 2 image(s) for K1",
"coop": "K1",
"date": "2026-08-20",
"total_images": 2,
"total_mortality_count": 29,
"_dir": "/path/to/mortality",
"results": [
{
"input_image": "batch_1.jpg",
"output_image": "output_batch_1.jpg",
"input_image": "scan_01.jpg",
"output_image": "output_scan_01.jpg",
"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": [...]
}
]
}
```
### `POST /api/mortality/scan`
Triggers immediate re-scanning of an existing coop directory:
### `GET /api/mortality/history`
Returns all `mortality_report.json` files from all registered directories, sorted newest first. Each report contains `total_mortality_count` (grand total across all images in that run) and `total_images`.
### `GET /api/mortality/date/<YYYY-MM-DD>`
Returns all mortality scans and total carcass counts recorded on a specific date:
**Request Body (`application/json`)**:
```json
{
"coop": "K1",
"date": "2026-08-20"
"date": "2026-07-09",
"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.
## [Unreleased] - 2026-08-20
### 🚀 Features & Architectural Improvements
- **Hierarchical Per-Coop & Flexible Cage/Floor Discovery (`discovery.py`)**:
- Implemented dynamic farm discovery engine (`discover_farm_structure`, `get_coop_for_location`, `resolve_floor_video_dir`).
- Added support for hierarchical coop folders (`VIDEOS/cycle7/<Coop>/[L1..Ln, mortality]`) where all floors and mortality sit on the same level per coop.
- Fully dynamic site layout discovery without hardcoding assumptions about numbers of coops (e.g. K1..K5 or custom names) or floors per coop (e.g. 2 floors vs 3 floors).
- **Multi-Coop Mortality Pipeline & Batch Runner (`mortality.py`, `cli.py`, `run_mortality_all.sh`)**:
- Added coop and covered floor metadata resolution in `run_mortality_count(...)` and `mortality_report.json`.
- Created `run_all_coop_mortality(...)` and `./run_mortality_all.sh` to batch-process mortality detection across all discovered coops with summary reports.
- Added CLI flags `--coop <ID>` and `--all-coops` to `chicken-counter mortality`.
- **Dedicated Stationary Inspection Camera Capture & Live API (`mortality.py`, `dashboard.py`, `templates/index.html`)**:
- Added support for dedicated per-coop stationary cameras (RTSP IP cameras, HTTP snapshots, and USB cameras) configured in `configs/mortality_config.yaml`.
- Added `POST /api/mortality/capture` endpoint to grab high-res still frames from a coop's stationary camera, timestamp and save them into `VIDEOS/cycle7/<Coop>/mortality/<YYYY-MM-DD>/`, and run YOLO carcass detection.
- Added `GET /api/mortality/camera/preview` to stream live snapshot previews to workers in the webapp before snapping.
- Added `GET /api/mortality/cameras` to list all registered coop stationary camera sources.
- Web UI Mortality Inspector: Integrated live camera preview, one-click **"📸 Snap & Count Batch from Camera"** button, and support for multi-batch counting per coop per day with cumulative totals.
- Added fallback manual file upload tab for disk-based images.
- **Dynamic Floor Config Synthesis & Sync Tool (`config.py`, `discovery.py`, `cli.py`)**:
- Implemented `load_or_synthesize_floor_config` allowing `chicken-counter batch --floor <ID>` to dynamically synthesize settings from `cycle7_batch_optimized.yaml` in memory without requiring a physical YAML file.
- Implemented `sync_floor_configs` and CLI command `chicken-counter sync-configs` to automatically scan `VIDEOS/` and generate boilerplate floor YAML configs for newly added coops/floors.
- Updated `run_all_coops.sh` to dynamically discover all floors directly from `VIDEOS/` or `configs/floor_config/` and seamlessly run physical YAMLs or virtual in-memory configs.
- **Unit Test Suite (`tests/test_flexible_discovery.py`)**:
- Added automated test cases validating dynamic coop and floor extraction, arbitrary site layout adaptation, path resolution, config synchronization, virtual floor config synthesis, and dated mortality discovery.
## [1.1.0] - 2026-08-19
## [Unreleased] - 2026-08-19
### 🐛 Bug Fixes
- **Multi-Camera Tracker State Isolation (`tracking.py` & `batch_runner.py`)**:
- Implemented per-camera independent tracker instances keyed by `stream_id` in `DetectionTracker`.
- Fixed tracker state bleed and track ID jumping in `tensor_batching` and multi-camera batch modes when camera frames are processed or when active camera sets shrink.
- Added unit test suite `tests/test_tracking.py` covering multi-stream tracker isolation.
- **Multi-Floor Script Syntax (`run_all_coops.sh`)**:
- Resolved fatal `syntax error: unexpected end of file` caused by missing `done` in floor configuration discovery loop.
- Expanded config discovery pattern from `K*-L*.yaml` to `*.yaml` to support custom named coops (e.g. `kandang-atas.yaml`).
@@ -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.
### ⚡ Performance & Optimization
- **Secondary Database Indexing (`dashboard.py`, `batch_runner.py`, `store_results.py`)**:
- Added `idx_batch_date`, `idx_batch_camera`, and `idx_batch_location` indexes on `batch_runs` to accelerate single camera, location, and date historical queries.
- **Atomic Config Persistence (`dashboard.py`)**:
- Added `_config_write_lock` and atomic temporary file replacement (`os.replace`) in `_persist_cycle_start_date` to prevent race conditions or half-written YAML reads.
- **Streaming Socket Keepalive (`dashboard.py`)**:
- Added an idle keepalive heartbeat (`b"--frame\r\n\r\n"`) every 3 seconds to immediately detect and terminate disconnected MJPEG streaming threads during pipeline idle periods.
- **Disk Traversal Caching in Live Dashboard (`dashboard.py`)**:
- Replaced repetitive `Path.rglob()` scans on every `/api/mortality/*` endpoint with a thread-safe 15-second in-memory TTL cache (`_MORTALITY_CACHE_TTL = 15.0s`).
- Added $O(1)$ memory index lookup for serving annotated images (`/api/mortality/image/<filename>`), eliminating full filesystem traversals across 30GB+ video datasets.
+17 -56
View File
@@ -95,80 +95,41 @@ Place input videos under the `VIDEOS` folder adjacent to the project following t
Then run:
```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 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 \
--config configs/floor_config/K1-L1.yaml \
--date 2026-06-18
# Run directly on ANY discovered floor (virtual config — zero YAML required!)
PYTHONPATH=src venv/bin/python -m chicken_counter.cli batch \
--floor K6-L1 \
--date 2026-06-18
# Automatically scan VIDEOS and generate boilerplate YAMLs for any newly added floors
PYTHONPATH=src venv/bin/python -m chicken_counter.cli sync-configs
# Run archive scripts in test_run_folder
./test_run_folder/test_run.sh
./test_run_folder/test_run_tensor_batch.sh
./test_run_folder/test_run_hybrid.sh
```
Output is saved in `../VIDEOS/cycle7/<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)
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.).
Place input photos in `../VIDEOS/cycle7/kandang-atas/mortality/`.
```bash
# Run mortality detection across ALL discovered coops (K1..K5, etc.)
./run_mortality_all.sh
# Run with default config
./test_run_mortality.sh
# Run for a specific coop
./run_mortality_all.sh K1
# or via CLI:
PYTHONPATH=src venv/bin/python -m chicken_counter.cli mortality --coop K1
# Override confidence threshold
CONF=0.65 ./test_run_mortality.sh
# Run across all coops for a specific date
./run_mortality_all.sh 2026-06-18
# Run directly on a specific image file
PYTHONPATH=src venv/bin/python -m chicken_counter.cli mortality -i /path/to/photo.jpg
# Run on a specific image
./test_run_mortality.sh /path/to/photo.jpg
```
Output annotated images are saved as `output_<original_name>.jpg` in each coop's mortality directory.
A `mortality_report.json` is generated for each coop, tagging the coop name and all covered floors (e.g., `["K1-L1", "K1-L2", "K1-L3"]`).
Output annotated images are saved as `output_<original_name>.jpg` in the same directory.
A `mortality_report.json` is also saved there with full detection data.
#### Key config options in `configs/mortality_config.yaml`
+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:
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)
# cameras:
+1 -1
View File
@@ -4,7 +4,7 @@ extends: ../cycle7_batch_optimized.yaml
batch:
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)
# cameras:
+1 -1
View File
@@ -4,4 +4,4 @@ extends: ../cycle7_batch_optimized.yaml
batch:
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:
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)
# cameras:
+1 -1
View File
@@ -4,7 +4,7 @@ extends: ../cycle7_batch_optimized.yaml
batch:
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)
# cameras:
+1 -1
View File
@@ -4,4 +4,4 @@ extends: ../cycle7_batch_optimized.yaml
batch:
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:
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)
# cameras:
+1 -1
View File
@@ -4,7 +4,7 @@ extends: ../cycle7_batch_optimized.yaml
batch:
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)
# cameras:
+1 -1
View File
@@ -4,4 +4,4 @@ extends: ../cycle7_batch_optimized.yaml
batch:
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:
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)
# cameras:
+1 -1
View File
@@ -4,7 +4,7 @@ extends: ../cycle7_batch_optimized.yaml
batch:
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)
# cameras:
+1 -1
View File
@@ -4,7 +4,7 @@ extends: ../cycle7_batch_optimized.yaml
batch:
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)
# cameras:
+1 -1
View File
@@ -4,7 +4,7 @@ extends: ../cycle7_batch_optimized.yaml
batch:
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)
# cameras:
+2 -14
View File
@@ -1,10 +1,7 @@
# Configuration for Mortality Chicken Carcass Detection & Counting
mortality:
# Base directory for multi-coop auto-discovery (K1..K5 and arbitrary coops)
coops_root_dir: "../VIDEOS/cycle7"
# Default single-coop input directory (or override via --coop K1 / --all-coops)
input_dir: "../VIDEOS/cycle7/K1/mortality"
output_dir: null # null = save output_<File Name> in each coop's mortality directory
input_dir: "../VIDEOS/cycle7/kandang-atas/mortality"
output_dir: null # null = save output_<File Name> in input directory
model_path: "models/chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt"
device: "0" # "0" for GPU acceleration, or "cpu"
conf: 0.7
@@ -14,12 +11,3 @@ mortality:
two_pass: false # Enable Union 2x Detect & Refine crop pipeline
classes: [0] # Class 0 = chicken ONLY; ignores background/text/equipment (classes 1 not-chicken, 2 half-chicken)
imgsz: 640
# Dedicated stationary camera streams per coop (RTSP URL, HTTP snapshot URL, or USB device index)
cameras:
K1: "rtsp://admin:admin@192.168.1.101:554/live"
K2: "rtsp://admin:admin@192.168.1.102:554/live"
K3: "rtsp://admin:admin@192.168.1.103:554/live"
K4: "rtsp://admin:admin@192.168.1.104:554/live"
K5: "rtsp://admin:admin@192.168.1.105:554/live"
kandang-atas: "rtsp://admin:admin@192.168.1.100:554/live"
+28 -424
View File
@@ -6,7 +6,6 @@ from __future__ import annotations
import argparse
import json
import mimetypes
import os
import re
import sqlite3
import threading
@@ -17,20 +16,6 @@ from pathlib import Path
from socketserver import ThreadingMixIn
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):
daemon_threads = True
@@ -42,7 +27,6 @@ TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
_thread_local = threading.local()
_db_lock = threading.Lock()
_config_write_lock = threading.Lock()
_db_path = ""
_mortality_dirs: list[Path] = []
_mortality_cache_lock = threading.Lock()
@@ -67,35 +51,29 @@ _cycle_start_date: str = _load_initial_cycle_start_date()
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
_cycle_start_date = new_date
updated_any = False
with _config_write_lock:
_cycle_start_date = new_date
for cfg_name in ("cycle7_batch_optimized.yaml", "cycle7_batch.yaml"):
cfg_path = Path(__file__).resolve().parent / "configs" / cfg_name
if cfg_path.exists():
try:
content = cfg_path.read_text(encoding="utf-8")
pattern = r"^([ \t]*cycle_start_date:[ \t]*)(?:['\"]?)([^'\"\r\n#]+)(?:['\"]?)([ \t]*(?:#.*)?)$"
for cfg_name in ("cycle7_batch_optimized.yaml", "cycle7_batch.yaml"):
cfg_path = Path(__file__).resolve().parent / "configs" / cfg_name
if cfg_path.exists():
content = cfg_path.read_text(encoding="utf-8")
pattern = r"^([ \t]*cycle_start_date:[ \t]*)(?:['\"]?)([^'\"\r\n#]+)(?:['\"]?)([ \t]*(?:#.*)?)$"
def replacer(match: re.Match) -> str:
prefix = match.group(1)
comment = match.group(3) or ""
if comment and not comment.startswith(" "):
comment = f" {comment.lstrip()}"
if not comment.startswith(" "):
comment = f" {comment}"
return f'{prefix}"{new_date}"{comment}'
def replacer(match: re.Match) -> str:
prefix = match.group(1)
comment = match.group(3) or ""
if comment and not comment.startswith(" "):
comment = f" {comment.lstrip()}"
if not comment.startswith(" "):
comment = f" {comment}"
return f'{prefix}"{new_date}"{comment}'
new_content, count = re.subn(pattern, replacer, content, count=1, flags=re.MULTILINE)
if count > 0:
tmp_path = cfg_path.with_suffix(f".tmp.{os.getpid()}")
tmp_path.write_text(new_content, encoding="utf-8")
os.replace(tmp_path, cfg_path)
updated_any = True
except Exception as err:
print(f"[dashboard] ⚠️ Error atomically updating {cfg_name}: {err}")
new_content, count = re.subn(pattern, replacer, content, count=1, flags=re.MULTILINE)
if count > 0:
cfg_path.write_text(new_content, encoding="utf-8")
updated_any = True
print(f"[dashboard] 📅 Updated cycle_start_date to: {new_date} (persisted in configs: {updated_any})")
return updated_any
@@ -141,9 +119,6 @@ def _init_db(db_path: str) -> None:
source_video TEXT NOT NULL DEFAULT '',
generated_at TEXT NOT NULL DEFAULT '',
UNIQUE(date, location, camera_id))""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_batch_date ON batch_runs (date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_batch_camera ON batch_runs (camera_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_batch_location ON batch_runs (location)")
conn.commit()
conn.close()
@@ -158,41 +133,6 @@ def _discover_cameras(shm_dir):
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):
shm_dir = DEFAULT_SHM_DIR
poll_ms = 1000
@@ -215,14 +155,6 @@ class DashboardHandler(SimpleHTTPRequestHandler):
except (BrokenPipeError, ConnectionResetError):
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):
try:
parsed = urlparse(self.path)
@@ -230,210 +162,9 @@ class DashboardHandler(SimpleHTTPRequestHandler):
if path == "/api/config/cycle_start_date":
self._handle_set_cycle_start_date()
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)
except (BrokenPipeError, ConnectionResetError):
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):
parsed = urlparse(self.path)
@@ -568,14 +299,9 @@ class DashboardHandler(SimpleHTTPRequestHandler):
self.send_header("Cache-Control", "no-cache")
self.end_headers()
last_mtime = 0.0
last_write_time = time.time()
keepalive_interval = 3.0
last_mtime = 0
try:
while True:
now = time.time()
frame_written = False
try:
mtime = frame_path.stat().st_mtime
if mtime != last_mtime:
@@ -588,19 +314,10 @@ class DashboardHandler(SimpleHTTPRequestHandler):
data + b"\r\n"
)
self.wfile.flush()
last_write_time = now
frame_written = True
except (FileNotFoundError, OSError):
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)
except (BrokenPipeError, ConnectionResetError, OSError):
except (BrokenPipeError, ConnectionResetError):
pass
def _handle_shm(self, path):
@@ -681,57 +398,27 @@ class DashboardHandler(SimpleHTTPRequestHandler):
def _handle_mortality(self, path: str) -> None:
"""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/history - List of all mortality reports found (newest first).
GET /api/mortality/date/<date> - Mortality breakdown for a specific date (YYYY-MM-DD).
GET /api/mortality/image/<name> - Serve an output_*.jpg annotated image by filename.
"""
global _mortality_dirs
if not _mortality_dirs:
_mortality_dirs = list(discover_all_mortality_dirs().values())
if not _mortality_dirs:
self._send_json({"error": "No mortality directory configured or detected. Start dashboard with --mortality-dir."})
self._send_json({"error": "No mortality directory configured. Start dashboard with --mortality-dir."})
return
def _infer_coop_and_floors(data: dict, report_path: Path) -> tuple[str, list[str]]:
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
def _enrich_report(data: dict) -> dict:
if "total_mortality_count" not in data and "results" in data:
data["total_mortality_count"] = sum(r.get("count", 0) for r in data["results"])
for res in data.get("results", []):
res["coop"] = coop
res["covered_floors"] = floors
return data
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()
with _mortality_cache_lock:
if not force_refresh and (now - _mortality_reports_mtime) < _MORTALITY_CACHE_TTL and _mortality_reports_cache:
return list(_mortality_reports_cache)
if not _mortality_dirs:
_mortality_dirs = list(discover_all_mortality_dirs().values())
found = []
img_cache: dict[str, Path] = {}
for mdir in _mortality_dirs:
@@ -739,6 +426,7 @@ class DashboardHandler(SimpleHTTPRequestHandler):
if p.is_dir():
for report in p.rglob("mortality_report.json"):
found.append(report)
# Index images in the same directory as the report
for img in report.parent.glob("output_*.jpg"):
img_cache[img.name] = img
for img in report.parent.glob("output_*.png"):
@@ -785,89 +473,6 @@ class DashboardHandler(SimpleHTTPRequestHandler):
return img_path
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 ---
if path == "/api/mortality/history":
results = []
@@ -876,7 +481,7 @@ class DashboardHandler(SimpleHTTPRequestHandler):
data = json.loads(report_path.read_text(encoding="utf-8"))
data["_dir"] = str(report_path.parent)
data["_report_mtime"] = report_path.stat().st_mtime
results.append(_enrich_report(data, report_path))
results.append(_enrich_report(data))
except (json.JSONDecodeError, OSError):
pass
results.sort(key=lambda x: x.get("_report_mtime", 0), reverse=True)
@@ -894,11 +499,10 @@ class DashboardHandler(SimpleHTTPRequestHandler):
latest_mtime = mtime
latest = json.loads(report_path.read_text(encoding="utf-8"))
latest["_dir"] = str(report_path.parent)
latest = _enrich_report(latest, report_path)
except (json.JSONDecodeError, OSError):
pass
if latest:
self._send_json(latest)
self._send_json(_enrich_report(latest))
else:
self._send_json({"error": "No mortality report found."})
return
@@ -917,7 +521,7 @@ class DashboardHandler(SimpleHTTPRequestHandler):
data = json.loads(report_path.read_text(encoding="utf-8"))
report_date = data.get("date") or time.strftime("%Y-%m-%d", time.localtime(report_path.stat().st_mtime))
if report_date == target_date or report_path.parent.name == target_date:
enriched = _enrich_report(data, report_path)
enriched = _enrich_report(data)
total_day_carcasses += enriched.get("total_mortality_count", 0)
total_day_images += enriched.get("total_images", 0)
all_results.extend(enriched.get("results", []))
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 chicken_counter.config import BatchSettings
from chicken_counter.discovery import resolve_floor_video_dir
@dataclass
@@ -18,7 +17,7 @@ class CameraDiscoveryResult:
def discover_camera_videos(day_dir: Path, settings: BatchSettings) -> CameraDiscoveryResult:
if not day_dir.is_dir():
# 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():
root_p.mkdir(parents=True, exist_ok=True)
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.compress import compress_video_to_target
from chicken_counter.config import BatchSettings, CameraConfig, CameraPreset, build_camera_config_from_batch
from chicken_counter.discovery import resolve_floor_video_dir
from chicken_counter.engine_utils import ensure_compatible_model
from chicken_counter.overlay import draw_overlay
from chicken_counter.pipeline import PipelineArtifacts, _consume_result, _write_stream_frame, build_pipeline, run_pipeline
@@ -42,9 +41,6 @@ def _store_to_db(report_path: Path, location: str, db_path: str) -> None:
source_video TEXT NOT NULL DEFAULT '',
generated_at TEXT NOT NULL DEFAULT '',
UNIQUE(date, location, camera_id))""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_batch_date ON batch_runs (date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_batch_camera ON batch_runs (camera_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_batch_location ON batch_runs (location)")
date = report["date"]
for camera_id, entry in report["cameras"].items():
@@ -87,8 +83,7 @@ def run_tensor_batched_daily_batch(
settings.batch.cycle_start_date = cycle_start_date
run_date = date or date_type.today().isoformat()
floor_root = resolve_floor_video_dir(settings.batch.root_dir, settings.batch.location)
day_dir = floor_root / run_date
day_dir = Path(settings.batch.root_dir) / run_date
output_dir = day_dir / settings.batch.output_subdir
output_dir.mkdir(parents=True, exist_ok=True)
@@ -198,10 +193,7 @@ def run_tensor_batched_daily_batch(
break
if frame_index % inference_stride == 0 or any(not last_batch_tracks[active_cams[i][0]] for i in current_active):
current_stream_ids = [active_cams[idx][0] for idx in current_active]
batch_tracks_list = shared_tracker.infer_batch(
current_frames, stream_ids=current_stream_ids, crop_rects=current_crops
)
batch_tracks_list = shared_tracker.infer_batch(current_frames, crop_rects=current_crops)
for i, idx in enumerate(current_active):
cam_id = active_cams[idx][0]
last_batch_tracks[cam_id] = batch_tracks_list[i]
@@ -307,8 +299,7 @@ def run_hybrid_daily_batch(
settings.batch.cycle_start_date = cycle_start_date
run_date = date or date_type.today().isoformat()
floor_root = resolve_floor_video_dir(settings.batch.root_dir, settings.batch.location)
day_dir = floor_root / run_date
day_dir = Path(settings.batch.root_dir) / run_date
output_dir = day_dir / settings.batch.output_subdir
output_dir.mkdir(parents=True, exist_ok=True)
@@ -464,10 +455,7 @@ def run_hybrid_daily_batch(
# 2. Batched GPU inference (Synchronous on main thread)
if frame_index % inference_stride == 0 or any(not last_batch_tracks[active_cams[i][0]] for i in current_active_now):
current_stream_ids = [active_cams[idx][0] for idx in current_active_now]
batch_tracks_list = shared_tracker.infer_batch(
current_frames, stream_ids=current_stream_ids, crop_rects=current_crops
)
batch_tracks_list = shared_tracker.infer_batch(current_frames, crop_rects=current_crops)
for i, idx in enumerate(current_active_now):
cam_id = active_cams[idx][0]
last_batch_tracks[cam_id] = batch_tracks_list[i]
@@ -595,8 +583,7 @@ def run_daily_batch(
)
run_date = date or date_type.today().isoformat()
floor_root = resolve_floor_video_dir(settings.batch.root_dir, settings.batch.location)
day_dir = floor_root / run_date
day_dir = Path(settings.batch.root_dir) / run_date
output_dir = day_dir / settings.batch.output_subdir
output_dir.mkdir(parents=True, exist_ok=True)
+5 -59
View File
@@ -5,15 +5,8 @@ from __future__ import annotations
import argparse
from chicken_counter.batch_runner import run_daily_batch
from chicken_counter.config import load_batch_config, load_camera_config, load_or_synthesize_floor_config
from chicken_counter.discovery import discover_farm_structure, sync_floor_configs
from chicken_counter.mortality import (
DEFAULT_CONFIG_PATH,
DEFAULT_MORTALITY_DIR,
DEFAULT_MODEL_PATH,
run_all_coop_mortality,
run_mortality_count,
)
from chicken_counter.config import load_batch_config, load_camera_config
from chicken_counter.mortality import DEFAULT_CONFIG_PATH, DEFAULT_MORTALITY_DIR, DEFAULT_MODEL_PATH, run_mortality_count
from chicken_counter.pipeline import run_pipeline
@@ -28,15 +21,7 @@ def build_parser() -> argparse.ArgumentParser:
run_parser.add_argument("--progress-bar", action="store_true", help="Show a terminal progress bar.")
batch_parser = subparsers.add_parser("batch", help="Run the daily Cycle7 multi-camera batch.")
batch_parser.add_argument(
"--config",
"--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("--config", required=True, help="Path to batch config YAML/JSON.")
batch_parser.add_argument(
"--date",
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.")
sync_parser = subparsers.add_parser("sync-configs", help="Auto-generate missing configs/floor_config/*.yaml files from VIDEOS.")
sync_parser.add_argument("--videos-root", default=None, help="Base VIDEOS directory.")
sync_parser.add_argument("--configs-dir", default=None, help="Destination configs directory.")
sync_parser.add_argument("--dry-run", action="store_true", help="Preview generated configs without writing files.")
mortality_parser = subparsers.add_parser("mortality", help="Run whole-image mortality chicken detection on photo(s).")
mortality_parser.add_argument("--config", default=DEFAULT_CONFIG_PATH, help="Path to mortality YAML config file.")
mortality_parser.add_argument("--input", "-i", default=None, help="Path to image file or directory containing images.")
mortality_parser.add_argument("--coop", default=None, help="Specific coop/cage ID to process (e.g. K1, K2).")
mortality_parser.add_argument("--all-coops", action="store_true", help="Batch-process mortality for ALL discovered coops.")
mortality_parser.add_argument("--videos-root", default=None, help="Base VIDEOS directory (defaults to ../VIDEOS).")
mortality_parser.add_argument("--output-dir", "-o", default=None, help="Directory to save annotated images and report.")
mortality_parser.add_argument("--model-path", "-m", default=None, help="Path to YOLO model (.onnx/.pt/.engine).")
mortality_parser.add_argument("--conf", type=float, default=None, help="Confidence threshold (overrides YAML config).")
@@ -85,39 +62,9 @@ def main() -> None:
parser = build_parser()
args = parser.parse_args()
if args.command == "sync-configs":
sync_floor_configs(
videos_root=args.videos_root,
configs_dir=args.configs_dir,
dry_run=args.dry_run,
)
return
if args.command == "mortality":
if getattr(args, "all_coops", False):
run_all_coop_mortality(
videos_root=args.videos_root,
date=getattr(args, "date", None),
config_path=args.config,
conf_threshold=args.conf,
iou_threshold=args.iou,
model_path=args.model_path,
two_pass=args.two_pass,
)
return
input_path = args.input
target_coop = getattr(args, "coop", None)
if not input_path and target_coop:
farm_struct = discover_farm_structure(args.videos_root)
coop_info = farm_struct.get_coop(target_coop)
if coop_info and coop_info.mortality_dir and coop_info.mortality_dir.is_dir():
input_path = str(coop_info.mortality_dir)
else:
print(f"[mortality] ⚠️ Mortality directory for coop {target_coop} not found.")
run_mortality_count(
input_path=input_path,
input_path=args.input,
output_dir=args.output_dir,
model_path=args.model_path,
conf_threshold=args.conf,
@@ -127,12 +74,11 @@ def main() -> None:
two_pass=args.two_pass,
config_path=args.config,
date=getattr(args, "date", None),
coop=target_coop,
)
return
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):
settings.batch.output_subdir = args.output_subdir
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(
settings: BatchSettings,
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
import json
import re
from datetime import datetime, timezone
from pathlib import Path
@@ -12,8 +11,6 @@ import numpy as np
import yaml
from ultralytics import YOLO
from chicken_counter.discovery import discover_farm_structure, get_coop_for_location
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_MODEL_PATH = str(PROJECT_ROOT / "models" / "chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt")
DEFAULT_MORTALITY_DIR = str((PROJECT_ROOT.parent / "VIDEOS" / "cycle7" / "kandang-atas" / "mortality").resolve())
@@ -41,60 +38,6 @@ def load_mortality_config(config_path: str | Path) -> dict:
return data.get("mortality", data)
def get_camera_source_for_coop(coop_id: str, config_path: str | Path | None = None) -> str | int | None:
"""Retrieve configured stationary camera source (RTSP URL, device index, or snapshot URL) for a coop."""
cfg = load_mortality_config(config_path or DEFAULT_CONFIG_PATH)
cameras = cfg.get("cameras", {})
if not cameras:
return None
if coop_id in cameras:
return cameras[coop_id]
for k, v in cameras.items():
if str(k).upper() == str(coop_id).upper():
return v
if "default" in cameras:
default_tmpl = str(cameras["default"])
num_m = re.search(r"\d+", coop_id)
coop_num = num_m.group(0) if num_m else "1"
return default_tmpl.format(coop=coop_id, coop_num=coop_num)
return None
def capture_camera_snapshot(
source: str | int,
timeout_sec: float = 5.0,
warmup_frames: int = 5,
) -> np.ndarray | None:
"""Capture a single clear still frame from a stationary RTSP stream, HTTP snapshot URL, or video device."""
if isinstance(source, str) and source.startswith(("http://", "https://")) and (source.endswith((".jpg", ".jpeg", ".png", "snapshot", "image")) or "snapshot" in source.lower()):
try:
import urllib.request
req = urllib.request.Request(source, headers={"User-Agent": "ChickenCounter/1.0"})
with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
arr = np.asarray(bytearray(resp.read()), dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if img is not None:
return img
except Exception as err:
print(f"[mortality] ⚠️ HTTP snapshot capture failed from {source}: {err}")
src_val = int(source) if isinstance(source, str) and source.isdigit() else source
cap = cv2.VideoCapture(src_val)
if not cap.isOpened():
print(f"[mortality] ⚠️ Failed to open camera source: {source}")
return None
try:
frame = None
for _ in range(max(1, warmup_frames)):
ret, tmp = cap.read()
if ret and tmp is not None:
frame = tmp
return frame
finally:
cap.release()
def deduplicate_boxes(
boxes_list: list[dict],
dedupe_radius_px: float = 30.0,
@@ -303,7 +246,6 @@ def run_mortality_count(
imgsz: int | None = None,
config_path: str | Path | None = DEFAULT_CONFIG_PATH,
date: str | None = None,
coop: str | None = None,
) -> dict:
config_path = resolve_path(config_path) if config_path else DEFAULT_CONFIG_PATH
cfg = load_mortality_config(config_path) if config_path else {}
@@ -351,21 +293,7 @@ def run_mortality_count(
print(f"[mortality] No input images found at: {input_path}")
return {"total_images": 0, "results": []}
# Resolve parent coop and covered floors
inferred_coop = coop
if not inferred_coop:
for part in reversed(input_p.parts):
if part.lower() != "mortality" and not re.match(r"^\d{4}-\d{2}-\d{2}$", part):
inferred_coop = get_coop_for_location(part)
break
inferred_coop = inferred_coop or "kandang-atas"
farm_struct = discover_farm_structure()
covered_floors = farm_struct.get_covered_floors(inferred_coop)
covered_str = ", ".join(covered_floors)
mode_str = "2x Similarity & Adaptive Accuracy Refinement" if two_pass else "Single-Pass Direct"
print(f"[mortality] Coop: {inferred_coop} (Covered Floors: {covered_str})")
print(f"[mortality] Mode: {mode_str}")
print(f"[mortality] Loading model from: {model_path} (device: {device})")
print(f"[mortality] Config: classes={classes}, conf={conf_threshold}, iou={iou_threshold}, min_area={min_box_area_px}px, dedupe_radius={dedupe_radius_px}px")
@@ -507,9 +435,6 @@ def run_mortality_count(
report_path = target_out_dir / "mortality_report.json"
report_data = {
"date": date or datetime.now().strftime("%Y-%m-%d"),
"coop": inferred_coop,
"location": inferred_coop,
"covered_floors": covered_floors,
"generated_at": datetime.now(timezone.utc).isoformat(),
"mode": "similarity_two_pass" if two_pass else "single_pass",
"config_file": str(config_path) if config_path else None,
@@ -527,75 +452,5 @@ def run_mortality_count(
with open(report_path, "w") as f:
json.dump(report_data, f, indent=2)
print(f"[mortality] [{inferred_coop}] Summary report saved to: {report_path} (Total Carcasses: {total_mortality_count} across {len(image_files)} images)")
print(f"[mortality] Summary report saved to: {report_path} (Total Carcasses: {total_mortality_count} across {len(image_files)} images)")
return report_data
def run_all_coop_mortality(
videos_root: str | Path | None = None,
date: str | None = None,
config_path: str | Path | None = DEFAULT_CONFIG_PATH,
conf_threshold: float | None = None,
iou_threshold: float | None = None,
model_path: str | Path | None = None,
device: str | int | None = None,
two_pass: bool | None = None,
) -> dict[str, dict]:
"""Batch-process mortality detection across all discovered coop mortality folders."""
farm_struct = discover_farm_structure(videos_root)
all_m_dirs = farm_struct.all_mortality_dirs()
if not all_m_dirs:
print(f"[mortality-batch] ⚠️ No coop mortality directories found under {farm_struct.videos_root}")
return {}
print(f"\n=================================================================")
print(f" Starting Batch Mortality Detection across {len(all_m_dirs)} Coops")
print(f" Discovered Coops: {', '.join(sorted(all_m_dirs.keys()))}")
if date:
print(f" Target Date: {date}")
print(f"=================================================================\n")
summary: dict[str, dict] = {}
total_carcasses_all = 0
total_images_all = 0
for coop_id in sorted(all_m_dirs.keys()):
mdir = all_m_dirs[coop_id]
covered = farm_struct.get_covered_floors(coop_id)
covered_str = ", ".join(covered)
print(f"\n>>> [MORTALITY] Processing Coop {coop_id} (Floors: {covered_str}) <<<")
print(f" Directory: {mdir}")
try:
res = run_mortality_count(
input_path=mdir,
config_path=config_path,
coop=coop_id,
date=date,
conf_threshold=conf_threshold,
iou_threshold=iou_threshold,
model_path=model_path,
device=device,
two_pass=two_pass,
)
summary[coop_id] = res
c_count = res.get("total_mortality_count", 0)
i_count = res.get("total_images", 0)
total_carcasses_all += c_count
total_images_all += i_count
print(f" ✅ Coop {coop_id} Done: {c_count} carcasses across {i_count} photos.")
except Exception as err:
print(f" ❌ Coop {coop_id} Error: {err}")
summary[coop_id] = {"error": str(err), "coop": coop_id}
print(f"\n=================================================================")
print(f" Batch Mortality Summary:")
for coop_id, res in summary.items():
if "error" in res:
print(f" {coop_id}: ERROR ({res['error']})")
else:
print(f" {coop_id} (Floors: {', '.join(farm_struct.get_covered_floors(coop_id))}): {res.get('total_mortality_count', 0)} carcasses ({res.get('total_images', 0)} images)")
print(f" TOTAL SITE MORTALITY: {total_carcasses_all} carcasses across {total_images_all} images")
print(f"=================================================================\n")
return summary
+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
import time
from pathlib import Path
from typing import Any
import numpy as np
import yaml
from ultralytics import YOLO
from ultralytics.trackers.track import TRACKER_MAP
from ultralytics.utils import IterableSimpleNamespace
from chicken_counter.config import CameraConfig
from chicken_counter.engine_utils import ensure_compatible_model
@@ -36,10 +32,6 @@ class DetectionTracker:
self.tracker_config_path = str(Path(config.tracker.tracker_config_path))
self.verbose = config.performance.verbose
self._infer_count = 0
self._stream_trackers: dict[str, Any] = {}
self._tracker_cfg_obj: IterableSimpleNamespace | None = None
self._init_tracker_cfg()
print(
f"[model] loaded {self.model_kind} from {model_path} "
f"(imgsz={config.detection.imgsz}, device={config.detection.device})"
@@ -47,49 +39,7 @@ class DetectionTracker:
if self.model_kind == "engine":
print("[model] TensorRT engine active; runtime half flag is ignored")
def _init_tracker_cfg(self) -> None:
try:
tracker_file = Path(self.tracker_config_path)
if tracker_file.exists():
with open(tracker_file, "r", encoding="utf-8") as f:
raw_cfg = yaml.safe_load(f) or {}
self._tracker_cfg_obj = IterableSimpleNamespace(**raw_cfg)
self._tracker_cfg_obj.device = self.config.detection.device
except Exception as exc:
if self.verbose:
print(f"[tracker] error reading tracker config: {exc}")
self._tracker_cfg_obj = None
def get_or_create_tracker(self, stream_id: str = "default") -> Any:
if stream_id not in self._stream_trackers:
if self._tracker_cfg_obj is not None and self._tracker_cfg_obj.tracker_type in TRACKER_MAP:
tracker_cls = TRACKER_MAP[self._tracker_cfg_obj.tracker_type]
self._stream_trackers[stream_id] = tracker_cls(args=self._tracker_cfg_obj)
else:
default_args = IterableSimpleNamespace(
tracker_type="botsort",
track_high_thresh=0.5,
track_low_thresh=0.1,
new_track_thresh=0.6,
track_buffer=self.config.tracker.track_buffer,
match_thresh=0.8,
fuse_score=True,
gmc_method="none",
proximity_thresh=0.5,
appearance_thresh=0.25,
with_reid=False,
model="auto",
device=self.config.detection.device,
)
self._stream_trackers[stream_id] = TRACKER_MAP["botsort"](args=default_args)
return self._stream_trackers[stream_id]
def reset_tracking(self, stream_id: str | None = None) -> None:
if stream_id:
if stream_id in self._stream_trackers:
del self._stream_trackers[stream_id]
else:
self._stream_trackers.clear()
def reset_tracking(self) -> None:
if hasattr(self.model, "predictor"):
self.model.predictor = None
@@ -97,25 +47,119 @@ class DetectionTracker:
self,
frame: np.ndarray,
*,
stream_id: str = "default",
crop_rect: tuple[int, int, int, int] | None = None,
) -> list[TrackObservation]:
results = self.infer_batch([frame], stream_ids=[stream_id], crop_rects=[crop_rect])
return results[0] if results else []
offset_x = 0
offset_y = 0
source = frame
if crop_rect is not None:
x1, y1, x2, y2 = crop_rect
source = frame[y1:y2, x1:x2]
offset_x, offset_y = x1, y1
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(
self,
frames: list[np.ndarray],
*,
stream_ids: list[str] | None = None,
crop_rects: list[tuple[int, int, int, int] | None] | None = None,
) -> list[list[TrackObservation]]:
if not frames:
return []
if stream_ids is None:
stream_ids = [f"cam_{i}" for i in range(len(frames))]
sources = []
offsets = []
for i, frame in enumerate(frames):
@@ -128,7 +172,10 @@ class DetectionTracker:
sources.append(frame)
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,
"iou": self.config.detection.iou,
"classes": self.config.detection.classes,
@@ -137,19 +184,19 @@ class DetectionTracker:
"device": self.config.detection.device,
}
if self.model_kind != "engine" and self.config.performance.half:
predict_kwargs["half"] = True
track_kwargs["half"] = True
if self.verbose:
t_start = time.monotonic()
try:
results = self.model.predict(source=sources, **predict_kwargs)
results = self.model.track(**track_kwargs)
except Exception:
results = []
for src in sources:
kw = dict(predict_kwargs)
kw = dict(track_kwargs)
kw["source"] = src
res = self.model.predict(**kw)
res = self.model.track(**kw)
if res:
results.append(res[0])
@@ -159,48 +206,53 @@ class DetectionTracker:
batch_tracks: list[list[TrackObservation]] = []
for idx, result in enumerate(results):
stream_id = stream_ids[idx] if idx < len(stream_ids) else f"stream_{idx}"
tracker = self.get_or_create_tracker(stream_id)
orig_src = sources[idx]
offset_x, offset_y = offsets[idx]
crop_rect = crop_rects[idx] if crop_rects and idx < len(crop_rects) else None
boxes = result.boxes
if boxes is None or len(boxes) == 0:
tracker.update(np.empty((0, 6)), orig_src)
if boxes is None or boxes.id is None:
batch_tracks.append([])
continue
det_np = boxes.cpu().numpy()
tracks_raw = tracker.update(det_np, orig_src)
ids = boxes.id.int().cpu().numpy()
classes = boxes.cls.int().cpu().numpy()
confidences = boxes.conf.cpu().numpy()
xyxy = boxes.xyxy.int().cpu().numpy()
if len(tracks_raw) == 0:
batch_tracks.append([])
continue
mask_polygons = None
if result.masks is not None and result.masks.xy is not None:
mask_polygons = result.masks.xy
tracks: list[TrackObservation] = []
for row in tracks_raw:
# Format: [x1, y1, x2, y2, track_id, conf, cls, idx]
x1 = int(row[0]) + offset_x
y1 = int(row[1]) + offset_y
x2 = int(row[2]) + offset_x
y2 = int(row[3]) + offset_y
track_id = int(row[4])
conf = float(row[5]) if len(row) > 5 else float(self.config.detection.conf)
cls_id = int(row[6]) if len(row) > 6 else 0
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=cls_id,
confidence=conf,
class_id=class_id,
confidence=confidence,
bbox_xyxy=(x1, y1, x2, y2),
centroid=centroid,
mask_polygon_xy=None,
mask_polygon_xy=polygon,
)
)
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"
if [ ! -d "$VIDEOS_BASE" ]; then
echo "[dashboard] 📁 Sibling directory '$VIDEOS_BASE' not found; creating skeleton directory..."
mkdir -p "$VIDEOS_BASE/cycle7/K1/mortality" "$VIDEOS_BASE/cycle7/K2/mortality" "$VIDEOS_BASE/cycle7/K3/mortality" "$VIDEOS_BASE/cycle7/K4/mortality" "$VIDEOS_BASE/cycle7/K5/mortality"
mkdir -p "$VIDEOS_BASE/cycle7"
fi
# 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 '',
generated_at TEXT NOT NULL DEFAULT '',
UNIQUE(date, location, camera_id)
);
CREATE INDEX IF NOT EXISTS idx_batch_date ON batch_runs (date);
CREATE INDEX IF NOT EXISTS idx_batch_camera ON batch_runs (camera_id);
CREATE INDEX IF NOT EXISTS idx_batch_location ON batch_runs (location);
)
"""
INSERT_SQL = """
@@ -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)
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA journal_mode=WAL")
conn.executescript(CREATE_TABLE)
conn.execute(CREATE_TABLE)
rows = 0
for camera_id, entry in cameras.items():
+22 -418
View File
@@ -39,10 +39,6 @@ body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:h
/* Mortality UI */
.mort-badge{background:#2a1518;color:#ff6b81;border:1px solid #ff4757;font-weight:700;padding:2px 6px;border-radius:4px;font-size:10px}
.mort-filter-btn{background:#0e0e14;border:1px solid #333;color:#888;border-radius:4px;padding:2px 6px;font-size:9px;cursor:pointer;font-weight:600;transition:all .15s}
.mort-filter-btn:hover{color:#fff;border-color:#555}
.mort-filter-btn.active{background:#ff4757;color:#fff;border-color:#ff4757}
.mort-coop-tag{display:inline-block;background:#33141a;color:#ff6b81;padding:1px 4px;border-radius:3px;font-size:9px;font-weight:700;margin-right:3px}
.mort-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:6px}
.mort-card{background:#0e0e14;border-radius:6px;overflow:hidden;cursor:pointer;border:1px solid #222;transition:all .15s;text-align:left}
.mort-card:hover{border-color:#ff4757;transform:translateY(-1px)}
@@ -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 .cnt{color:#ff9f43;font-weight:700}
.mort-upload-btn{background:#2a1518;color:#ff6b81;border:1px solid #ff4757;font-weight:700;padding:2px 8px;border-radius:4px;font-size:10px;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:3px}
.mort-upload-btn:hover{background:#ff4757;color:#fff}
/* Upload & Capture Modal */
#mort-upload-modal{display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,.85);z-index:1000;align-items:center;justify-content:center;backdrop-filter:blur(4px)}
#mort-upload-modal.active{display:flex}
.mort-upload-box{background:#16161e;border:1px solid #333;border-radius:10px;width:90%;max-width:480px;padding:18px;box-shadow:0 8px 32px rgba(0,0,0,.8);position:relative;color:#ddd}
.mort-upload-box h2{font-size:15px;margin:0 0 10px 0;color:#fff;display:flex;align-items:center;gap:6px}
.mort-tab-row{display:flex;gap:6px;margin-bottom:12px;border-bottom:1px solid #282834;padding-bottom:8px}
.mort-tab-btn{background:none;border:none;color:#888;font-size:11px;font-weight:600;cursor:pointer;padding:4px 10px;border-radius:4px;transition:all .15s}
.mort-tab-btn:hover{color:#fff}
.mort-tab-btn.active{background:#2a1518;color:#ff6b81;border:1px solid #ff4757}
.mort-cam-preview-box{background:#0e0e14;border:1px solid #333;border-radius:6px;overflow:hidden;position:relative;height:180px;display:flex;align-items:center;justify-content:center;margin-bottom:10px}
.mort-cam-preview-box img{width:100%;height:100%;object-fit:contain;background:#000}
.mort-cam-preview-overlay{position:absolute;bottom:6px;right:8px;background:rgba(0,0,0,.75);padding:2px 8px;border-radius:4px;font-size:10px;color:#2ed573;border:1px solid #333}
.mort-capture-btn{width:100%;padding:10px;background:#ff4757;color:#fff;border:none;border-radius:6px;font-weight:700;font-size:13px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px;transition:background .15s}
.mort-capture-btn:hover{background:#ff6b81}
.mort-capture-btn:disabled{background:#333;color:#777;cursor:not-allowed}
.mort-upload-row{display:flex;gap:10px;margin-bottom:12px}
.mort-upload-field{flex:1}
.mort-upload-field label{display:block;font-size:10px;color:#888;margin-bottom:4px;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
.mort-upload-field select, .mort-upload-field input[type="date"]{width:100%;padding:6px 8px;background:#0e0e14;border:1px solid #333;border-radius:6px;color:#fff;font-size:11px;outline:none;box-sizing:border-box}
.mort-dropzone{border:2px dashed #444;border-radius:8px;padding:18px 12px;text-align:center;background:#0e0e14;cursor:pointer;transition:border-color .15s, background .15s}
.mort-dropzone:hover, .mort-dropzone.dragover{border-color:#ff4757;background:#1b1419}
.mort-dropzone-icon{font-size:26px;margin-bottom:4px;display:block}
.mort-dropzone-txt{font-size:12px;color:#aaa}
.mort-dropzone-sub{font-size:10px;color:#666;margin-top:2px}
.mort-preview-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(70px, 1fr));gap:6px;margin-top:10px;max-height:140px;overflow-y:auto}
.mort-preview-item{position:relative;border-radius:4px;overflow:hidden;border:1px solid #333;height:65px}
.mort-preview-item img{width:100%;height:100%;object-fit:cover}
.mort-preview-item .rm-btn{position:absolute;top:2px;right:2px;background:rgba(0,0,0,.7);color:#ff4757;border:none;border-radius:50%;width:16px;height:16px;font-size:10px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1}
.mort-submit-btn{width:100%;padding:9px;background:#ff4757;color:#fff;border:none;border-radius:6px;font-weight:700;font-size:12px;cursor:pointer;margin-top:12px;transition:background .15s}
.mort-submit-btn:hover{background:#ff6b81}
.mort-submit-btn:disabled{background:#333;color:#777;cursor:not-allowed}
.mort-upload-status{margin-top:10px;font-size:11px;text-align:center;display:none}
/* Fullscreen Image Modal */
#mort-modal{display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,.88);z-index:999;align-items:center;justify-content:center;flex-direction:column;backdrop-filter:blur(4px)}
#mort-modal.active{display:flex}
@@ -119,15 +79,11 @@ body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:h
<div class="cam-list" id="cam-list"></div>
<!-- Mortality / Carcass Detection Panel -->
<div class="mort-panel" id="mort-panel">
<div class="mort-panel" id="mort-panel" style="display:none">
<h3>
<span>&#x2620;&#xfe0f; Mortality Scans</span>
<span style="display:flex;gap:5px;align-items:center">
<button class="mort-upload-btn" onclick="openMortUploadModal()">&#x1f4f7; Upload / Capture</button>
<span class="mort-badge" id="mort-header-badge">0</span>
</span>
</h3>
<div id="mort-coop-filters" style="display:flex;gap:4px;flex-wrap:wrap;margin:4px 0 6px 0"></div>
<div id="mort-summary-text" style="color:#777;font-size:10px;margin-bottom:4px"></div>
<div class="mort-grid" id="mort-grid"></div>
</div>
@@ -138,71 +94,6 @@ body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:h
</div>
</div>
<!-- Modal for Capturing Mortality Photos from Stationary Camera or Uploading -->
<div id="mort-upload-modal" onclick="closeMortUploadModal(event)">
<div class="mort-upload-box" onclick="event.stopPropagation()">
<button class="close-btn" onclick="closeMortUploadModal(event)" style="top:10px;right:14px;font-size:24px">&times;</button>
<h2>&#x1f4f8; Mortality Inspection Camera</h2>
<div class="mort-tab-row">
<button class="mort-tab-btn active" id="tab-btn-cam" onclick="switchMortModalTab('cam')">&#x1f4f7; Stationary Camera</button>
<button class="mort-tab-btn" id="tab-btn-file" onclick="switchMortModalTab('file')">&#x1f4c1; File Upload</button>
</div>
<div class="mort-upload-row">
<div class="mort-upload-field">
<label for="mort-upload-coop">Target Coop / Cage</label>
<select id="mort-upload-coop" onchange="onMortCoopChanged()">
<option value="K1">K1 (L1, L2, L3)</option>
<option value="K2">K2 (L1, L2, L3)</option>
<option value="K3">K3 (L1, L2, L3)</option>
<option value="K4">K4 (L1, L2)</option>
<option value="K5">K5 (L1, L2)</option>
<option value="kandang-atas">kandang-atas</option>
</select>
</div>
<div class="mort-upload-field">
<label for="mort-upload-date">Date</label>
<input type="date" id="mort-upload-date">
</div>
</div>
<!-- Tab 1: Stationary Camera Capture (Default) -->
<div id="mort-cam-tab-content">
<div class="mort-cam-preview-box" id="mort-cam-preview-box">
<img id="mort-cam-live-img" src="" alt="Stationary Camera View" onerror="this.style.display='none';document.getElementById('mort-cam-offline-msg').style.display='block'">
<div id="mort-cam-offline-msg" style="display:none;color:#777;font-size:11px;text-align:center;padding:12px">
&#x1f4f7; Stationary Camera Preview<br><span style="color:#555;font-size:9px">Click Capture to snap high-res photo</span>
</div>
<div class="mort-cam-preview-overlay" id="mort-cam-status-overlay">&#x1f534; Camera Ready</div>
</div>
<button class="mort-capture-btn" id="mort-capture-btn" onclick="captureFromStationaryCamera()">
&#x1f4f8; Snap &amp; Count Batch from Camera
</button>
<div style="font-size:10px;color:#666;text-align:center;margin-top:6px">
Place batch of chickens on table &rarr; click Snap &rarr; clear table &rarr; repeat if needed
</div>
</div>
<!-- Tab 2: Manual File Upload -->
<div id="mort-file-tab-content" style="display:none">
<input type="file" id="mort-file-input" accept="image/*" multiple style="display:none" onchange="handleMortFilesSelected(event)">
<div class="mort-dropzone" id="mort-dropzone" onclick="document.getElementById('mort-file-input').click()">
<span class="mort-dropzone-icon">&#x1f4f8;</span>
<div class="mort-dropzone-txt">Browse or Drop Photos</div>
<div class="mort-dropzone-sub">Select multiple photos for this coop</div>
</div>
<div class="mort-preview-grid" id="mort-preview-grid"></div>
<button class="mort-submit-btn" id="mort-submit-btn" onclick="submitMortalityUpload()" disabled>
Count Mortalities (0 photos selected)
</button>
</div>
<div class="mort-upload-status" id="mort-upload-status"></div>
</div>
</div>
<!-- Modal for high-res mortality preview -->
<div id="mort-modal" onclick="closeMortModal(event)">
<button class="close-btn" onclick="closeMortModal(event)">&times;</button>
@@ -354,101 +245,33 @@ function loadHistory() {
}
}
var allMortalityData = [];
var selectedMortCoop = "ALL";
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");
if (!reports || !reports.length) {
fetch("/api/mortality/latest").then(r => r.ok ? r.json() : null).then(latest => {
if (!latest || latest.error || !latest.results || !latest.results.length) {
panel.style.display = "none";
return;
}
allMortalityData = [latest];
renderMortalityUI();
}).catch(function() { panel.style.display = "none"; });
if (!data || data.error || !data.results || !data.results.length) {
panel.style.display = "none";
return;
}
allMortalityData = reports;
renderMortalityUI();
panel.style.display = "block";
var totalCarcasses = data.total_mortality_count !== undefined ? data.total_mortality_count : data.results.reduce((acc, x) => acc + (x.count || 0), 0);
document.getElementById("mort-header-badge").textContent = totalCarcasses + " detected";
document.getElementById("mort-summary-text").textContent = (data.date ? data.date + " \u2022 " : "") + data.results.length + " photo(s) scanned";
var grid = document.getElementById("mort-grid");
grid.innerHTML = data.results.map(function(item) {
var imgSrc = "/api/mortality/image/" + encodeURIComponent(item.output_image || item.input_image);
var title = (item.output_image || item.input_image || "scan").replace(/^output_/, "");
var count = item.count !== undefined ? item.count : (item.detections ? item.detections.length : 0);
return '<div class="mort-card" onclick="openMortModal(\'' + imgSrc + '\', \'' + title + ' (' + count + ' carcasses)\')">' +
'<img src="' + imgSrc + '" alt="' + title + '" loading="lazy" onerror="this.style.opacity=0.3">' +
'<div class="info">' +
'<span style="max-width:70px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + title + '">' + title + '</span>' +
'<span class="cnt">' + count + ' 💀</span>' +
'</div></div>';
}).join("");
}).catch(function() {});
}
function renderMortalityUI() {
var panel = document.getElementById("mort-panel");
if (!allMortalityData || !allMortalityData.length) {
panel.style.display = "none";
return;
}
panel.style.display = "block";
var coopsSet = {};
allMortalityData.forEach(function(rep) {
var c = rep.coop || rep.location || "kandang-atas";
coopsSet[c] = (coopsSet[c] || 0) + (rep.total_mortality_count || 0);
});
var coopsList = Object.keys(coopsSet).sort();
var filterDiv = document.getElementById("mort-coop-filters");
if (coopsList.length > 1) {
var fHtml = '<button class="mort-filter-btn' + (selectedMortCoop === 'ALL' ? ' active' : '') + '" onclick="setMortCoopFilter(\'ALL\')">ALL</button>';
coopsList.forEach(function(c) {
fHtml += '<button class="mort-filter-btn' + (selectedMortCoop === c ? ' active' : '') + '" onclick="setMortCoopFilter(\'' + c + '\')">' + c + '</button>';
});
filterDiv.innerHTML = fHtml;
filterDiv.style.display = "flex";
} else {
filterDiv.style.display = "none";
}
var filteredReports = selectedMortCoop === "ALL"
? allMortalityData
: allMortalityData.filter(function(r) { return (r.coop || r.location || "").toUpperCase() === selectedMortCoop.toUpperCase(); });
var totalCarcasses = 0;
var allItems = [];
filteredReports.forEach(function(rep) {
totalCarcasses += (rep.total_mortality_count || 0);
var cName = rep.coop || rep.location || "kandang-atas";
var cFloors = rep.covered_floors ? rep.covered_floors.join(", ") : "";
(rep.results || []).forEach(function(res) {
allItems.push({
item: res,
coop: cName,
floors: cFloors,
date: rep.date || ""
});
});
});
document.getElementById("mort-header-badge").textContent = totalCarcasses + " detected";
var summaryTxt = (selectedMortCoop === "ALL" ? "All Coops" : selectedMortCoop) + " \u2022 " + allItems.length + " scan(s)";
document.getElementById("mort-summary-text").textContent = summaryTxt;
var grid = document.getElementById("mort-grid");
grid.innerHTML = allItems.map(function(obj) {
var item = obj.item;
var imgSrc = "/api/mortality/image/" + encodeURIComponent(item.output_image || item.input_image);
var title = (item.output_image || item.input_image || "scan").replace(/^output_/, "");
var count = item.count !== undefined ? item.count : (item.detections ? item.detections.length : 0);
var captionText = '[' + obj.coop + (obj.floors ? ' (Floors: ' + obj.floors + ')' : '') + '] ' + title + ' (' + count + ' carcasses)';
return '<div class="mort-card" onclick="openMortModal(\'' + imgSrc + '\', \'' + captionText.replace(/'/g, "\\'") + '\')">' +
'<img src="' + imgSrc + '" alt="' + title + '" loading="lazy" onerror="this.style.opacity=0.3">' +
'<div class="info">' +
'<span style="max-width:85px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + title + '">' +
'<span class="mort-coop-tag">' + obj.coop + '</span>' + title + '</span>' +
'<span class="cnt">' + count + ' 💀</span>' +
'</div></div>';
}).join("");
}
function setMortCoopFilter(coop) {
selectedMortCoop = coop;
renderMortalityUI();
}
function openMortModal(src, caption) {
var modal = document.getElementById("mort-modal");
document.getElementById("mort-modal-img").src = src;
@@ -460,227 +283,8 @@ function closeMortModal(event) {
document.getElementById("mort-modal").classList.remove("active");
}
/* Mortality Inspection Camera & Upload Logic */
var selectedUploadFiles = [];
var mortModalTab = "cam";
function switchMortModalTab(tab) {
mortModalTab = tab;
var btnCam = document.getElementById("tab-btn-cam");
var btnFile = document.getElementById("tab-btn-file");
var contentCam = document.getElementById("mort-cam-tab-content");
var contentFile = document.getElementById("mort-file-tab-content");
if (tab === "cam") {
if (btnCam) btnCam.classList.add("active");
if (btnFile) btnFile.classList.remove("active");
if (contentCam) contentCam.style.display = "block";
if (contentFile) contentFile.style.display = "none";
refreshMortCamPreview();
} else {
if (btnFile) btnFile.classList.add("active");
if (btnCam) btnCam.classList.remove("active");
if (contentFile) contentFile.style.display = "block";
if (contentCam) contentCam.style.display = "none";
}
}
function onMortCoopChanged() {
if (mortModalTab === "cam") {
refreshMortCamPreview();
}
}
function refreshMortCamPreview() {
var coop = document.getElementById("mort-upload-coop").value || "K1";
var img = document.getElementById("mort-cam-live-img");
var offlineMsg = document.getElementById("mort-cam-offline-msg");
var overlay = document.getElementById("mort-cam-status-overlay");
if (!img) return;
overlay.textContent = "Connecting to " + coop + " camera...";
overlay.style.color = "#ff9f43";
img.onload = function() {
img.style.display = "block";
offlineMsg.style.display = "none";
overlay.textContent = "🟢 " + coop + " Camera Live";
overlay.style.color = "#2ed573";
};
img.onerror = function() {
img.style.display = "none";
offlineMsg.style.display = "block";
overlay.textContent = "⚪ Stationary Camera Ready";
overlay.style.color = "#aaa";
};
img.src = "/api/mortality/camera/preview?coop=" + encodeURIComponent(coop) + "&_t=" + Date.now();
}
function captureFromStationaryCamera() {
var coop = document.getElementById("mort-upload-coop").value || "K1";
var date = document.getElementById("mort-upload-date").value || new Date().toISOString().split("T")[0];
var btn = document.getElementById("mort-capture-btn");
var statusDiv = document.getElementById("mort-upload-status");
btn.disabled = true;
btn.innerHTML = "⏳ Snapping &amp; Counting with YOLO...";
statusDiv.style.display = "block";
statusDiv.style.color = "#ff9f43";
statusDiv.textContent = "Capturing frame from " + coop + " stationary camera and counting carcasses...";
fetch("/api/mortality/capture", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ coop: coop, date: date })
}).then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === "success") {
statusDiv.style.color = "#2ed573";
statusDiv.innerHTML = "✅ <b>Batch #" + data.total_images + ": " + data.batch_count + " carcass(es)</b> detected!<br>" +
"<span style='color:#ff9f43'>Total for " + coop + " Today: <b>" + data.total_mortality_count + " carcasses</b> across " + data.total_images + " batch(es)</span>";
btn.disabled = false;
btn.innerHTML = "📸 Snap &amp; Count Next Batch";
loadMortality();
setTimeout(refreshMortCamPreview, 600);
} else {
statusDiv.style.color = "#ff4757";
statusDiv.textContent = "❌ Capture error: " + (data.message || "Failed to capture frame");
btn.disabled = false;
btn.innerHTML = "📸 Retry Capture";
}
}).catch(function(err) {
statusDiv.style.color = "#ff4757";
statusDiv.textContent = "❌ Network error: " + err.message;
btn.disabled = false;
btn.innerHTML = "📸 Retry Capture";
});
}
function openMortUploadModal() {
var modal = document.getElementById("mort-upload-modal");
var today = new Date().toISOString().split("T")[0];
document.getElementById("mort-upload-date").value = today;
var coopSelect = document.getElementById("mort-upload-coop");
if (allMortalityData && allMortalityData.length) {
var coopsSet = {};
allMortalityData.forEach(function(r) {
var c = r.coop || r.location;
if (c) coopsSet[c] = true;
});
["K1", "K2", "K3", "K4", "K5", "kandang-atas"].forEach(function(c) { coopsSet[c] = true; });
var list = Object.keys(coopsSet).sort();
if (list.length > 0) {
coopSelect.innerHTML = list.map(function(c) {
return '<option value="' + c + '">' + c + '</option>';
}).join("");
}
}
selectedUploadFiles = [];
renderUploadPreviews();
document.getElementById("mort-upload-status").style.display = "none";
modal.classList.add("active");
switchMortModalTab("cam");
}
function closeMortUploadModal(event) {
var modal = document.getElementById("mort-upload-modal");
modal.classList.remove("active");
}
function handleMortFilesSelected(event) {
var files = event.target.files;
if (!files || !files.length) return;
for (var i = 0; i < files.length; i++) {
selectedUploadFiles.push(files[i]);
}
renderUploadPreviews();
}
function removeUploadFile(idx) {
selectedUploadFiles.splice(idx, 1);
renderUploadPreviews();
}
function renderUploadPreviews() {
var grid = document.getElementById("mort-preview-grid");
var btn = document.getElementById("mort-submit-btn");
if (!selectedUploadFiles.length) {
grid.innerHTML = "";
btn.disabled = true;
btn.textContent = "Count Mortalities (0 photos selected)";
return;
}
btn.disabled = false;
btn.textContent = "Scan & Count Chickens (" + selectedUploadFiles.length + " photo" + (selectedUploadFiles.length > 1 ? "s" : "") + ")";
grid.innerHTML = selectedUploadFiles.map(function(file, idx) {
var url = URL.createObjectURL(file);
return '<div class="mort-preview-item">' +
'<img src="' + url + '" alt="preview">' +
'<button class="rm-btn" onclick="removeUploadFile(' + idx + ')">&times;</button>' +
'</div>';
}).join("");
}
function submitMortalityUpload() {
if (!selectedUploadFiles.length) return;
var coop = document.getElementById("mort-upload-coop").value || "K1";
var date = document.getElementById("mort-upload-date").value || new Date().toISOString().split("T")[0];
var btn = document.getElementById("mort-submit-btn");
var statusDiv = document.getElementById("mort-upload-status");
btn.disabled = true;
btn.textContent = "⏳ Running YOLO Detection...";
statusDiv.style.display = "block";
statusDiv.style.color = "#ff9f43";
statusDiv.textContent = "Uploading " + selectedUploadFiles.length + " photo(s) and counting carcasses...";
var formData = new FormData();
formData.append("coop", coop);
formData.append("date", date);
for (var i = 0; i < selectedUploadFiles.length; i++) {
formData.append("images", selectedUploadFiles[i]);
}
fetch("/api/mortality/upload", {
method: "POST",
body: formData
}).then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === "success") {
statusDiv.style.color = "#2ed573";
statusDiv.innerHTML = "✅ <b>" + data.total_mortality_count + " carcasses</b> detected across " + data.total_images + " photo(s) for <b>" + coop + "</b> (" + date + ")!";
btn.textContent = "✅ Scan Completed";
loadMortality();
setTimeout(function() {
closeMortUploadModal();
}, 2500);
} else {
statusDiv.style.color = "#ff4757";
statusDiv.textContent = "❌ Error: " + (data.message || "Failed to process photos");
btn.disabled = false;
btn.textContent = "Retry Scan";
}
}).catch(function(err) {
statusDiv.style.color = "#ff4757";
statusDiv.textContent = "❌ Network error: " + err.message;
btn.disabled = false;
btn.textContent = "Retry Scan";
});
}
document.addEventListener("keydown", function(e) {
if (e.key === "Escape") {
closeMortModal();
closeMortUploadModal();
}
if (e.key === "Escape") closeMortModal();
});
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()