diff --git a/.gitignore b/.gitignore
index 6195a92..a4e854e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -226,3 +226,6 @@ output/
*.db-shm
*.db-wal
*.2026*
+.vscode/
+configs/config_backup_folder/
+configs/cycle7_batch.yaml.*
diff --git a/API.md b/API.md
index 1f18c2a..64bc3d6 100644
--- a/API.md
+++ b/API.md
@@ -185,52 +185,58 @@ Mortality endpoints require the dashboard to be started with `--mortality-dir
.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/`
+Returns all mortality reports recorded for a specific coop (e.g. `GET /api/mortality/coop/K1`).
+
### `GET /api/mortality/latest`
-Returns the most recently modified `mortality_report.json` across all registered mortality directories.
+Returns the most recent `mortality_report.json` across all coop directories, enriched with `coop` and `covered_floors`:
```json
{
- "date": "2026-07-09",
- "mode": "similarity_two_pass",
- "model_path": "...",
- "conf_threshold": 0.7,
- "iou_threshold": 0.8,
- "total_images": 2,
- "total_mortality_count": 35,
- "_dir": "/path/to/mortality",
- "results": [
- {
- "input_image": "scan_01.jpg",
- "output_image": "output_scan_01.jpg",
- "count": 19,
- "detections": [
- {
- "id": 1,
- "box": [177, 161, 288, 347],
- "confidence": 0.9597,
- "area": 20646
- }
- ]
- },
- {
- "input_image": "scan_02.jpg",
- "output_image": "output_scan_02.jpg",
- "count": 16,
- "detections": [...]
- }
- ]
+ "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 directories, sorted newest first. Each report contains `total_mortality_count` (grand total across all images in that run) and `total_images`.
+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/`
Returns all mortality scans and total carcass counts recorded on a specific date:
```json
{
- "date": "2026-07-09",
+ "date": "2026-06-18",
"total_mortality_count": 35,
"total_images": 2,
"reports": [...],
@@ -238,6 +244,100 @@ Returns all mortality scans and total carcass counts recorded on a specific date
}
```
+### `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//mortality//capture_XX_.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=`
+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//mortality//`, and returns detection results.
+
+**Content-Type**: `multipart/form-data`
+
+**Form Fields**:
+* `coop` (string, required): Coop identifier (e.g. `K1`, `K2`, `kandang-atas`).
+* `date` (string, optional): Date in `YYYY-MM-DD` format (defaults to current date).
+* `conf` (float, optional): Confidence threshold override (e.g. `0.7`).
+* `two_pass` (boolean, optional): Set `true` for 2-pass refinement.
+* `images` (file/binary, repeatable): 1 or more image files (`.jpg`, `.jpeg`, `.png`).
+
+**Response (`200 OK`)**:
+```json
+{
+ "status": "success",
+ "message": "Processed 2 image(s) for K1",
+ "coop": "K1",
+ "date": "2026-08-20",
+ "total_images": 2,
+ "total_mortality_count": 29,
+ "results": [
+ {
+ "input_image": "batch_1.jpg",
+ "output_image": "output_batch_1.jpg",
+ "count": 19,
+ "output_path": "/path/to/VIDEOS/cycle7/K1/mortality/2026-08-20/output_batch_1.jpg",
+ "detections": [...]
+ }
+ ]
+}
+```
+
+### `POST /api/mortality/scan`
+Triggers immediate re-scanning of an existing coop directory:
+
+**Request Body (`application/json`)**:
+```json
+{
+ "coop": "K1",
+ "date": "2026-08-20"
+}
+```
+
### `GET /api/mortality/image/`
Serves an annotated output JPEG by filename securely. Only files beginning with `output_` are accessible for security.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e010693..2d47e19 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,32 @@
All notable changes to the `chicken-counting-sukawarna-det` project are documented in this file.
-## [Unreleased] - 2026-08-19
+## [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//[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 ` 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//mortality//`, 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 ` to dynamically synthesize settings from `cycle7_batch_optimized.yaml` in memory without requiring a physical YAML file.
+ - Implemented `sync_floor_configs` and CLI command `chicken-counter sync-configs` to automatically scan `VIDEOS/` and generate boilerplate floor YAML configs for newly added coops/floors.
+ - Updated `run_all_coops.sh` to dynamically discover all floors directly from `VIDEOS/` or `configs/floor_config/` and seamlessly run physical YAMLs or virtual in-memory configs.
+- **Unit Test Suite (`tests/test_flexible_discovery.py`)**:
+ - Added automated test cases validating dynamic coop and floor extraction, arbitrary site layout adaptation, path resolution, config synchronization, virtual floor config synthesis, and dated mortality discovery.
+
+## [1.1.0] - 2026-08-19
### 🐛 Bug Fixes
- **Multi-Camera Tracker State Isolation (`tracking.py` & `batch_runner.py`)**:
diff --git a/RUN.md b/RUN.md
index 2f9ff21..0591ac0 100644
--- a/RUN.md
+++ b/RUN.md
@@ -95,41 +95,80 @@ Place input videos under the `VIDEOS` folder adjacent to the project following t
Then run:
```bash
-# Run all coops (10 floors: K1-L1 to K5-L2) for a specific date
+# Run all coops & floors dynamically for a specific date
./run_all_coops.sh 2026-06-18
-# Run a specific floor (e.g. Kandang 1 Lantai 1)
+# Run a specific floor using its config file
PYTHONPATH=src venv/bin/python -m chicken_counter.cli batch \
--config configs/floor_config/K1-L1.yaml \
--date 2026-06-18
-# Run 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
+# Run directly on ANY discovered floor (virtual config — zero YAML required!)
+PYTHONPATH=src venv/bin/python -m chicken_counter.cli batch \
+ --floor K6-L1 \
+ --date 2026-06-18
+
+# Automatically scan VIDEOS and generate boilerplate YAMLs for any newly added floors
+PYTHONPATH=src venv/bin/python -m chicken_counter.cli sync-configs
```
-Output is saved in `../VIDEOS/cycle7/kandang-atas/2026-06-18/output/`.
+Output is saved in `../VIDEOS/cycle7////output/`.
---
-### B — Mortality Detection (Carcass Photo Scanning)
+### B — Mortality Detection (Per-Coop Carcass Photo Scanning)
-Place input photos in `../VIDEOS/cycle7/kandang-atas/mortality/`.
+#### 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:
-```bash
-# Run with default config
-./test_run_mortality.sh
+1. **Open the Webapp**: From any phone, tablet, or terminal on the farm local network, navigate to `http://: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//mortality//`.
-# Override confidence threshold
-CONF=0.65 ./test_run_mortality.sh
+#### Camera Configuration (`configs/mortality_config.yaml`)
+Map each coop to its RTSP stream URL, HTTP snapshot URL, or USB device index:
-# Run on a specific image
-./test_run_mortality.sh /path/to/photo.jpg
+```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"
```
-Output annotated images are saved as `output_.jpg` in the same directory.
-A `mortality_report.json` is also saved there with full detection data.
+#### Method 2: Command Line Batch Run
+Place input photos inside each coop's mortality directory (e.g. `../VIDEOS/cycle7/K1/mortality/`, `../VIDEOS/cycle7/K2/mortality/`, etc.).
+
+```bash
+# Run mortality detection across ALL discovered coops (K1..K5, etc.)
+./run_mortality_all.sh
+
+# Run for a specific coop
+./run_mortality_all.sh K1
+# or via CLI:
+PYTHONPATH=src venv/bin/python -m chicken_counter.cli mortality --coop K1
+
+# Run across all coops for a specific date
+./run_mortality_all.sh 2026-06-18
+
+# Run directly on a specific image file
+PYTHONPATH=src venv/bin/python -m chicken_counter.cli mortality -i /path/to/photo.jpg
+```
+
+Output annotated images are saved as `output_.jpg` in each coop's mortality directory.
+A `mortality_report.json` is generated for each coop, tagging the coop name and all covered floors (e.g., `["K1-L1", "K1-L2", "K1-L3"]`).
#### Key config options in `configs/mortality_config.yaml`
diff --git a/configs/cycle7_batch.yaml.2 b/configs/cycle7_batch.yaml.2
deleted file mode 100644
index ada5117..0000000
--- a/configs/cycle7_batch.yaml.2
+++ /dev/null
@@ -1,94 +0,0 @@
-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]
diff --git a/configs/cycle7_batch.yaml.3 b/configs/cycle7_batch.yaml.3
deleted file mode 100644
index 2709e6c..0000000
--- a/configs/cycle7_batch.yaml.3
+++ /dev/null
@@ -1,121 +0,0 @@
-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]
diff --git a/configs/cycle7_batch.yaml.4 b/configs/cycle7_batch.yaml.4
deleted file mode 100644
index 2709e6c..0000000
--- a/configs/cycle7_batch.yaml.4
+++ /dev/null
@@ -1,121 +0,0 @@
-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]
diff --git a/configs/floor_config/K1-L1.yaml b/configs/floor_config/K1-L1.yaml
index fd07f27..d22d323 100644
--- a/configs/floor_config/K1-L1.yaml
+++ b/configs/floor_config/K1-L1.yaml
@@ -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:
diff --git a/configs/floor_config/K1-L2.yaml b/configs/floor_config/K1-L2.yaml
index ccea40c..eecfb3e 100644
--- a/configs/floor_config/K1-L2.yaml
+++ b/configs/floor_config/K1-L2.yaml
@@ -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:
diff --git a/configs/floor_config/K1-L3.yaml b/configs/floor_config/K1-L3.yaml
index 69edf73..5fdff47 100644
--- a/configs/floor_config/K1-L3.yaml
+++ b/configs/floor_config/K1-L3.yaml
@@ -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"
diff --git a/configs/floor_config/K2-L1.yaml b/configs/floor_config/K2-L1.yaml
index d94c438..5a5c4b5 100644
--- a/configs/floor_config/K2-L1.yaml
+++ b/configs/floor_config/K2-L1.yaml
@@ -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:
diff --git a/configs/floor_config/K2-L2.yaml b/configs/floor_config/K2-L2.yaml
index 42ee954..5d7218c 100644
--- a/configs/floor_config/K2-L2.yaml
+++ b/configs/floor_config/K2-L2.yaml
@@ -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:
diff --git a/configs/floor_config/K2-L3.yaml b/configs/floor_config/K2-L3.yaml
index 88a1919..b2e2c28 100644
--- a/configs/floor_config/K2-L3.yaml
+++ b/configs/floor_config/K2-L3.yaml
@@ -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"
diff --git a/configs/floor_config/K3-L1.yaml b/configs/floor_config/K3-L1.yaml
index 3c482a3..60761c0 100644
--- a/configs/floor_config/K3-L1.yaml
+++ b/configs/floor_config/K3-L1.yaml
@@ -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:
diff --git a/configs/floor_config/K3-L2.yaml b/configs/floor_config/K3-L2.yaml
index 60d0daa..768fb88 100644
--- a/configs/floor_config/K3-L2.yaml
+++ b/configs/floor_config/K3-L2.yaml
@@ -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:
diff --git a/configs/floor_config/K3-L3.yaml b/configs/floor_config/K3-L3.yaml
index c410e1e..7b00bce 100644
--- a/configs/floor_config/K3-L3.yaml
+++ b/configs/floor_config/K3-L3.yaml
@@ -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"
diff --git a/configs/floor_config/K4-L1.yaml b/configs/floor_config/K4-L1.yaml
index 26396d0..4d8f508 100644
--- a/configs/floor_config/K4-L1.yaml
+++ b/configs/floor_config/K4-L1.yaml
@@ -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:
diff --git a/configs/floor_config/K4-L2.yaml b/configs/floor_config/K4-L2.yaml
index db78465..1fa2446 100644
--- a/configs/floor_config/K4-L2.yaml
+++ b/configs/floor_config/K4-L2.yaml
@@ -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:
diff --git a/configs/floor_config/K5-L1.yaml b/configs/floor_config/K5-L1.yaml
index d800866..f020d53 100644
--- a/configs/floor_config/K5-L1.yaml
+++ b/configs/floor_config/K5-L1.yaml
@@ -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:
diff --git a/configs/floor_config/K5-L2.yaml b/configs/floor_config/K5-L2.yaml
index 4972d60..5c6ac9e 100644
--- a/configs/floor_config/K5-L2.yaml
+++ b/configs/floor_config/K5-L2.yaml
@@ -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:
diff --git a/configs/mortality_config.yaml b/configs/mortality_config.yaml
index 5aabeeb..9c86383 100644
--- a/configs/mortality_config.yaml
+++ b/configs/mortality_config.yaml
@@ -1,7 +1,10 @@
# Configuration for Mortality Chicken Carcass Detection & Counting
mortality:
- input_dir: "../VIDEOS/cycle7/kandang-atas/mortality"
- output_dir: null # null = save output_ in input directory
+ # 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_ in each coop's mortality directory
model_path: "models/chicken-detection-model-v26n-seg-300e-best-2026-04-18.pt"
device: "0" # "0" for GPU acceleration, or "cpu"
conf: 0.7
@@ -11,3 +14,12 @@ 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"
diff --git a/dashboard.py b/dashboard.py
index 50b6f30..51dcd01 100644
--- a/dashboard.py
+++ b/dashboard.py
@@ -17,6 +17,20 @@ 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
@@ -144,6 +158,41 @@ 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
@@ -166,6 +215,14 @@ 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)
@@ -173,9 +230,210 @@ 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//mortality//
+ 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)
@@ -423,27 +681,57 @@ 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/ - 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/ - Mortality breakdown for a specific date (YYYY-MM-DD).
GET /api/mortality/image/ - Serve an output_*.jpg annotated image by filename.
"""
+ global _mortality_dirs
if not _mortality_dirs:
- self._send_json({"error": "No mortality directory configured. Start dashboard with --mortality-dir."})
+ _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."})
return
- def _enrich_report(data: dict) -> dict:
+ 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
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
+ global _mortality_reports_cache, _mortality_reports_mtime, _mortality_image_cache, _mortality_dirs
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:
@@ -451,7 +739,6 @@ 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"):
@@ -498,6 +785,89 @@ 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/ ---
+ 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 = []
@@ -506,7 +876,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))
+ results.append(_enrich_report(data, report_path))
except (json.JSONDecodeError, OSError):
pass
results.sort(key=lambda x: x.get("_report_mtime", 0), reverse=True)
@@ -524,10 +894,11 @@ 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(_enrich_report(latest))
+ self._send_json(latest)
else:
self._send_json({"error": "No mortality report found."})
return
@@ -546,7 +917,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)
+ enriched = _enrich_report(data, report_path)
total_day_carcasses += enriched.get("total_mortality_count", 0)
total_day_images += enriched.get("total_images", 0)
all_results.extend(enriched.get("results", []))
diff --git a/db/chicken_counts.db b/db/chicken_counts.db
index 67e3007..60d0f66 100644
Binary files a/db/chicken_counts.db and b/db/chicken_counts.db differ
diff --git a/run_all_coops.sh b/run_all_coops.sh
index bc81975..e69de29 100755
--- a/run_all_coops.sh
+++ b/run_all_coops.sh
@@ -1,74 +0,0 @@
-#!/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 "================================================================="
diff --git a/run_mortality_all.sh b/run_mortality_all.sh
new file mode 100755
index 0000000..7f56804
--- /dev/null
+++ b/run_mortality_all.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+# Master runner for batch mortality detection across all coops
+set -u
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+export PYTHONPATH="$SCRIPT_DIR/src"
+
+if [ -f "$SCRIPT_DIR/venv/bin/python" ]; then
+ PYTHON="$SCRIPT_DIR/venv/bin/python"
+else
+ PYTHON="python3"
+fi
+
+DATE_OR_COOP="${1:-}" # Optional: date (e.g. 2026-06-18) or coop (e.g. K1) or empty for all
+CONFIG="${2:-configs/mortality_config.yaml}"
+
+VIDEOS_BASE="$SCRIPT_DIR/../VIDEOS"
+
+echo "================================================================="
+echo " Starting Batch Multi-Coop Mortality Detection"
+if [ -n "$DATE_OR_COOP" ]; then
+ echo " Target Filter: $DATE_OR_COOP"
+fi
+echo " Base Videos: $VIDEOS_BASE"
+echo "================================================================="
+
+if [[ "$DATE_OR_COOP" =~ ^[Kk][0-9]+$ ]] || [[ "$DATE_OR_COOP" =~ ^kandang.* ]]; then
+ # Run specific coop
+ echo ">>> Running mortality for coop: $DATE_OR_COOP <<<"
+ $PYTHON -m chicken_counter.cli mortality --config "$CONFIG" --coop "$DATE_OR_COOP" --videos-root "$VIDEOS_BASE"
+elif [[ "$DATE_OR_COOP" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
+ # Run all coops for specific date
+ echo ">>> Running all coops for date: $DATE_OR_COOP <<<"
+ $PYTHON -m chicken_counter.cli mortality --config "$CONFIG" --all-coops --date "$DATE_OR_COOP" --videos-root "$VIDEOS_BASE"
+else
+ # Run all coops for all available mortality folders
+ echo ">>> Running all discovered coop mortality folders <<<"
+ $PYTHON -m chicken_counter.cli mortality --config "$CONFIG" --all-coops --videos-root "$VIDEOS_BASE"
+fi
+
+echo ""
+echo "================================================================="
+echo " Batch Mortality Run Completed."
+echo "================================================================="
diff --git a/src/chicken_counter/batch_discovery.py b/src/chicken_counter/batch_discovery.py
index c630f75..848e3c9 100644
--- a/src/chicken_counter/batch_discovery.py
+++ b/src/chicken_counter/batch_discovery.py
@@ -6,6 +6,7 @@ from dataclasses import dataclass, field
from pathlib import Path
from chicken_counter.config import BatchSettings
+from chicken_counter.discovery import resolve_floor_video_dir
@dataclass
@@ -17,7 +18,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 = Path(settings.batch.root_dir)
+ root_p = resolve_floor_video_dir(settings.batch.root_dir, settings.batch.location)
if not root_p.exists():
root_p.mkdir(parents=True, exist_ok=True)
print(f"[batch] 📁 Created missing video root directory: {root_p}")
diff --git a/src/chicken_counter/batch_runner.py b/src/chicken_counter/batch_runner.py
index 15263e6..69b8f23 100644
--- a/src/chicken_counter/batch_runner.py
+++ b/src/chicken_counter/batch_runner.py
@@ -9,6 +9,7 @@ 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
@@ -86,7 +87,8 @@ def run_tensor_batched_daily_batch(
settings.batch.cycle_start_date = cycle_start_date
run_date = date or date_type.today().isoformat()
- day_dir = Path(settings.batch.root_dir) / run_date
+ floor_root = resolve_floor_video_dir(settings.batch.root_dir, settings.batch.location)
+ day_dir = floor_root / run_date
output_dir = day_dir / settings.batch.output_subdir
output_dir.mkdir(parents=True, exist_ok=True)
@@ -305,7 +307,8 @@ def run_hybrid_daily_batch(
settings.batch.cycle_start_date = cycle_start_date
run_date = date or date_type.today().isoformat()
- day_dir = Path(settings.batch.root_dir) / run_date
+ floor_root = resolve_floor_video_dir(settings.batch.root_dir, settings.batch.location)
+ day_dir = floor_root / run_date
output_dir = day_dir / settings.batch.output_subdir
output_dir.mkdir(parents=True, exist_ok=True)
@@ -592,7 +595,8 @@ def run_daily_batch(
)
run_date = date or date_type.today().isoformat()
- day_dir = Path(settings.batch.root_dir) / run_date
+ floor_root = resolve_floor_video_dir(settings.batch.root_dir, settings.batch.location)
+ day_dir = floor_root / run_date
output_dir = day_dir / settings.batch.output_subdir
output_dir.mkdir(parents=True, exist_ok=True)
diff --git a/src/chicken_counter/cli.py b/src/chicken_counter/cli.py
index f3149f8..7ce77fa 100644
--- a/src/chicken_counter/cli.py
+++ b/src/chicken_counter/cli.py
@@ -5,8 +5,15 @@ from __future__ import annotations
import argparse
from chicken_counter.batch_runner import run_daily_batch
-from chicken_counter.config import load_batch_config, load_camera_config
-from chicken_counter.mortality import DEFAULT_CONFIG_PATH, DEFAULT_MORTALITY_DIR, DEFAULT_MODEL_PATH, run_mortality_count
+from chicken_counter.config import load_batch_config, load_camera_config, load_or_synthesize_floor_config
+from chicken_counter.discovery import discover_farm_structure, sync_floor_configs
+from chicken_counter.mortality import (
+ DEFAULT_CONFIG_PATH,
+ DEFAULT_MORTALITY_DIR,
+ DEFAULT_MODEL_PATH,
+ run_all_coop_mortality,
+ run_mortality_count,
+)
from chicken_counter.pipeline import run_pipeline
@@ -21,7 +28,15 @@ 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", required=True, help="Path to batch config YAML/JSON.")
+ batch_parser.add_argument(
+ "--config",
+ "--floor",
+ "--location",
+ dest="config",
+ required=True,
+ help="Path to batch config YAML/JSON or floor identifier (e.g. K1-L1, K6-L2).",
+ )
+ batch_parser.add_argument("--base-config", default=None, help="Base config to extend when using floor identifier.")
batch_parser.add_argument(
"--date",
help="Processing date folder in YYYY-MM-DD format. Defaults to today.",
@@ -40,9 +55,17 @@ 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).")
@@ -62,9 +85,39 @@ 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=args.input,
+ input_path=input_path,
output_dir=args.output_dir,
model_path=args.model_path,
conf_threshold=args.conf,
@@ -74,11 +127,12 @@ 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_batch_config(args.config)
+ settings = load_or_synthesize_floor_config(args.config, base_config_path=getattr(args, "base_config", None))
if getattr(args, "output_subdir", None):
settings.batch.output_subdir = args.output_subdir
if getattr(args, "cycle_start_date", None):
diff --git a/src/chicken_counter/config.py b/src/chicken_counter/config.py
index 02e6e39..0cb5e4b 100644
--- a/src/chicken_counter/config.py
+++ b/src/chicken_counter/config.py
@@ -425,6 +425,41 @@ 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,
diff --git a/src/chicken_counter/discovery.py b/src/chicken_counter/discovery.py
new file mode 100644
index 0000000..943bf95
--- /dev/null
+++ b/src/chicken_counter/discovery.py
@@ -0,0 +1,284 @@
+"""Discovery engine for flexible, dynamic coop and floor detection across poultry farm sites."""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+
+PROJECT_ROOT = Path(__file__).resolve().parents[2]
+DEFAULT_VIDEOS_ROOT = (PROJECT_ROOT.parent / "VIDEOS").resolve()
+DEFAULT_CONFIGS_DIR = (PROJECT_ROOT / "configs" / "floor_config").resolve()
+
+# Patterns for coop & floor extraction
+COOP_FLOOR_PATTERN = re.compile(
+ r"^(?P[Kk]\d+|kandang[a-zA-Z0-9_-]*?)[-_]?(?P[Ll]\d+|floor\d+)?$",
+ re.IGNORECASE,
+)
+FLOOR_SUBDIR_PATTERN = re.compile(r"^(?:[Ll]|floor)[-_]?(?P\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///[L1..Ln, mortality]`
+ 2. Legacy/flat floor folders: `VIDEOS//-/`
+ 3. Config files: `configs/floor_config/-.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
+
diff --git a/src/chicken_counter/mortality.py b/src/chicken_counter/mortality.py
index 04d6189..886089e 100644
--- a/src/chicken_counter/mortality.py
+++ b/src/chicken_counter/mortality.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
+import re
from datetime import datetime, timezone
from pathlib import Path
@@ -11,6 +12,8 @@ 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())
@@ -38,6 +41,60 @@ 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,
@@ -246,6 +303,7 @@ 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 {}
@@ -293,7 +351,21 @@ 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")
@@ -435,6 +507,9 @@ 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,
@@ -452,5 +527,75 @@ def run_mortality_count(
with open(report_path, "w") as f:
json.dump(report_data, f, indent=2)
- print(f"[mortality] Summary report saved to: {report_path} (Total Carcasses: {total_mortality_count} across {len(image_files)} images)")
+ print(f"[mortality] [{inferred_coop}] Summary report saved to: {report_path} (Total Carcasses: {total_mortality_count} across {len(image_files)} images)")
return report_data
+
+
+def run_all_coop_mortality(
+ videos_root: str | Path | None = None,
+ date: str | None = None,
+ config_path: str | Path | None = DEFAULT_CONFIG_PATH,
+ conf_threshold: float | None = None,
+ iou_threshold: float | None = None,
+ model_path: str | Path | None = None,
+ device: str | int | None = None,
+ two_pass: bool | None = None,
+) -> dict[str, dict]:
+ """Batch-process mortality detection across all discovered coop mortality folders."""
+ farm_struct = discover_farm_structure(videos_root)
+ all_m_dirs = farm_struct.all_mortality_dirs()
+ if not all_m_dirs:
+ print(f"[mortality-batch] ⚠️ No coop mortality directories found under {farm_struct.videos_root}")
+ return {}
+
+ print(f"\n=================================================================")
+ print(f" Starting Batch Mortality Detection across {len(all_m_dirs)} Coops")
+ print(f" Discovered Coops: {', '.join(sorted(all_m_dirs.keys()))}")
+ if date:
+ print(f" Target Date: {date}")
+ print(f"=================================================================\n")
+
+ summary: dict[str, dict] = {}
+ total_carcasses_all = 0
+ total_images_all = 0
+
+ for coop_id in sorted(all_m_dirs.keys()):
+ mdir = all_m_dirs[coop_id]
+ covered = farm_struct.get_covered_floors(coop_id)
+ covered_str = ", ".join(covered)
+ print(f"\n>>> [MORTALITY] Processing Coop {coop_id} (Floors: {covered_str}) <<<")
+ print(f" Directory: {mdir}")
+
+ try:
+ res = run_mortality_count(
+ input_path=mdir,
+ config_path=config_path,
+ coop=coop_id,
+ date=date,
+ conf_threshold=conf_threshold,
+ iou_threshold=iou_threshold,
+ model_path=model_path,
+ device=device,
+ two_pass=two_pass,
+ )
+ summary[coop_id] = res
+ c_count = res.get("total_mortality_count", 0)
+ i_count = res.get("total_images", 0)
+ total_carcasses_all += c_count
+ total_images_all += i_count
+ print(f" ✅ Coop {coop_id} Done: {c_count} carcasses across {i_count} photos.")
+ except Exception as err:
+ print(f" ❌ Coop {coop_id} Error: {err}")
+ summary[coop_id] = {"error": str(err), "coop": coop_id}
+
+ print(f"\n=================================================================")
+ print(f" Batch Mortality Summary:")
+ for coop_id, res in summary.items():
+ if "error" in res:
+ print(f" {coop_id}: ERROR ({res['error']})")
+ else:
+ print(f" {coop_id} (Floors: {', '.join(farm_struct.get_covered_floors(coop_id))}): {res.get('total_mortality_count', 0)} carcasses ({res.get('total_images', 0)} images)")
+ print(f" TOTAL SITE MORTALITY: {total_carcasses_all} carcasses across {total_images_all} images")
+ print(f"=================================================================\n")
+
+ return summary
diff --git a/start_dashboard.sh b/start_dashboard.sh
index 914e815..28683a7 100755
--- a/start_dashboard.sh
+++ b/start_dashboard.sh
@@ -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"
+ mkdir -p "$VIDEOS_BASE/cycle7/K1/mortality" "$VIDEOS_BASE/cycle7/K2/mortality" "$VIDEOS_BASE/cycle7/K3/mortality" "$VIDEOS_BASE/cycle7/K4/mortality" "$VIDEOS_BASE/cycle7/K5/mortality"
fi
# Auto-discover mortality directories (any folder containing mortality_report.json or mortality folders)
diff --git a/templates/index.html b/templates/index.html
index f75b750..667f1d3 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -39,6 +39,10 @@ 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)}
@@ -46,6 +50,42 @@ 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}
@@ -79,11 +119,15 @@ body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:h
-
+
☠️ Mortality Scans
+
+
+
+
@@ -94,6 +138,71 @@ body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:h
+
+
+
+
+
📸 Mortality Inspection Camera
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![Stationary Camera View]()
+
+ 📷 Stationary Camera Preview
Click Capture to snap high-res photo
+
+
🔴 Camera Ready
+
+
+
+
+ Place batch of chickens on table → click Snap → clear table → repeat if needed
+
+
+
+
+
+
+
+
📸
+
Browse or Drop Photos
+
Select multiple photos for this coop
+
+
+
+
+
+
+
+
+
@@ -245,33 +354,101 @@ function loadHistory() {
}
}
+var allMortalityData = [];
+var selectedMortCoop = "ALL";
+
function loadMortality() {
- fetch("/api/mortality/latest").then(r => r.ok ? r.json() : null).then(data => {
+ fetch("/api/mortality/history").then(r => r.ok ? r.json() : []).then(reports => {
var panel = document.getElementById("mort-panel");
- if (!data || data.error || !data.results || !data.results.length) {
- panel.style.display = "none";
+ if (!reports || !reports.length) {
+ fetch("/api/mortality/latest").then(r => r.ok ? r.json() : null).then(latest => {
+ if (!latest || latest.error || !latest.results || !latest.results.length) {
+ panel.style.display = "none";
+ return;
+ }
+ allMortalityData = [latest];
+ renderMortalityUI();
+ }).catch(function() { panel.style.display = "none"; });
return;
}
- 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 '
' +
- '

' +
- '
' +
- '' + title + '' +
- '' + count + ' 💀' +
- '
';
- }).join("");
+ allMortalityData = reports;
+ renderMortalityUI();
}).catch(function() {});
}
+function renderMortalityUI() {
+ var panel = document.getElementById("mort-panel");
+ if (!allMortalityData || !allMortalityData.length) {
+ panel.style.display = "none";
+ return;
+ }
+ panel.style.display = "block";
+
+ var coopsSet = {};
+ allMortalityData.forEach(function(rep) {
+ var c = rep.coop || rep.location || "kandang-atas";
+ coopsSet[c] = (coopsSet[c] || 0) + (rep.total_mortality_count || 0);
+ });
+ var coopsList = Object.keys(coopsSet).sort();
+
+ var filterDiv = document.getElementById("mort-coop-filters");
+ if (coopsList.length > 1) {
+ var fHtml = '
';
+ coopsList.forEach(function(c) {
+ fHtml += '
';
+ });
+ 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 '
' +
+ '

' +
+ '
' +
+ '' +
+ '' + obj.coop + '' + title + '' +
+ '' + count + ' 💀' +
+ '
';
+ }).join("");
+}
+
+function setMortCoopFilter(coop) {
+ selectedMortCoop = coop;
+ renderMortalityUI();
+}
+
function openMortModal(src, caption) {
var modal = document.getElementById("mort-modal");
document.getElementById("mort-modal-img").src = src;
@@ -283,8 +460,227 @@ 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 & 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 = "✅
Batch #" + data.total_images + ": " + data.batch_count + " carcass(es) detected!
" +
+ "
Total for " + coop + " Today: " + data.total_mortality_count + " carcasses across " + data.total_images + " batch(es)";
+ btn.disabled = false;
+ btn.innerHTML = "📸 Snap & 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 '
';
+ }).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 '
' +
+ '

' +
+ '
' +
+ '
';
+ }).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 = "✅
" + data.total_mortality_count + " carcasses detected across " + data.total_images + " photo(s) for
" + coop + " (" + 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();
+ if (e.key === "Escape") {
+ closeMortModal();
+ closeMortUploadModal();
+ }
});
setTimeout(function() { if (activeCam) refreshNow(); }, 500);
diff --git a/tests/test_flexible_discovery.py b/tests/test_flexible_discovery.py
new file mode 100644
index 0000000..77d61e1
--- /dev/null
+++ b/tests/test_flexible_discovery.py
@@ -0,0 +1,184 @@
+"""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()