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