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 Inspection Camera

+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ Stationary Camera View + +
🔴 Camera Ready
+
+ + +
+ Place batch of chickens on table → click Snap → clear table → repeat if needed +
+
+ + + + +
+
+
+
@@ -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 + '' + - '
' + - '' + 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 '
' + + '' + title + '' + + '
' + + '' + + '' + 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 '
' + + 'preview' + + '' + + '
'; + }).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()