6 Commits
Author SHA1 Message Date
proitlab ae86821165 Add DB API endpoints: summary, history, date, camera, location
- api/db/summary — overall totals
- api/db/history — per-date rows
- api/db/date/<date> — single date detail with per-camera breakdown
- api/db/camera/<id> — all runs for a specific camera
- api/db/location/<name> — per-location summary + history
- API.md — full endpoint documentation with schema
2026-07-22 14:29:53 +07:00
proitlab efd4726bc6 DB: write incrementally after each camera, Flask dashboard with single-camera view
- batch_runner: _store_to_db after each camera (not just at end)
- dashboard: Flask app with templates/index.html
- Single-camera full-screen live stream with auto-switch
- DB history panel in sidebar
- store_results.py: standalone script for existing reports
2026-07-22 14:25:35 +07:00
proitlab 6dbaa517dc Dashboard: auto-focus running camera, show date + all-camera totals
- Auto-detect active camera by polling frame_index
- Show All Cameras Total accumulation in sidebar
- Show per-camera totals in camera list
- Add run_date to stats.json and display in dashboard
- Pipeline: thread run_date through build/run/batch
2026-07-22 13:36:20 +07:00
proitlab a54d0e6b2c Optimize counting.py: fast-reject, skip empty tracks, throttle purge
- Return early when tracks list is empty
- Fast-reject centroid by bounding rect before pointPolygonTest
- Purge stale tracks every 30 frames instead of every frame
- Skip deque append when trail_length == 0
2026-07-22 13:18:05 +07:00
proitlab d31ad05f0a Remove shared tracker between cameras, fix task warning, add export script
- Remove shared DetectionTracker across cameras to prevent state leakage
- Add task="detect" to YOLO constructor to suppress warning
- Add export_engine.py script for .pt to .engine conversion
- Regenerate ONNX and TensorRT engine with latest settings
2026-07-22 13:04:30 +07:00
proitlab af4e514357 Fix WARNING and CC2 not writing json to ouput folder 2026-07-22 12:34:32 +07:00
39 changed files with 1020 additions and 3606 deletions

No files matched your search

+149
View File
@@ -0,0 +1,149 @@
# Chicken Counter API
Base URL: `http://<jetson-ip>:8080`
## Live Dashboard
### `GET /`
Returns the dashboard HTML page.
### `GET /api/cameras`
List cameras currently writing to `/dev/shm`.
```json
{"cameras": ["CC1", "CC2", "CC3"]}
```
### `GET /shm/<camera_id>/stats.json`
Live stats for the active pipeline.
```json
{
"frame_index": 5120,
"inside_box_count": 42,
"total_entered_count": 1858,
"track_count": 99,
"backward_active": false,
"smoothed_speed": 4.3,
"count_events": 0,
"run_date": "2026-06-10"
}
```
### `GET /shm/<camera_id>/frame.jpg`
Live JPEG frame from the active pipeline.
---
## Database
All endpoints require the dashboard to be started with `--db <path>`. If no DB exists, endpoints return `[]` or `{}`.
### `GET /api/db/summary`
Overall totals across all dates and locations.
```json
{
"days": 12,
"locations": 2,
"total_runs": 48,
"total_chickens": 125000,
"total_hours": 8.5
}
```
### `GET /api/db/history`
Per-date summary, newest first (max 50 rows).
```json
[
{
"date": "2026-06-10",
"location": "kandang-atas",
"cams": 4,
"total": 5570,
"minutes": 40.2
}
]
```
### `GET /api/db/date/<date>`
Detail for a specific date. Format: `YYYY-MM-DD`.
```json
{
"date": "2026-06-10",
"total": {"total": 5570, "minutes": 40.2},
"cameras": [
{
"camera_id": "CC1",
"total_entered": 1500,
"frames_processed": 24800,
"elapsed_seconds": 600.5,
"stopped_reason": "backward",
"source_video": "kandang_1_camera_1_2026-06-10_120056.mp4",
"location": "kandang-atas"
}
]
}
```
### `GET /api/db/camera/<camera_id>`
History for a specific camera across all dates (max 50 rows).
```json
[
{
"date": "2026-06-10",
"location": "kandang-atas",
"total_entered": 1500,
"frames_processed": 24800,
"elapsed_seconds": 600.5,
"stopped_reason": "backward"
}
]
```
### `GET /api/db/location/<location>`
Summary and history for a specific location.
```json
{
"location": "kandang-atas",
"summary": {"days": 5, "total": 25000, "hours": 3.2},
"history": [
{
"date": "2026-06-10",
"cameras": "CC1, CC2, CC3, CC4",
"total": 5570,
"minutes": 40.2
}
]
}
```
---
## Database Schema
```sql
CREATE TABLE batch_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
location TEXT NOT NULL,
camera_id TEXT NOT NULL,
total_entered INTEGER NOT NULL DEFAULT 0,
frames_processed INTEGER NOT NULL DEFAULT 0,
elapsed_seconds REAL NOT NULL DEFAULT 0.0,
stopped_reason TEXT NOT NULL DEFAULT '',
source_video TEXT NOT NULL DEFAULT '',
generated_at TEXT NOT NULL DEFAULT '',
UNIQUE(date, location, camera_id)
);
```
Data is inserted automatically by the batch runner when `location` and `db_path` are configured in the batch YAML, or manually via:
```bash
python3 store_results.py output/counts_2026-06-10.json --location kandang-atas --db chicken_counts.db
```
+7 -10
View File
@@ -11,11 +11,11 @@ defaults:
model_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine
classes: [0]
ignored_classes: [1, 2]
conf: 0.55
conf: 0.35
iou: 0.55
imgsz: 640
device: "0"
min_box_area_px: 5000
min_box_area_px: 3000
validate_while_inside: true
detection_zone:
enabled: true
@@ -25,7 +25,7 @@ defaults:
tracker:
tracker_config_path: /media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/configs/trackers/botsort_chicken.yaml
persist: true
track_buffer: 45
track_buffer: 75
gate:
mode: two_line
lines_y: [420, 730]
@@ -40,7 +40,7 @@ defaults:
debounce_frames: 12
min_features: 60
max_corners: 80
stride_frames: 3
stride_frames: 2
flow_scale: 0.5
quality_level: 0.01
min_distance: 8
@@ -49,11 +49,10 @@ defaults:
show_boxes: true
show_track_trails: false
trail_length: 20
show_center_marker: false
show_center_marker: true
show_track_ring: false
count_anchor: [780, 120]
inside_box_only: true
validated_only: true
pending_blink: true
pending_colors:
- [255, 255, 0]
@@ -61,7 +60,7 @@ defaults:
display:
show_window: false
encoder: auto
output_bitrate_kbps: 2000
output_bitrate_kbps: 4000
codec_preference: [avc1, mp4v, H264]
performance:
half: false
@@ -81,7 +80,7 @@ defaults:
inset_right_px: 60
inset_top_px: 0
inset_bottom_px: 0
min_overlap_ratio: 0.35
min_overlap_ratio: 0.30
cameras:
CC1:
@@ -96,8 +95,6 @@ cameras:
CC2:
camera_num: 2
count_anchor: [900, 120]
detection:
min_box_area_px: 3000
roi:
points:
- [20, 380]
+94
View File
@@ -0,0 +1,94 @@
batch:
root_dir: /media/jetson/DATA/.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: /media/jetson/DATA/.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: /media/jetson/DATA/.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]
View File
File renamed without changes.
+121
View File
@@ -0,0 +1,121 @@
batch:
root_dir: /media/jetson/DATA/.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: /media/jetson/DATA/.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: /media/jetson/DATA/.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 -4
View File
@@ -1,9 +1,9 @@
tracker_type: botsort
track_high_thresh: 0.65
track_low_thresh: 0.3
new_track_thresh: 0.7
track_high_thresh: 0.5
track_low_thresh: 0.1
new_track_thresh: 0.6
track_buffer: 75
match_thresh: 0.9
match_thresh: 0.8
fuse_score: true
gmc_method: none
proximity_thresh: 0.5
-64
View File
@@ -1,64 +0,0 @@
cmake_minimum_required(VERSION 3.16)
project(chicken_counter VERSION 0.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif()
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE)
find_package(OpenCV 4.0 REQUIRED COMPONENTS core imgproc video videoio highgui imgcodecs dnn)
find_package(nlohmann_json 3.0 REQUIRED)
find_package(yaml-cpp REQUIRED)
find_package(CUDAToolkit REQUIRED)
find_library(NVINFER_LIB nvinfer PATHS /usr/lib/aarch64-linux-gnu REQUIRED)
find_library(NVONNX_LIB nvonnxparser PATHS /usr/lib/aarch64-linux-gnu REQUIRED)
set(COMMON_LIBS
opencv_core opencv_imgproc opencv_video opencv_videoio opencv_highgui opencv_imgcodecs opencv_dnn
nlohmann_json::nlohmann_json
yaml-cpp
CUDA::cudart
${NVINFER_LIB}
${NVONNX_LIB}
)
add_library(chicken_counter_lib STATIC
src/pipeline.cpp
src/batch_runner.cpp
)
target_include_directories(chicken_counter_lib PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
/usr/include/aarch64-linux-gnu
)
target_link_libraries(chicken_counter_lib PUBLIC ${COMMON_LIBS})
target_compile_options(chicken_counter_lib PRIVATE -O3 -march=armv8.2-a+fp16+dotprod -flto -DNDEBUG)
add_executable(chicken_counter_cli
src/main.cpp
)
target_link_libraries(chicken_counter_cli PRIVATE chicken_counter_lib)
target_link_options(chicken_counter_cli PRIVATE -Wl,--strip-all)
add_executable(test_config
tests/test_config.cpp
)
target_link_libraries(test_config PRIVATE chicken_counter_lib)
add_executable(test_modules
tests/test_modules.cpp
)
target_link_libraries(test_modules PRIVATE chicken_counter_lib)
add_executable(chicken_counter_dashboard
src/dashboard.cpp
)
target_link_libraries(chicken_counter_dashboard PRIVATE pthread)
enable_testing()
add_test(NAME config COMMAND test_config)
add_test(NAME modules COMMAND test_modules)
@@ -1,102 +0,0 @@
#pragma once
#include <filesystem>
#include <iostream>
#include <regex>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include "chicken_counter/config.hpp"
namespace cc {
struct CameraDiscoveryResult {
std::unordered_map<std::string, std::string> found;
std::unordered_map<std::string, std::string> skipped;
};
inline std::string replace_glob_placeholder(const std::string& pattern, int num) {
std::string s = pattern;
std::string token = "{num}";
size_t pos = s.find(token);
if (pos != std::string::npos) {
s.replace(pos, token.size(), std::to_string(num));
}
return s;
}
inline std::vector<std::string> glob_filenames(const std::string& dir, const std::string& pattern) {
namespace fs = std::filesystem;
std::vector<std::string> matches;
std::string regex_str = "^";
for (char c : pattern) {
if (c == '*') regex_str += ".*";
else if (c == '?') regex_str += ".";
else if (c == '.' || c == '[' || c == ']' || c == '(' || c == ')' || c == '{' || c == '}')
regex_str += std::string("\\") + c;
else regex_str += c;
}
regex_str += "$";
std::regex re(regex_str);
for (auto& entry : fs::directory_iterator(dir)) {
if (!entry.is_regular_file()) continue;
std::string fname = entry.path().filename().string();
if (std::regex_match(fname, re))
matches.push_back(entry.path().string());
}
std::sort(matches.begin(), matches.end());
return matches;
}
inline CameraDiscoveryResult discover_camera_videos(
const std::string& day_dir,
const BatchSettings& settings)
{
namespace fs = std::filesystem;
if (!fs::is_directory(day_dir))
throw std::runtime_error("Daily input folder does not exist: " + day_dir);
CameraDiscoveryResult result;
int total = static_cast<int>(settings.cameras.size());
using pair_t = std::pair<std::string, CameraPreset>;
std::vector<pair_t> sorted_cams(settings.cameras.begin(), settings.cameras.end());
std::sort(sorted_cams.begin(), sorted_cams.end(),
[](const pair_t& a, const pair_t& b) { return a.second.camera_num < b.second.camera_num; });
for (const auto& [camera_id, preset] : sorted_cams) {
std::string pattern = replace_glob_placeholder(settings.batch.camera_glob, preset.camera_num);
auto matches = glob_filenames(day_dir, pattern);
if (matches.empty()) {
result.skipped[camera_id] = "video_not_found";
continue;
}
if (matches.size() > 1) {
result.skipped[camera_id] = "multiple_matches";
continue;
}
result.found[camera_id] = matches[0];
}
if (result.found.empty()) {
std::string summary;
for (const auto& [id, reason] : result.skipped) summary += id + " (" + reason + "), ";
throw std::runtime_error("No camera videos found in " + day_dir + ". Skipped: " + summary);
}
int found_count = static_cast<int>(result.found.size());
if (!result.skipped.empty()) {
std::string summary;
for (const auto& [id, reason] : result.skipped) summary += id + " (" + reason + "), ";
std::cerr << "[batch] discovered " << found_count << "/" << total
<< " cameras; skipped: " << summary << "\n";
} else {
std::cerr << "[batch] discovered " << found_count << "/" << total << " cameras\n";
}
return result;
}
} // namespace cc
@@ -1,15 +0,0 @@
#pragma once
#include <string>
#include "chicken_counter/config.hpp"
namespace cc {
std::string run_daily_batch(const BatchSettings& settings,
const std::string& date = "",
bool verbose = false,
bool no_video = false,
bool show_progress = false);
} // namespace cc
-22
View File
@@ -1,22 +0,0 @@
#pragma once
#include <stdexcept>
#include <string>
#include <opencv2/videoio.hpp>
namespace cc {
inline cv::VideoCapture open_capture(const std::string& source) {
cv::VideoCapture cap;
if (source.size() == 1 && std::isdigit(source[0])) {
cap.open(std::stoi(source));
} else {
cap.open(source);
}
if (!cap.isOpened())
throw std::runtime_error("Unable to open video source: " + source);
return cap;
}
} // namespace cc
-106
View File
@@ -1,106 +0,0 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <filesystem>
#include <iostream>
#include <stdexcept>
#include <string>
#include <opencv2/videoio.hpp>
namespace cc {
inline double video_duration_seconds(const std::string& path) {
cv::VideoCapture cap(path);
if (!cap.isOpened())
throw std::runtime_error("Unable to open video for duration probe: " + path);
double fc = cap.get(cv::CAP_PROP_FRAME_COUNT);
double fps = cap.get(cv::CAP_PROP_FPS);
cap.release();
if (fps > 0 && fc > 0) return fc / fps;
throw std::runtime_error("Unable to determine duration for video: " + path);
}
inline double file_size_mb(const std::string& path) {
return static_cast<double>(std::filesystem::file_size(path)) / (1024.0 * 1024.0);
}
inline void run_ffmpeg(const std::vector<std::string>& command) {
std::string cmd;
for (const auto& arg : command) cmd += arg + " ";
cmd = cmd.substr(0, cmd.size() - 1) + " 2>&1";
int ret = std::system(cmd.c_str());
if (ret != 0)
throw std::runtime_error("ffmpeg failed with code " + std::to_string(ret));
}
inline double compress_video_to_target(
const std::string& input_path,
const std::string& output_path,
int max_mb = 200,
int max_attempts = 3)
{
namespace fs = std::filesystem;
if (!fs::is_regular_file(input_path))
throw std::runtime_error("Input video not found: " + input_path);
fs::create_directories(fs::path(output_path).parent_path());
double duration = video_duration_seconds(input_path);
if (duration <= 0)
throw std::runtime_error("Invalid video duration for " + input_path);
int target_kbps = std::max(300, static_cast<int>((max_mb * 8192) / duration * 0.92));
for (int attempt = 0; attempt < max_attempts; ++attempt) {
int attempt_kbps = std::max(300,
static_cast<int>(target_kbps * std::pow(0.85, attempt)));
if (fs::exists(output_path)) fs::remove(output_path);
std::vector<std::vector<std::string>> codec_attempts = {
{"-c:v", "h264_nvmpi", "-b:v", std::to_string(attempt_kbps) + "k",
"-maxrate", std::to_string(attempt_kbps) + "k",
"-bufsize", std::to_string(attempt_kbps * 2) + "k"},
{"-c:v", "libx264", "-preset", "fast",
"-b:v", std::to_string(attempt_kbps) + "k",
"-maxrate", std::to_string(attempt_kbps) + "k",
"-bufsize", std::to_string(attempt_kbps * 2) + "k"}
};
bool succeeded = false;
for (const auto& cargs : codec_attempts) {
std::vector<std::string> cmd = {"ffmpeg", "-y", "-i", input_path};
cmd.insert(cmd.end(), cargs.begin(), cargs.end());
cmd.push_back("-c:a");
cmd.push_back("copy");
cmd.push_back(output_path);
try {
run_ffmpeg(cmd);
succeeded = true;
break;
} catch (const std::runtime_error&) {
if (fs::exists(output_path)) fs::remove(output_path);
}
}
if (!succeeded)
throw std::runtime_error("Unable to compress video: " + input_path);
double size_mb = file_size_mb(output_path);
std::cerr << "[compress] " << fs::path(output_path).filename().string()
<< ": " << size_mb << " MB (attempt " << (attempt + 1)
<< ", target " << attempt_kbps << " kbps)\n";
if (size_mb <= max_mb) return size_mb;
}
double final_size = file_size_mb(output_path);
if (final_size > max_mb)
throw std::runtime_error("Compressed video exceeds " + std::to_string(max_mb)
+ " MB: " + output_path);
return final_size;
}
} // namespace cc
-617
View File
@@ -1,617 +0,0 @@
#pragma once
#include <cstdint>
#include <fstream>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <vector>
#include <nlohmann/json.hpp>
#include <opencv2/core/types.hpp>
#include <yaml-cpp/yaml.h>
#include "chicken_counter/types.hpp"
// ---------------------------------------------------------------------------
// cv::Point2i ↔ nlohmann::json (serialised as [x, y])
// ---------------------------------------------------------------------------
namespace cv {
inline void to_json(nlohmann::json& j, const Point2i& p) { j = {p.x, p.y}; }
inline void from_json(const nlohmann::json& j, Point2i& p) {
p.x = j.at(0).get<int>();
p.y = j.at(1).get<int>();
}
inline void to_json(nlohmann::json& j, const Scalar& s) { j = {s[0], s[1], s[2]}; }
inline void from_json(const nlohmann::json& j, Scalar& s) {
s = Scalar(j.at(0).get<double>(), j.at(1).get<double>(), j.at(2).get<double>());
}
} // namespace cv
namespace cc {
// ---------------------------------------------------------------------------
// YAML::Node → nlohmann::json
// ---------------------------------------------------------------------------
inline nlohmann::json yaml_to_json(const YAML::Node& node) {
if (node.IsNull()) return nullptr;
if (node.IsScalar()) {
std::string tag = node.Tag();
if (tag == "!") return node.as<std::string>();
try {
double dval = node.as<double>();
int ival = static_cast<int>(dval);
if (dval == static_cast<double>(ival)) return ival;
return dval;
} catch (const YAML::BadConversion&) {
std::string val = node.as<std::string>();
if (val == "true" || val == "True" || val == "yes" || val == "Yes")
return true;
if (val == "false" || val == "False" || val == "no" || val == "No")
return false;
if (val == "null" || val == "Null" || val == "NULL" || val == "~")
return nullptr;
return val;
}
}
if (node.IsSequence()) {
nlohmann::json arr = nlohmann::json::array();
for (const auto& item : node) arr.push_back(yaml_to_json(item));
return arr;
}
if (node.IsMap()) {
nlohmann::json obj = nlohmann::json::object();
for (const auto& kv : node) obj[kv.first.as<std::string>()] = yaml_to_json(kv.second);
return obj;
}
return nullptr;
}
// ---------------------------------------------------------------------------
// Config structs (no std::optional – nlohmann 3.10 compatibility)
// ---------------------------------------------------------------------------
struct DetectionConfig {
std::string model_path;
std::vector<int> classes = {0};
std::vector<int> ignored_classes = {1, 2};
float conf = 0.35f;
float iou = 0.55f;
int imgsz = 640;
std::string device; // empty = auto
int min_box_area_px = 0;
bool validate_while_inside = true;
};
inline void to_json(nlohmann::json& j, const DetectionConfig& c) {
j = {
{"model_path", c.model_path},
{"classes", c.classes},
{"ignored_classes", c.ignored_classes},
{"conf", c.conf}, {"iou", c.iou},
{"imgsz", c.imgsz},
{"min_box_area_px", c.min_box_area_px},
{"validate_while_inside", c.validate_while_inside}
};
if (!c.device.empty()) j["device"] = c.device;
}
inline void from_json(const nlohmann::json& j, DetectionConfig& c) {
j.at("model_path").get_to(c.model_path);
c.classes = j.value("classes", std::vector<int>{0});
c.ignored_classes = j.value("ignored_classes", std::vector<int>{1, 2});
c.conf = j.value("conf", 0.35f);
c.iou = j.value("iou", 0.55f);
c.imgsz = j.value("imgsz", 640);
if (j.contains("device")) {
if (j["device"].is_string()) c.device = j["device"];
else c.device = j["device"].dump();
}
c.min_box_area_px = j.value("min_box_area_px", 0);
c.validate_while_inside = j.value("validate_while_inside", true);
}
struct RoiConfig {
std::vector<cv::Point2i> points;
int inset_left_px = 0;
int inset_right_px = 0;
int inset_top_px = 0;
int inset_bottom_px = 0;
float min_overlap_ratio = 0.0f;
bool is_polygon() const { return points.size() > 2; }
cv::Rect bounding_rect() const {
if (points.empty()) return {};
int x1 = points[0].x, y1 = points[0].y, x2 = x1, y2 = y1;
for (const auto& p : points) {
if (p.x < x1) x1 = p.x; if (p.y < y1) y1 = p.y;
if (p.x > x2) x2 = p.x; if (p.y > y2) y2 = p.y;
}
return cv::Rect(x1, y1, x2 - x1, y2 - y1);
}
std::vector<cv::Point2i> counting_polygon() const {
auto br = bounding_rect();
int x_min = br.x + inset_left_px;
int x_max = br.x + br.width - inset_right_px;
int y_min = br.y + inset_top_px;
int y_max = br.y + br.height - inset_bottom_px;
const int min_w = 20, min_h = 20;
if (x_max - x_min < min_w) {
int cx = (x_min + x_max) / 2;
x_min = cx - min_w / 2; x_max = cx + min_w / 2;
}
if (y_max - y_min < min_h) {
int cy = (y_min + y_max) / 2;
y_min = cy - min_h / 2; y_max = cy + min_h / 2;
}
return {{x_min, y_min}, {x_max, y_min}, {x_max, y_max}, {x_min, y_max}};
}
cv::Rect counting_rect() const {
auto poly = counting_polygon();
if (poly.empty()) return {};
int x1 = poly[0].x, y1 = poly[0].y, x2 = x1, y2 = y1;
for (const auto& p : poly) {
if (p.x < x1) x1 = p.x; if (p.y < y1) y1 = p.y;
if (p.x > x2) x2 = p.x; if (p.y > y2) y2 = p.y;
}
return cv::Rect(x1, y1, x2 - x1, y2 - y1);
}
};
inline void to_json(nlohmann::json& j, const RoiConfig& c) {
j = {
{"points", c.points},
{"inset_left_px", c.inset_left_px},
{"inset_right_px", c.inset_right_px},
{"inset_top_px", c.inset_top_px},
{"inset_bottom_px", c.inset_bottom_px},
{"min_overlap_ratio", c.min_overlap_ratio}
};
}
inline void from_json(const nlohmann::json& j, RoiConfig& c) {
c.points = j.at("points").get<std::vector<cv::Point2i>>();
c.inset_left_px = j.value("inset_left_px", 0);
c.inset_right_px = j.value("inset_right_px", 0);
c.inset_top_px = j.value("inset_top_px", 0);
c.inset_bottom_px = j.value("inset_bottom_px", 0);
c.min_overlap_ratio = j.value("min_overlap_ratio", 0.0f);
}
struct DetectionZoneConfig {
bool enabled = false;
int buffer_above_px = 250;
int buffer_below_px = 250;
bool show_in_overlay = false;
cv::Rect compute_rect(const RoiConfig& roi, int frame_w, int frame_h) const {
auto br = roi.bounding_rect();
int x1 = std::max(0, br.x);
int x2 = std::min(frame_w, br.x + br.width);
int y1 = std::max(0, br.y - buffer_above_px);
int y2 = std::min(frame_h, br.y + br.height + buffer_below_px);
return cv::Rect(x1, y1, x2 - x1, y2 - y1);
}
};
inline void to_json(nlohmann::json& j, const DetectionZoneConfig& c) {
j = {{"enabled", c.enabled}, {"buffer_above_px", c.buffer_above_px},
{"buffer_below_px", c.buffer_below_px}, {"show_in_overlay", c.show_in_overlay}};
}
inline void from_json(const nlohmann::json& j, DetectionZoneConfig& c) {
c.enabled = j.value("enabled", false);
c.buffer_above_px = j.value("buffer_above_px", 250);
c.buffer_below_px = j.value("buffer_below_px", 250);
c.show_in_overlay = j.value("show_in_overlay", false);
}
struct TrackerConfig {
std::string tracker_config_path;
bool persist = true;
int track_buffer = 75;
};
inline void to_json(nlohmann::json& j, const TrackerConfig& c) {
j = {{"tracker_config_path", c.tracker_config_path},
{"persist", c.persist}, {"track_buffer", c.track_buffer}};
}
inline void from_json(const nlohmann::json& j, TrackerConfig& c) {
j.at("tracker_config_path").get_to(c.tracker_config_path);
c.persist = j.value("persist", true);
c.track_buffer = j.value("track_buffer", 75);
}
struct GateConfig {
std::string mode = "two_line";
std::vector<int> lines_y = {320, 600};
std::string direction = "bottom_to_up";
};
inline void to_json(nlohmann::json& j, const GateConfig& c) {
j = {{"mode", c.mode}, {"lines_y", c.lines_y}, {"direction", c.direction}};
}
inline void from_json(const nlohmann::json& j, GateConfig& c) {
c.mode = j.value("mode", "two_line");
c.lines_y = j.value("lines_y", std::vector<int>{320, 600});
c.direction = j.value("direction", "bottom_to_up");
}
struct MotionConfig {
bool enabled = true;
std::string axis = "vertical";
float forward_sign = 1.0f;
float ema_alpha = 0.2f;
float reverse_enter_threshold = -1.5f;
float reverse_exit_threshold = -0.5f;
int debounce_frames = 12;
int min_features = 60;
int max_corners = 300;
float quality_level = 0.01f;
int min_distance = 8;
int block_radius = 6;
int stride_frames = 1;
float flow_scale = 1.0f;
};
inline void to_json(nlohmann::json& j, const MotionConfig& c) {
j = {{"enabled", c.enabled}, {"axis", c.axis}, {"forward_sign", c.forward_sign},
{"ema_alpha", c.ema_alpha}, {"reverse_enter_threshold", c.reverse_enter_threshold},
{"reverse_exit_threshold", c.reverse_exit_threshold}, {"debounce_frames", c.debounce_frames},
{"min_features", c.min_features}, {"max_corners", c.max_corners},
{"quality_level", c.quality_level}, {"min_distance", c.min_distance},
{"block_radius", c.block_radius}, {"stride_frames", c.stride_frames},
{"flow_scale", c.flow_scale}};
}
inline void from_json(const nlohmann::json& j, MotionConfig& c) {
c.enabled = j.value("enabled", true);
c.axis = j.value("axis", "vertical");
c.forward_sign = j.value("forward_sign", 1.0f);
c.ema_alpha = j.value("ema_alpha", 0.2f);
c.reverse_enter_threshold = j.value("reverse_enter_threshold", -1.5f);
c.reverse_exit_threshold = j.value("reverse_exit_threshold", -0.5f);
c.debounce_frames = j.value("debounce_frames", 12);
c.min_features = j.value("min_features", 60);
c.max_corners = j.value("max_corners", 300);
c.quality_level = j.value("quality_level", 0.01f);
c.min_distance = j.value("min_distance", 8);
c.block_radius = j.value("block_radius", 6);
c.stride_frames = j.value("stride_frames", 1);
c.flow_scale = j.value("flow_scale", 1.0f);
}
struct OverlayConfig {
bool show_boxes = true;
bool show_track_trails = true;
int trail_length = 20;
bool show_center_marker = true;
bool show_track_ring = false;
cv::Point2i count_anchor = {900, 120};
bool inside_box_only = true;
bool validated_only = false;
bool pending_blink = true;
std::vector<cv::Scalar> pending_colors = {cv::Scalar(255, 255, 0), cv::Scalar(0, 255, 255)};
};
inline void to_json(nlohmann::json& j, const OverlayConfig& c) {
j = {{"show_boxes", c.show_boxes}, {"show_track_trails", c.show_track_trails},
{"trail_length", c.trail_length}, {"show_center_marker", c.show_center_marker},
{"show_track_ring", c.show_track_ring}, {"count_anchor", c.count_anchor},
{"inside_box_only", c.inside_box_only}, {"validated_only", c.validated_only},
{"pending_blink", c.pending_blink},
{"pending_colors", c.pending_colors}};
}
inline void from_json(const nlohmann::json& j, OverlayConfig& c) {
c.show_boxes = j.value("show_boxes", true);
c.show_track_trails = j.value("show_track_trails", true);
c.trail_length = j.value("trail_length", 20);
c.show_center_marker = j.value("show_center_marker", true);
c.show_track_ring = j.value("show_track_ring", false);
c.count_anchor = j.value("count_anchor", cv::Point2i{900, 120});
c.inside_box_only = j.value("inside_box_only", true);
c.validated_only = j.value("validated_only", false);
c.pending_blink = j.value("pending_blink", true);
c.pending_colors = j.value("pending_colors",
std::vector<cv::Scalar>{cv::Scalar(255, 255, 0), cv::Scalar(0, 255, 255)});
}
struct DisplayConfig {
std::string window_name = "Chicken Counter";
bool show_window = true;
std::string output_path; // empty = no output
float write_fps = -1.0f; // -1 = auto
int max_frames = -1; // -1 = unlimited
std::string encoder = "auto";
int output_bitrate_kbps = 4000;
std::vector<std::string> codec_preference = {"avc1", "mp4v", "H264"};
};
inline void to_json(nlohmann::json& j, const DisplayConfig& c) {
j = {{"window_name", c.window_name}, {"show_window", c.show_window},
{"encoder", c.encoder}, {"output_bitrate_kbps", c.output_bitrate_kbps},
{"codec_preference", c.codec_preference}};
if (!c.output_path.empty()) j["output_path"] = c.output_path;
if (c.write_fps >= 0) j["write_fps"] = c.write_fps;
if (c.max_frames >= 0) j["max_frames"] = c.max_frames;
}
inline void from_json(const nlohmann::json& j, DisplayConfig& c) {
c.window_name = j.value("window_name", "Chicken Counter");
c.show_window = j.value("show_window", true);
if (j.contains("output_path") && !j["output_path"].is_null())
c.output_path = j["output_path"].get<std::string>();
c.write_fps = j.value("write_fps", -1.0f);
c.max_frames = j.value("max_frames", -1);
c.encoder = j.value("encoder", "auto");
c.output_bitrate_kbps = j.value("output_bitrate_kbps", 4000);
c.codec_preference = j.value("codec_preference",
std::vector<std::string>{"avc1", "mp4v", "H264"});
}
struct PerformanceConfig {
bool half = false;
bool overlay_buffer_reuse = true;
int inference_stride = 1;
bool verbose = false;
};
inline void to_json(nlohmann::json& j, const PerformanceConfig& c) {
j = {{"half", c.half}, {"overlay_buffer_reuse", c.overlay_buffer_reuse},
{"inference_stride", c.inference_stride}, {"verbose", c.verbose}};
}
inline void from_json(const nlohmann::json& j, PerformanceConfig& c) {
c.half = j.value("half", false);
c.overlay_buffer_reuse = j.value("overlay_buffer_reuse", true);
c.inference_stride = j.value("inference_stride", 1);
c.verbose = j.value("verbose", false);
}
struct StreamConfig {
bool enabled = false;
std::string shm_dir = "/dev/shm";
int interval_frames = 5;
};
inline void to_json(nlohmann::json& j, const StreamConfig& c) {
j = {{"enabled", c.enabled}, {"shm_dir", c.shm_dir},
{"interval_frames", c.interval_frames}};
}
inline void from_json(const nlohmann::json& j, StreamConfig& c) {
c.enabled = j.value("enabled", false);
c.shm_dir = j.value("shm_dir", "/dev/shm");
c.interval_frames = j.value("interval_frames", 5);
}
struct FeedbackConfig {
bool enabled = false;
int every_n_frames = 300;
bool save_images = true;
std::string image_output_dir = "output/checkpoints";
bool log_to_terminal = true;
};
inline void to_json(nlohmann::json& j, const FeedbackConfig& c) {
j = {{"enabled", c.enabled}, {"every_n_frames", c.every_n_frames},
{"save_images", c.save_images}, {"image_output_dir", c.image_output_dir},
{"log_to_terminal", c.log_to_terminal}};
}
inline void from_json(const nlohmann::json& j, FeedbackConfig& c) {
c.enabled = j.value("enabled", false);
c.every_n_frames = j.value("every_n_frames", 300);
c.save_images = j.value("save_images", true);
c.image_output_dir = j.value("image_output_dir", "output/checkpoints");
c.log_to_terminal = j.value("log_to_terminal", true);
}
struct CameraConfig {
std::string camera_id;
std::string source;
DetectionConfig detection;
TrackerConfig tracker;
RoiConfig roi;
GateConfig gate;
MotionConfig motion;
OverlayConfig overlay;
DisplayConfig display;
PerformanceConfig performance;
FeedbackConfig feedback;
DetectionZoneConfig detection_zone;
StreamConfig stream;
};
inline void to_json(nlohmann::json& j, const CameraConfig& c) {
j = {{"camera_id", c.camera_id}, {"source", c.source},
{"detection", c.detection}, {"tracker", c.tracker},
{"roi", c.roi}, {"gate", c.gate}, {"motion", c.motion},
{"overlay", c.overlay}, {"display", c.display},
{"performance", c.performance}, {"feedback", c.feedback},
{"detection_zone", c.detection_zone}, {"stream", c.stream}};
}
inline void from_json(const nlohmann::json& j, CameraConfig& c) {
j.at("camera_id").get_to(c.camera_id);
j.at("source").get_to(c.source);
c.detection = j.value("detection", DetectionConfig{});
c.tracker = j.value("tracker", TrackerConfig{});
c.roi = j.value("roi", RoiConfig{});
c.gate = j.value("gate", GateConfig{});
c.motion = j.value("motion", MotionConfig{});
c.overlay = j.value("overlay", OverlayConfig{});
c.display = j.value("display", DisplayConfig{});
c.performance = j.value("performance", PerformanceConfig{});
c.feedback = j.value("feedback", FeedbackConfig{});
c.detection_zone = j.value("detection_zone", DetectionZoneConfig{});
c.stream = j.value("stream", StreamConfig{});
}
struct BatchConfig {
std::string root_dir;
std::string camera_glob = "kandang_*_camera_{num}_*.mp4";
std::string output_subdir = "output";
int compress_max_mb = 200;
bool delete_intermediate = false;
int checkpoint_every_n_frames = 3000;
};
inline void to_json(nlohmann::json& j, const BatchConfig& c) {
j = {{"root_dir", c.root_dir}, {"camera_glob", c.camera_glob},
{"output_subdir", c.output_subdir}, {"compress_max_mb", c.compress_max_mb},
{"delete_intermediate", c.delete_intermediate},
{"checkpoint_every_n_frames", c.checkpoint_every_n_frames}};
}
inline void from_json(const nlohmann::json& j, BatchConfig& c) {
j.at("root_dir").get_to(c.root_dir);
c.camera_glob = j.value("camera_glob", "kandang_*_camera_{num}_*.mp4");
c.output_subdir = j.value("output_subdir", "output");
c.compress_max_mb = j.value("compress_max_mb", 200);
c.delete_intermediate = j.value("delete_intermediate", false);
c.checkpoint_every_n_frames = j.value("checkpoint_every_n_frames", 3000);
}
struct CameraPreset {
std::string camera_id;
int camera_num;
RoiConfig roi;
cv::Point2i count_anchor = {-1, -1};
GateConfig gate;
MotionConfig motion;
nlohmann::json detection_overrides;
bool has_gate = false;
bool has_motion = false;
};
struct BatchSettings {
BatchConfig batch;
nlohmann::json defaults = nlohmann::json::object();
std::unordered_map<std::string, CameraPreset> cameras;
};
// ---------------------------------------------------------------------------
// Config-loading functions
// ---------------------------------------------------------------------------
inline nlohmann::json load_data(const std::string& path) {
if (path.size() >= 5 && path.compare(path.size() - 5, 5, ".json") == 0) {
std::ifstream f(path);
return nlohmann::json::parse(f);
}
YAML::Node yaml = YAML::LoadFile(path);
return yaml_to_json(yaml);
}
inline nlohmann::json deep_merge(nlohmann::json base, const nlohmann::json& override) {
for (auto it = override.begin(); it != override.end(); ++it) {
if (it.value().is_object() && base.contains(it.key()) && base[it.key()].is_object()) {
base[it.key()] = deep_merge(base[it.key()], it.value());
} else {
base[it.key()] = it.value();
}
}
return base;
}
inline CameraConfig load_camera_config(const std::string& path, const std::string& camera_id = "") {
auto raw = load_data(path);
if (raw.contains("batch")) {
throw std::runtime_error(
"This is a batch config file. Use 'chicken-counter batch --config ...' instead.");
}
if (raw.contains("cameras") && !raw.contains("defaults")) {
if (camera_id.empty())
throw std::runtime_error("camera_id is required when config contains multiple cameras");
raw = raw["cameras"][camera_id];
}
return raw.get<CameraConfig>();
}
inline BatchSettings load_batch_config(const std::string& path) {
auto raw = load_data(path);
if (!raw.contains("batch"))
throw std::runtime_error("Batch config must contain a top-level 'batch' section");
BatchSettings settings;
settings.batch = raw["batch"].get<BatchConfig>();
settings.defaults = raw.value("defaults", nlohmann::json::object());
if (raw.contains("cameras")) {
for (auto& [id, cam] : raw["cameras"].items()) {
CameraPreset preset;
preset.camera_id = id;
preset.camera_num = cam["camera_num"].get<int>();
preset.roi.points = cam["roi"]["points"].get<std::vector<cv::Point2i>>();
if (cam.contains("count_anchor")) {
preset.count_anchor = cam["count_anchor"].get<cv::Point2i>();
} else if (cam.contains("overlay") && cam["overlay"].contains("count_anchor")) {
preset.count_anchor = cam["overlay"]["count_anchor"].get<cv::Point2i>();
}
if (cam.contains("gate")) {
preset.gate = cam["gate"].get<GateConfig>();
preset.has_gate = true;
}
if (cam.contains("motion")) {
preset.motion = cam["motion"].get<MotionConfig>();
preset.has_motion = true;
}
if (cam.contains("detection")) {
preset.detection_overrides = cam["detection"];
}
settings.cameras[id] = std::move(preset);
}
}
return settings;
}
inline CameraConfig build_camera_config_from_batch(
const BatchSettings& settings,
const std::string& camera_id,
const std::string& source,
const std::string& output_path,
const std::string& checkpoint_dir)
{
auto it = settings.cameras.find(camera_id);
if (it == settings.cameras.end())
throw std::runtime_error("Unknown camera_id in batch config: " + camera_id);
const auto& preset = it->second;
auto raw = deep_merge(settings.defaults, {{"camera_id", camera_id}, {"source", source}});
if (preset.count_anchor.x >= 0) {
if (!raw.contains("overlay")) raw["overlay"] = nlohmann::json::object();
raw["overlay"]["count_anchor"] = preset.count_anchor;
}
if (!preset.detection_overrides.empty()) {
if (!raw.contains("detection")) raw["detection"] = nlohmann::json::object();
raw["detection"].update(preset.detection_overrides);
}
if (!raw.contains("roi")) raw["roi"] = nlohmann::json::object();
raw["roi"]["points"] = preset.roi.points;
if (preset.has_gate) {
raw["gate"] = {{"mode", preset.gate.mode},
{"lines_y", preset.gate.lines_y},
{"direction", preset.gate.direction}};
}
if (preset.has_motion) {
raw["motion"] = {
{"enabled", preset.motion.enabled},
{"axis", preset.motion.axis},
{"forward_sign", preset.motion.forward_sign},
{"ema_alpha", preset.motion.ema_alpha},
{"reverse_enter_threshold", preset.motion.reverse_enter_threshold},
{"reverse_exit_threshold", preset.motion.reverse_exit_threshold},
{"debounce_frames", preset.motion.debounce_frames},
{"min_features", preset.motion.min_features},
{"max_corners", preset.motion.max_corners},
{"quality_level", preset.motion.quality_level},
{"min_distance", preset.motion.min_distance},
{"block_radius", preset.motion.block_radius},
{"stride_frames", preset.motion.stride_frames},
{"flow_scale", preset.motion.flow_scale}
};
}
if (!raw.contains("display")) raw["display"] = nlohmann::json::object();
if (!output_path.empty()) raw["display"]["output_path"] = output_path;
raw["display"]["show_window"] = false;
if (!raw.contains("feedback")) raw["feedback"] = nlohmann::json::object();
raw["feedback"]["enabled"] = true;
raw["feedback"]["every_n_frames"] = settings.batch.checkpoint_every_n_frames;
raw["feedback"]["save_images"] = !output_path.empty();
raw["feedback"]["image_output_dir"] = checkpoint_dir;
raw["feedback"]["log_to_terminal"] = true;
return raw.get<CameraConfig>();
}
} // namespace cc
-199
View File
@@ -1,199 +0,0 @@
#pragma once
#include <cstdint>
#include <deque>
#include <iostream>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include <opencv2/imgproc.hpp>
#include <opencv2/core/types.hpp>
#include "chicken_counter/config.hpp"
#include "chicken_counter/types.hpp"
namespace cc {
class CountingZone {
public:
CountingZone() {}
CountingZone(const RoiConfig& roi,
const GateConfig& gate,
int trail_length,
int track_buffer,
int min_box_area_px = 0,
bool validate_while_inside = true,
bool verbose = false)
: roi(roi), gate(gate), trail_length(trail_length),
track_buffer(track_buffer), min_box_area_px(min_box_area_px),
min_overlap_ratio(roi.min_overlap_ratio),
validate_while_inside(validate_while_inside),
verbose(verbose)
{
for (auto& p : roi.counting_polygon())
counting_polygon.push_back(p);
auto r = roi.counting_rect();
counting_rect = r;
}
std::vector<CountEvent> update(
const std::vector<TrackObservation>& tracks,
int frame_index,
bool counting_paused = false)
{
std::vector<CountEvent> events;
std::unordered_set<int> active_ids, inside_ids;
for (const auto& track : tracks) {
active_ids.insert(track.track_id);
last_seen_frame[track.track_id] = frame_index;
auto& hist = histories[track.track_id];
if (hist.size() >= static_cast<size_t>(trail_length))
hist.pop_front();
hist.push_back({track.centroid_x, track.centroid_y});
if (inside_roi({track.centroid_x, track.centroid_y}))
inside_ids.insert(track.track_id);
if (counting_paused) continue;
if (inside_ids.find(track.track_id) == inside_ids.end()) continue;
if (counted_ids.find(track.track_id) != counted_ids.end()) continue;
bool should_validate = false;
if (validate_while_inside) {
should_validate = meets_validation_thresholds(track);
} else {
bool just_entered = inside_ids.find(track.track_id) != inside_ids.end()
&& prev_inside_ids.find(track.track_id) == prev_inside_ids.end();
should_validate = just_entered && meets_validation_thresholds(track);
}
if (should_validate) {
counted_ids.insert(track.track_id);
++total_entered_count;
sequence_numbers[track.track_id] = total_entered_count;
latest_validated_track_id = track.track_id;
CountEvent ev;
ev.track_id = track.track_id;
ev.frame_index = frame_index;
ev.total_entered_after_event = total_entered_count;
ev.sequence_number = total_entered_count;
events.push_back(ev);
if (verbose) {
int bbox_area = std::max(0, track.bbox_x2 - track.bbox_x1)
* std::max(0, track.bbox_y2 - track.bbox_y1);
double overlap = bbox_overlap_ratio(track);
std::cerr << "[count] track=" << track.track_id
<< " seq=#" << total_entered_count
<< " frame=" << frame_index
<< " area=" << bbox_area
<< " overlap=" << overlap
<< " conf=" << track.confidence
<< " centroid=" << track.centroid_x << "," << track.centroid_y << "\n";
}
}
}
inside_box_count = static_cast<int>(inside_ids.size());
current_inside_ids = inside_ids;
prev_inside_ids = inside_ids;
purge_stale(frame_index, active_ids);
return events;
}
std::vector<cv::Point2i> trail_for(int track_id) const {
auto it = histories.find(track_id);
if (it == histories.end()) return {};
return {it->second.begin(), it->second.end()};
}
size_t sequence_number_for(int track_id) const {
auto it = sequence_numbers.find(track_id);
return (it != sequence_numbers.end()) ? it->second : 0;
}
bool is_inside(int track_id) const {
return current_inside_ids.find(track_id) != current_inside_ids.end();
}
bool is_validated(int track_id) const {
return counted_ids.find(track_id) != counted_ids.end();
}
int inside_box_count = 0;
int total_entered_count = 0;
std::optional<int> latest_validated_track_id;
private:
RoiConfig roi;
GateConfig gate;
int trail_length;
int track_buffer;
int min_box_area_px;
float min_overlap_ratio;
bool validate_while_inside;
bool verbose;
std::vector<cv::Point2i> counting_polygon;
cv::Rect counting_rect;
std::unordered_map<int, std::deque<cv::Point2i>> histories;
std::unordered_map<int, int> last_seen_frame;
std::unordered_set<int> counted_ids;
std::unordered_set<int> prev_inside_ids;
std::unordered_set<int> current_inside_ids;
std::unordered_map<int, int> sequence_numbers;
bool inside_roi(cv::Point2i p) const {
return cv::pointPolygonTest(counting_polygon, p, false) > 0;
}
bool meets_size_threshold(const TrackObservation& track) const {
int area = std::max(0, track.bbox_x2 - track.bbox_x1)
* std::max(0, track.bbox_y2 - track.bbox_y1);
return area >= min_box_area_px;
}
double bbox_overlap_ratio(const TrackObservation& track) const {
int bbox_area = std::max(0, track.bbox_x2 - track.bbox_x1)
* std::max(0, track.bbox_y2 - track.bbox_y1);
if (bbox_area <= 0) return 0.0;
int ix1 = std::max(track.bbox_x1, counting_rect.x);
int iy1 = std::max(track.bbox_y1, counting_rect.y);
int ix2 = std::min(track.bbox_x2, counting_rect.x + counting_rect.width);
int iy2 = std::min(track.bbox_y2, counting_rect.y + counting_rect.height);
if (ix2 <= ix1 || iy2 <= iy1) return 0.0;
double intersection = (ix2 - ix1) * (iy2 - iy1);
return intersection / bbox_area;
}
bool meets_overlap_threshold(const TrackObservation& track) const {
if (min_overlap_ratio <= 0) return true;
return bbox_overlap_ratio(track) >= min_overlap_ratio;
}
bool meets_validation_thresholds(const TrackObservation& track) const {
return meets_size_threshold(track) && meets_overlap_threshold(track);
}
void purge_stale(int frame_index, const std::unordered_set<int>& active_ids) {
std::vector<int> stale;
for (const auto& [id, last] : last_seen_frame) {
if (active_ids.find(id) == active_ids.end()
&& frame_index - last > track_buffer)
stale.push_back(id);
}
for (int id : stale) {
last_seen_frame.erase(id);
histories.erase(id);
prev_inside_ids.erase(id);
current_inside_ids.erase(id);
}
}
};
} // namespace cc
-146
View File
@@ -1,146 +0,0 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <iostream>
#include <vector>
#include <opencv2/core/types.hpp>
#include <opencv2/imgproc.hpp>
#include <opencv2/video.hpp>
#include "chicken_counter/config.hpp"
#include "chicken_counter/types.hpp"
namespace cc {
class BackwardMotionDetector {
public:
BackwardMotionDetector() {}
BackwardMotionDetector(const MotionConfig& config,
const RoiConfig& roi,
bool verbose = false)
: config(config), roi(roi), verbose(verbose)
{
int x1 = roi.points[0].x, y1 = roi.points[0].y;
int x2 = x1, y2 = y1;
for (const auto& p : roi.points) {
if (p.x < x1) x1 = p.x; if (p.y < y1) y1 = p.y;
if (p.x > x2) x2 = p.x; if (p.y > y2) y2 = p.y;
}
roi_bounds = cv::Rect(x1, y1, x2 - x1, y2 - y1);
}
MotionState update(const cv::Mat& frame,
const std::vector<TrackObservation>& tracks,
int frame_index)
{
if (!config.enabled) return state;
int stride = std::max(1, config.stride_frames);
if (frame_index % stride != 0) return state;
cv::Mat gray;
cv::cvtColor(frame, gray, cv::COLOR_BGR2GRAY);
cv::Mat gray_roi = gray(roi_bounds);
double scale = config.flow_scale;
if (scale < 1.0) {
int tw = std::max(1, static_cast<int>(gray_roi.cols * scale));
int th = std::max(1, static_cast<int>(gray_roi.rows * scale));
cv::resize(gray_roi, gray_roi, {tw, th}, 0, 0, cv::INTER_AREA);
} else {
scale = 1.0;
}
cv::Mat mask(gray_roi.size(), CV_8UC1, cv::Scalar(255));
int r = config.block_radius;
for (const auto& track : tracks) {
int lx1 = static_cast<int>((std::max(0, track.bbox_x1 - r) - roi_bounds.x) * scale);
int ly1 = static_cast<int>((std::max(0, track.bbox_y1 - r) - roi_bounds.y) * scale);
int lx2 = static_cast<int>((std::min(roi_bounds.x + roi_bounds.width, track.bbox_x2 + r) - roi_bounds.x) * scale);
int ly2 = static_cast<int>((std::min(roi_bounds.y + roi_bounds.height, track.bbox_y2 + r) - roi_bounds.y) * scale);
if (lx2 <= lx1 || ly2 <= ly1) continue;
cv::rectangle(mask, {lx1, ly1}, {lx2, ly2}, 0, -1);
}
std::vector<cv::Point2f> points;
cv::goodFeaturesToTrack(gray_roi, points, config.max_corners,
config.quality_level, config.min_distance, mask, config.block_radius);
if (previous_gray.empty() || static_cast<int>(points.size()) < config.min_features) {
previous_gray = gray_roi.clone();
return state;
}
std::vector<cv::Point2f> next_points;
std::vector<uint8_t> status;
std::vector<float> err;
cv::calcOpticalFlowPyrLK(previous_gray, gray_roi, points, next_points, status, err);
previous_gray = gray_roi.clone();
if (next_points.empty() || status.empty()) return state;
int valid_count = 0;
double flow_sum = 0.0;
for (size_t i = 0; i < status.size(); ++i) {
if (!status[i]) continue;
double v = (config.axis == "vertical")
? (next_points[i].y - points[i].y)
: (next_points[i].x - points[i].x);
flow_sum += v;
valid_count++;
}
if (valid_count < config.min_features) return state;
double median_speed = flow_sum / valid_count * config.forward_sign;
double alpha = config.ema_alpha;
state.smoothed_speed = static_cast<float>(
alpha * median_speed + (1.0 - alpha) * state.smoothed_speed);
if (state.smoothed_speed <= config.reverse_enter_threshold) {
++state.consecutive_reverse_frames;
} else if (state.smoothed_speed > config.reverse_exit_threshold) {
state.consecutive_reverse_frames = 0;
state.backward_active = false;
}
if (state.consecutive_reverse_frames >= config.debounce_frames) {
bool was_active = state.backward_active;
state.backward_active = true;
if (verbose && !was_active) {
std::cerr << "[motion] BACKWARD TRIGGERED! smoothed="
<< state.smoothed_speed
<< " consecutive=" << state.consecutive_reverse_frames << "\n";
}
}
if (verbose) {
++update_count;
std::cerr << "[motion #" << update_count
<< "] features=" << valid_count
<< " median_speed=" << median_speed
<< " smoothed=" << state.smoothed_speed
<< " consecutive_rev=" << state.consecutive_reverse_frames
<< " backward=" << state.backward_active << "\n";
}
return state;
}
MotionState state;
private:
MotionConfig config;
RoiConfig roi;
bool verbose;
cv::Rect roi_bounds;
cv::Mat previous_gray;
int update_count = 0;
};
} // namespace cc
-162
View File
@@ -1,162 +0,0 @@
#pragma once
#include <cstdint>
#include <string>
#include <opencv2/imgproc.hpp>
#include <opencv2/core/types.hpp>
#include "chicken_counter/config.hpp"
#include "chicken_counter/counting.hpp"
#include "chicken_counter/types.hpp"
namespace cc {
const cv::Scalar WHITE(255, 255, 255);
const cv::Scalar BLACK(0, 0, 0);
const cv::Scalar CYAN(255, 255, 0);
const cv::Scalar RED(0, 0, 255);
const cv::Scalar YELLOW(0, 255, 255);
const cv::Scalar ORANGE(0, 165, 255);
const cv::Scalar BLUE(255, 120, 0);
const cv::Scalar LIME(80, 220, 80);
const cv::Scalar GRAY(160, 160, 160);
inline void draw_outlined_text(
cv::Mat& frame,
const std::string& text,
cv::Point origin,
double font_scale,
const cv::Scalar& fill_color,
const cv::Scalar& outline_color,
int thickness,
int outline_thickness)
{
int font = cv::FONT_HERSHEY_SIMPLEX;
cv::putText(frame, text, origin, font, font_scale, outline_color, outline_thickness, cv::LINE_AA);
cv::putText(frame, text, origin, font, font_scale, fill_color, thickness, cv::LINE_AA);
}
inline void draw_trail(cv::Mat& frame, const std::vector<cv::Point2i>& trail) {
if (trail.size() < 2) return;
for (size_t i = 1; i < trail.size(); ++i)
cv::line(frame, trail[i - 1], trail[i], YELLOW, 2);
}
inline void draw_dashed_rectangle(
cv::Mat& frame,
cv::Point pt1,
cv::Point pt2,
const cv::Scalar& color,
int thickness = 1,
int dash_length = 12)
{
int x1 = pt1.x, y1 = pt1.y, x2 = pt2.x, y2 = pt2.y;
for (int x = x1; x < x2; x += dash_length * 2)
cv::line(frame, {x, y1}, {std::min(x + dash_length, x2), y1}, color, thickness);
for (int x = x1; x < x2; x += dash_length * 2)
cv::line(frame, {x, y2}, {std::min(x + dash_length, x2), y2}, color, thickness);
for (int y = y1; y < y2; y += dash_length * 2)
cv::line(frame, {x1, y}, {x1, std::min(y + dash_length, y2)}, color, thickness);
for (int y = y1; y < y2; y += dash_length * 2)
cv::line(frame, {x2, y}, {x2, std::min(y + dash_length, y2)}, color, thickness);
}
inline void draw_detection_zone(cv::Mat& frame, const CameraConfig& config) {
int h = frame.rows, w = frame.cols;
auto r = config.detection_zone.compute_rect(config.roi, w, h);
draw_dashed_rectangle(frame, {r.x, r.y}, {r.x + r.width, r.y + r.height}, GRAY, 1);
}
inline void draw_roi_and_gates(cv::Mat& frame, const CameraConfig& config) {
std::vector<cv::Point2i> pts = config.roi.counting_polygon();
std::vector<std::vector<cv::Point>> contours(1);
for (const auto& p : pts) contours[0].emplace_back(p);
cv::polylines(frame, contours, true, BLUE, 3);
}
inline cv::Mat draw_overlay(
cv::Mat& frame,
const CameraConfig& config,
CountingZone& counting_zone,
const std::vector<TrackObservation>& tracks,
const MotionState& motion_state,
int frame_index = 0,
cv::Mat* buffer = nullptr)
{
cv::Mat annotated;
if (buffer) {
frame.copyTo(*buffer);
annotated = *buffer;
} else {
annotated = frame.clone();
}
if (config.detection_zone.enabled && config.detection_zone.show_in_overlay)
draw_detection_zone(annotated, config);
draw_roi_and_gates(annotated, config);
bool blink_on = (frame_index / 8) % 2 == 0;
const auto& pc = config.overlay.pending_colors;
auto pending_colors = pc.empty()
? std::vector<cv::Scalar>{CYAN, YELLOW}
: pc;
for (const auto& track : tracks) {
bool inside_box = counting_zone.is_inside(track.track_id);
if (config.overlay.inside_box_only && !inside_box) continue;
bool validated = counting_zone.is_validated(track.track_id);
if (config.overlay.validated_only && !validated) continue;
int x1 = track.bbox_x1, y1 = track.bbox_y1, x2 = track.bbox_x2, y2 = track.bbox_y2;
int cx = track.centroid_x, cy = track.centroid_y;
int seq = static_cast<int>(counting_zone.sequence_number_for(track.track_id));
if (config.overlay.show_boxes) {
cv::Scalar box_color;
if (validated) {
box_color = ORANGE;
} else if (config.overlay.pending_blink) {
box_color = pending_colors[blink_on ? 0 : 1 % pending_colors.size()];
} else {
box_color = pending_colors[0];
}
cv::rectangle(annotated, {x1, y1}, {x2, y2}, box_color, 2);
}
if (validated && seq > 0) {
draw_outlined_text(annotated, std::to_string(seq),
{x1, std::max(24, y1 - 8)}, 0.8, LIME, BLACK, 2, 4);
}
if (config.overlay.show_center_marker) {
cv::Scalar marker_color = validated ? ORANGE
: pending_colors[blink_on ? 0 : 1 % pending_colors.size()];
cv::circle(annotated, {cx, cy}, 4, marker_color, -1);
if (config.overlay.show_track_ring) {
int radius = std::max(20, static_cast<int>(std::max(x2 - x1, y2 - y1) * 0.6));
cv::circle(annotated, {cx, cy}, radius, WHITE, 1);
}
}
if (config.overlay.show_track_trails) {
auto trail = counting_zone.trail_for(track.track_id);
draw_trail(annotated, trail);
}
}
auto& anchor = config.overlay.count_anchor;
draw_outlined_text(annotated,
"TOTAL ENTERED: " + std::to_string(counting_zone.total_entered_count),
anchor, 1.35, BLUE, BLACK, 4, 6);
const char* motion_label = motion_state.backward_active ? "BACKWARD STOP" : "FORWARD";
cv::Scalar motion_color = motion_state.backward_active ? RED : YELLOW;
cv::putText(annotated, motion_label, {anchor.x, anchor.y + 42},
cv::FONT_HERSHEY_SIMPLEX, 0.8, motion_color, 2, cv::LINE_AA);
return annotated;
}
} // namespace cc
-39
View File
@@ -1,39 +0,0 @@
#pragma once
#include <chrono>
#include <optional>
#include <opencv2/core.hpp>
#include <opencv2/videoio.hpp>
#include "chicken_counter/config.hpp"
#include "chicken_counter/counting.hpp"
#include "chicken_counter/motion.hpp"
#include "chicken_counter/tracking.hpp"
#include "chicken_counter/types.hpp"
namespace cc {
struct PipelineArtifacts {
cv::VideoCapture capture;
DetectionTracker* tracker;
CountingZone counting_zone;
BackwardMotionDetector motion_detector;
cv::VideoWriter writer;
cv::Mat overlay_buffer;
double run_start_time;
int total_source_frames;
bool owns_tracker;
cv::Rect detection_zone_rect;
bool has_writer;
bool has_detection_zone;
};
PipelineArtifacts build_pipeline(const CameraConfig& config,
DetectionTracker* tracker = nullptr);
PipelineResult run_pipeline(const CameraConfig& config,
DetectionTracker* tracker = nullptr,
bool show_progress = false);
} // namespace cc
-139
View File
@@ -1,139 +0,0 @@
#pragma once
#include <chrono>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
#include "chicken_counter/types.hpp"
namespace cc {
inline std::string now_iso() {
auto now = std::chrono::system_clock::now();
auto t = std::chrono::system_clock::to_time_t(now);
std::ostringstream oss;
oss << std::put_time(std::gmtime(&t), "%FT%TZ");
return oss.str();
}
inline std::string relative_output_path(const std::string& path,
const std::string& base_dir) {
if (path.empty()) return "";
if (base_dir.empty()) return std::filesystem::path(path).filename().string();
try {
auto rel = std::filesystem::relative(path, base_dir);
return rel.string();
} catch (...) {
return std::filesystem::path(path).filename().string();
}
}
inline nlohmann::json build_camera_report_entry(
const CameraBatchResult& item,
const std::string& output_dir = "")
{
if (item.skipped) {
return {{"skipped", true},
{"skip_reason", item.skip_reason},
{"total_entered", 0}};
}
return {
{"total_entered", item.pipeline.total_entered_count},
{"source_video", std::filesystem::path(item.pipeline.source_video).filename().string()},
{"vis_video", relative_output_path(item.pipeline.vis_video_path, output_dir)},
{"compressed_video", relative_output_path(item.compressed_video_path, output_dir)},
{"compressed_size_mb", item.compressed_size_mb},
{"frames_processed", item.pipeline.frames_processed},
{"stopped_reason", item.pipeline.stopped_reason},
{"elapsed_seconds", std::round(item.pipeline.elapsed_seconds * 10.0) / 10.0}
};
}
struct BatchReport {
std::string date;
std::string generated_at;
nlohmann::json cameras;
int total_entered_sum;
};
inline BatchReport build_batch_report(
const std::string& date,
const std::vector<CameraBatchResult>& results,
const std::string& output_dir = "")
{
BatchReport report;
report.date = date;
report.generated_at = now_iso();
report.total_entered_sum = 0;
for (const auto& item : results) {
auto entry = build_camera_report_entry(item, output_dir);
if (entry.empty()) continue;
report.cameras[item.camera_id] = entry;
if (!item.skipped)
report.total_entered_sum += entry.value("total_entered", 0);
}
return report;
}
inline std::string write_json_file(const std::string& path, const nlohmann::json& j) {
namespace fs = std::filesystem;
fs::create_directories(fs::path(path).parent_path());
std::ofstream f(path);
f << j.dump(2);
std::cerr << "[report] wrote " << path << "\n";
return path;
}
inline std::string write_batch_report(const BatchReport& report,
const std::string& output_path) {
nlohmann::json j = {
{"date", report.date},
{"generated_at", report.generated_at},
{"cameras", report.cameras},
{"total_entered_sum", report.total_entered_sum}
};
return write_json_file(output_path, j);
}
inline std::string write_camera_report(
const std::string& date,
const CameraBatchResult& item,
const std::string& output_dir)
{
namespace fs = std::filesystem;
fs::create_directories(output_dir);
auto entry = build_camera_report_entry(item, output_dir);
nlohmann::json payload = {
{"date", date},
{"camera_id", item.camera_id},
{"generated_at", now_iso()}
};
for (auto& [k, v] : entry.items()) payload[k] = v;
std::string path = output_dir + "/" + item.camera_id + "_counts_" + date + ".json";
return write_json_file(path, payload);
}
inline std::string persist_batch_reports(
const std::string& date,
const std::vector<CameraBatchResult>& results,
const std::string& output_dir)
{
const auto& latest = results.back();
write_camera_report(date, latest, output_dir);
std::string aggregate_path = output_dir + "/counts_" + date + ".json";
auto report = build_batch_report(date, results, output_dir);
write_batch_report(report, aggregate_path);
return aggregate_path;
}
} // namespace cc
-390
View File
@@ -1,390 +0,0 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <fstream>
#include <iostream>
#include <memory>
#include <vector>
#include <opencv2/core.hpp>
#include <opencv2/dnn.hpp>
#include <opencv2/imgproc.hpp>
#include <opencv2/video/tracking.hpp>
#include <NvInfer.h>
#include <NvOnnxParser.h>
#include <cuda_runtime.h>
#include "chicken_counter/config.hpp"
#include "chicken_counter/types.hpp"
namespace cc {
template <typename T> struct TRTDeleter { void operator()(T* p) const { delete p; } };
template <typename T> using TRT_ptr = std::unique_ptr<T, TRTDeleter<T>>;
struct CudaStreamDeleter { void operator()(cudaStream_t* s) const { cudaStreamDestroy(*s); delete s; } };
using Cuda_stream_ptr = std::unique_ptr<cudaStream_t, CudaStreamDeleter>;
struct CudaHostDeleter { template <typename T> void operator()(T* p) const { cudaFreeHost(p); } };
template <typename T> using Cuda_host_ptr = std::unique_ptr<T, CudaHostDeleter>;
inline void trt_check(cudaError_t e, const char* m = "") {
if (e != cudaSuccess) throw std::runtime_error(std::string("CUDA:") + cudaGetErrorString(e) + " " + m);
}
// ---------------------------------------------------------------------------
// TensorRT engine (build from ONNX, cache to disk)
// ---------------------------------------------------------------------------
class TensorRTEngine {
public:
TensorRTEngine(const std::string& onnx_path, const std::string& cache_path = "") {
if (!cache_path.empty()) {
std::ifstream fc(cache_path, std::ios::binary | std::ios::ate);
if (fc) {
size_t sz = fc.tellg(); fc.seekg(0);
std::vector<char> data(sz);
fc.read(data.data(), sz);
runtime.reset(nvinfer1::createInferRuntime(logger));
engine.reset(runtime->deserializeCudaEngine(data.data(), sz));
if (engine) {
std::cerr << "[trt] loaded cache: " << cache_path << "\n";
init_io();
return;
}
}
}
std::cerr << "[trt] building from " << onnx_path << " ...\n";
auto builder = TRT_ptr<nvinfer1::IBuilder>(nvinfer1::createInferBuilder(logger));
auto network = TRT_ptr<nvinfer1::INetworkDefinition>(
builder->createNetworkV2(1U << static_cast<uint32_t>(
nvinfer1::NetworkDefinitionCreationFlag::kEXPLICIT_BATCH)));
auto parser = TRT_ptr<nvonnxparser::IParser>(nvonnxparser::createParser(*network, logger));
if (!parser->parseFromFile(onnx_path.c_str(),
static_cast<int>(nvinfer1::ILogger::Severity::kWARNING)))
throw std::runtime_error("ONNX parse failed");
auto config = TRT_ptr<nvinfer1::IBuilderConfig>(builder->createBuilderConfig());
config->setMemoryPoolLimit(nvinfer1::MemoryPoolType::kWORKSPACE, 256ULL << 20);
if (builder->platformHasFastFp16()) config->setFlag(nvinfer1::BuilderFlag::kFP16);
// set optimisation profile if input has dynamic dims
int nb = network->getNbInputs();
if (nb > 0) {
auto in = network->getInput(0);
auto prof = builder->createOptimizationProfile();
nvinfer1::Dims minD = in->getDimensions(), optD = minD, maxD = minD;
for (int d = 0; d < minD.nbDims; ++d) {
if (minD.d[d] < 0) {
minD.d[d] = 1; optD.d[d] = 1; maxD.d[d] = 1;
if (d == 2) { optD.d[d] = 640; maxD.d[d] = 640; }
if (d == 3) { optD.d[d] = 640; maxD.d[d] = 640; }
}
}
prof->setDimensions(in->getName(), nvinfer1::OptProfileSelector::kMIN, minD);
prof->setDimensions(in->getName(), nvinfer1::OptProfileSelector::kOPT, optD);
prof->setDimensions(in->getName(), nvinfer1::OptProfileSelector::kMAX, maxD);
config->addOptimizationProfile(prof);
}
auto plan = TRT_ptr<nvinfer1::IHostMemory>(
builder->buildSerializedNetwork(*network, *config));
if (!plan) throw std::runtime_error("buildSerializedNetwork failed");
runtime.reset(nvinfer1::createInferRuntime(logger));
engine.reset(runtime->deserializeCudaEngine(plan->data(), plan->size()));
if (!cache_path.empty()) {
std::ofstream out(cache_path, std::ios::binary);
out.write(static_cast<const char*>(plan->data()), plan->size());
std::cerr << "[trt] cached: " << cache_path << " (" << plan->size() << " B)\n";
}
init_io();
}
~TensorRTEngine() {
for (auto& kv : buffers) cudaFree(kv.second);
}
void run(const float* input, float* output) {
trt_check(cudaMemcpyAsync(buffers[input_name], input, input_bytes,
cudaMemcpyHostToDevice, *stream));
context->enqueueV3(*stream);
trt_check(cudaMemcpyAsync(output, buffers[output_name], output_bytes,
cudaMemcpyDeviceToHost, *stream));
cudaStreamSynchronize(*stream);
}
std::string input_name, output_name;
size_t input_bytes = 0, output_bytes = 0;
private:
struct Logger : nvinfer1::ILogger {
void log(Severity sev, const char* msg) noexcept override {
if (sev <= Severity::kWARNING) std::cerr << "[trt] " << msg << std::endl;
}
};
Logger logger;
TRT_ptr<nvinfer1::IRuntime> runtime;
TRT_ptr<nvinfer1::ICudaEngine> engine;
TRT_ptr<nvinfer1::IExecutionContext> context;
std::unordered_map<std::string, void*> buffers;
Cuda_stream_ptr stream;
void init_io() {
context.reset(engine->createExecutionContext());
if (!context) throw std::runtime_error("createExecutionContext failed");
int nb = engine->getNbIOTensors();
for (int i = 0; i < nb; ++i) {
auto name = engine->getIOTensorName(i);
auto mode = engine->getTensorIOMode(name);
auto shape = engine->getTensorShape(name);
size_t bytes = 1;
for (int d = 0; d < shape.nbDims; ++d) bytes *= shape.d[d];
bytes *= sizeof(float);
void* ptr = nullptr;
cudaError_t e = cudaMalloc(&ptr, bytes);
if (e != cudaSuccess) throw std::runtime_error(std::string("cudaMalloc:") + cudaGetErrorString(e));
if (!context->setTensorAddress(name, ptr))
throw std::runtime_error(std::string("setTensorAddress: ") + name);
buffers[name] = ptr;
if (mode == nvinfer1::TensorIOMode::kINPUT) {
input_name = name; input_bytes = bytes;
} else {
output_name = name; output_bytes = bytes;
}
}
stream.reset(new cudaStream_t{});
trt_check(cudaStreamCreate(stream.get()));
std::cerr << "[trt] ready: in=" << input_name << " (" << input_bytes
<< "B) out=" << output_name << " (" << output_bytes << "B)\n";
}
};
// ---------------------------------------------------------------------------
// DetectionTracker (TensorRT inference + SORT tracking)
// ---------------------------------------------------------------------------
class DetectionTracker {
public:
DetectionTracker(const CameraConfig& config)
: config(config), imgsz(config.detection.imgsz),
conf_thresh(config.detection.conf),
iou_thresh(config.detection.iou),
verbose(config.performance.verbose),
track_buffer(config.tracker.track_buffer)
{
std::string path = config.detection.model_path;
size_t dot = path.rfind('.');
std::string kind = (dot != std::string::npos) ? path.substr(dot) : "";
if (kind == ".engine" || kind == ".onnx") {
std::string onnx = (kind == ".engine")
? path.substr(0, path.size() - 7) + ".onnx" : path;
use_trt = true;
trt = std::make_unique<TensorRTEngine>(onnx, path + ".cache");
} else {
throw std::runtime_error("Unsupported model format: " + kind);
}
// alloc pinned output buffer (reused every inference)
int out_floats = static_cast<int>(trt->output_bytes / sizeof(float));
cudaMallocHost(&output_buf, trt->output_bytes);
output_buf_size = out_floats;
std::cerr << "[model] ready\n";
}
~DetectionTracker() {
if (output_buf) cudaFreeHost(output_buf);
}
void reset_tracking() {
active_tracks.clear();
last_frame_id = 0;
}
std::vector<TrackObservation> infer(const cv::Mat& frame,
const cv::Rect& crop_rect = {}) {
++last_frame_id;
cv::Mat source;
int off_x = 0, off_y = 0;
if (!crop_rect.empty() && crop_rect.width > 0 && crop_rect.height > 0) {
source = frame(crop_rect);
off_x = crop_rect.x; off_y = crop_rect.y;
} else {
source = frame;
}
float scale; int pad_x, pad_y;
cv::Mat blob = preprocess(source, scale, pad_x, pad_y);
// inference (blob.data is already NCHW float, use directly)
trt->run(reinterpret_cast<float*>(blob.data), output_buf);
// decode
auto dets = decode(scale, pad_x, pad_y, source.cols, source.rows);
auto tracks = associate(dets, off_x, off_y);
if (verbose && last_frame_id % 30 == 0)
std::cerr << "[track] f=" << last_frame_id << " det=" << dets.size()
<< " trk=" << tracks.size() << "\n";
return tracks;
}
CameraConfig config;
private:
bool use_trt = false;
std::unique_ptr<TensorRTEngine> trt;
int imgsz;
float conf_thresh, iou_thresh;
bool verbose;
int track_buffer, last_frame_id = 0;
float* output_buf = nullptr;
int output_buf_size = 0;
// --- Kalman track ---
struct KalmanTrack {
int id; cv::KalmanFilter kf; cv::Rect2f bbox;
int hits = 0, time_since_update = 0;
KalmanTrack(int tid, const cv::Rect2f& b) : id(tid), bbox(b) {
kf.init(7, 4, 0, CV_32F);
kf.transitionMatrix = (cv::Mat_<float>(7, 7) <<
1,0,0,0,1,0,0, 0,1,0,0,0,1,0, 0,0,1,0,0,0,1, 0,0,0,1,0,0,0,
0,0,0,0,1,0,0, 0,0,0,0,0,1,0, 0,0,0,0,0,0,1);
cv::setIdentity(kf.measurementMatrix);
cv::setIdentity(kf.processNoiseCov, cv::Scalar::all(1e-2));
cv::setIdentity(kf.measurementNoiseCov, cv::Scalar::all(1e-1));
cv::setIdentity(kf.errorCovPost, cv::Scalar::all(1));
kf.statePost.at<float>(0) = b.x + b.width/2;
kf.statePost.at<float>(1) = b.y + b.height/2;
kf.statePost.at<float>(2) = b.area();
kf.statePost.at<float>(3) = b.width / b.height;
}
cv::Rect2f predict() {
cv::Mat p = kf.predict();
float w = std::sqrt(std::max(1.0f, p.at<float>(2) * p.at<float>(3)));
float h = std::max(1.0f, p.at<float>(2) / w);
bbox = cv::Rect2f(p.at<float>(0) - w/2, p.at<float>(1) - h/2, w, h);
return bbox;
}
void update(const cv::Rect2f& b) {
float cx = b.x + b.width/2, cy = b.y + b.height/2;
kf.correct((cv::Mat_<float>(4, 1) << cx, cy, b.area(), b.width / b.height));
bbox = b; hits++; time_since_update = 0;
}
};
std::vector<KalmanTrack> active_tracks;
int next_track_id = 1;
// --- preprocess ---
cv::Mat preprocess(const cv::Mat& img, float& scale, int& pad_x, int& pad_y) {
int w = img.cols, h = img.rows;
scale = static_cast<float>(imgsz) / std::max(w, h);
int nw = static_cast<int>(w * scale), nh = static_cast<int>(h * scale);
pad_x = (imgsz - nw) / 2;
pad_y = (imgsz - nh) / 2;
cv::Mat r, p;
cv::resize(img, r, {nw, nh});
cv::copyMakeBorder(r, p, pad_y, imgsz - nh - pad_y, pad_x, imgsz - nw - pad_x,
cv::BORDER_CONSTANT, {114, 114, 114});
return cv::dnn::blobFromImage(p, 1.0/255.0, {imgsz, imgsz}, cv::Scalar(), true, false);
}
// --- decode: model outputs (1, 300, 6) = [x1,y1,x2,y2,conf,cls] in letterbox space ---
std::vector<cv::Rect2f> decode(float scale, int pad_x, int pad_y, int ow, int oh) {
std::vector<cv::Rect> iboxes; iboxes.reserve(32);
std::vector<float> scores; scores.reserve(32);
const float* d = output_buf;
int stride = 6; // (x1,y1,x2,y2,conf,cls) per detection
for (int i = 0; i < output_buf_size / stride; ++i) {
const float* row = d + i * stride;
float conf = row[4];
if (conf < conf_thresh) continue;
// boxes are in letterbox coordinates, scale back
float x1 = (row[0] - pad_x) / scale;
float y1 = (row[1] - pad_y) / scale;
float x2 = (row[2] - pad_x) / scale;
float y2 = (row[3] - pad_y) / scale;
int ix1 = std::max(0, std::min(static_cast<int>(x1), ow));
int iy1 = std::max(0, std::min(static_cast<int>(y1), oh));
int ix2 = std::max(0, std::min(static_cast<int>(x2), ow));
int iy2 = std::max(0, std::min(static_cast<int>(y2), oh));
if (ix2 > ix1 && iy2 > iy1) {
iboxes.push_back({ix1, iy1, ix2 - ix1, iy2 - iy1});
scores.push_back(conf);
}
}
std::vector<int> idx;
cv::dnn::NMSBoxes(iboxes, scores, conf_thresh, iou_thresh, idx);
std::vector<cv::Rect2f> out; out.reserve(idx.size());
for (int i : idx) out.push_back(iboxes[i]);
return out;
}
// --- SORT association ---
std::vector<TrackObservation> associate(const std::vector<cv::Rect2f>& dets, int ox, int oy) {
for (auto& t : active_tracks) { t.predict(); t.time_since_update++; }
int nd = static_cast<int>(dets.size()), nt = static_cast<int>(active_tracks.size());
if (nd == 0) goto cleanup;
{
std::vector<std::vector<double>> iou(nt, std::vector<double>(nd));
for (int t = 0; t < nt; ++t)
for (int d = 0; d < nd; ++d)
iou[t][d] = 1.0 - _iou(active_tracks[t].bbox, dets[d]);
std::vector<int> order(nd); for (int i = 0; i < nd; ++i) order[i] = i;
std::sort(order.begin(), order.end(), [&](int a, int b){ return dets[a].area() > dets[b].area(); });
std::vector<bool> used(nd, false); std::vector<int> match(nt, -1);
for (int d : order) {
int best = -1; double best_cost = 0.3;
for (int t = 0; t < nt; ++t) {
if (match[t] >= 0) continue;
if (iou[t][d] < best_cost) { best_cost = iou[t][d]; best = t; }
}
if (best >= 0) { match[best] = d; used[d] = true; }
}
for (int t = 0; t < nt; ++t) if (match[t] >= 0) active_tracks[t].update(dets[match[t]]);
for (int d = 0; d < nd; ++d) if (!used[d]) {
KalmanTrack tk(++next_track_id, dets[d]); tk.hits = 1; active_tracks.push_back(tk);
}
}
cleanup:
active_tracks.erase(std::remove_if(active_tracks.begin(), active_tracks.end(),
[this](const KalmanTrack& t){ return t.time_since_update > track_buffer; }),
active_tracks.end());
std::vector<TrackObservation> out;
for (const auto& t : active_tracks) {
if (t.hits < 3) continue;
auto& b = t.bbox;
int x1 = static_cast<int>(b.x) + ox, y1 = static_cast<int>(b.y) + oy;
int x2 = static_cast<int>(b.x + b.width) + ox, y2 = static_cast<int>(b.y + b.height) + oy;
out.push_back({t.id, 0, 0.9f, x1, y1, x2, y2, (x1+x2)/2, (y1+y2)/2, {}});
}
return out;
}
static double _iou(const cv::Rect2f& a, const cv::Rect2f& b) {
float ix1 = std::max(a.x, b.x), iy1 = std::max(a.y, b.y);
float ix2 = std::min(a.x + a.width, b.x + b.width);
float iy2 = std::min(a.y + a.height, b.y + b.height);
if (ix2 <= ix1 || iy2 <= iy1) return 0.0;
float I = (ix2 - ix1) * (iy2 - iy1);
float U = a.area() + b.area() - I;
return U > 0 ? I / U : 0.0;
}
};
} // namespace cc
-63
View File
@@ -1,63 +0,0 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include <opencv2/core/types.hpp>
namespace cc {
struct TrackObservation {
int track_id;
int class_id;
float confidence;
int bbox_x1, bbox_y1, bbox_x2, bbox_y2;
int centroid_x, centroid_y;
std::vector<cv::Point2i> mask_polygon_xy;
};
struct CountEvent {
int track_id;
int frame_index;
int total_entered_after_event;
int sequence_number;
};
struct MotionState {
float smoothed_speed = 0.0f;
int consecutive_reverse_frames = 0;
bool backward_active = false;
};
struct FrameResult {
int frame_index = 0;
std::vector<TrackObservation> tracks;
int inside_box_count = 0;
int total_entered_count = 0;
std::optional<int> latest_validated_track_id;
MotionState motion_state;
std::vector<CountEvent> count_events;
};
struct PipelineResult {
std::string camera_id;
int total_entered_count;
int frames_processed;
std::string stopped_reason;
std::string vis_video_path;
std::string source_video;
double elapsed_seconds;
};
struct CameraBatchResult {
std::string camera_id;
PipelineResult pipeline;
bool skipped = false;
std::string skip_reason;
std::string compressed_video_path;
double compressed_size_mb = 0.0;
};
} // namespace cc
@@ -1,66 +0,0 @@
#pragma once
#include <cstdint>
#include <filesystem>
#include <iostream>
#include <string>
#include <vector>
#include <opencv2/videoio.hpp>
namespace cc {
inline cv::VideoWriter make_video_writer(
const std::string& path,
const cv::Size& frame_size,
double fps,
const std::string& encoder = "auto",
int output_bitrate_kbps = 4000,
const std::vector<std::string>& codec_preference = {})
{
namespace fs = std::filesystem;
fs::create_directories(fs::path(path).parent_path());
int bitrate_bps = std::max(1, output_bitrate_kbps) * 1000;
auto codecs = codec_preference.empty()
? std::vector<std::string>{"avc1", "mp4v", "H264"}
: codec_preference;
if (encoder == "auto" || encoder == "gstreamer") {
int fps_int = std::max(1, static_cast<int>(std::round(fps)));
std::string pipeline =
"appsrc ! video/x-raw, format=BGR ! "
"video/x-raw,width=" + std::to_string(frame_size.width) +
",height=" + std::to_string(frame_size.height) +
",framerate=" + std::to_string(fps_int) + "/1 ! "
"videoconvert ! nvvidconv ! "
"video/x-raw(memory:NVMM),format=NV12 ! "
"nvv4l2h264enc bitrate=" + std::to_string(bitrate_bps) +
" insert-sps-pps=true ! "
"h264parse ! mp4mux ! filesink location=" + path;
cv::VideoWriter writer(pipeline, cv::CAP_GSTREAMER, 0, fps, frame_size, true);
if (writer.isOpened()) {
std::cerr << "[video] opened GStreamer hardware encoder (bitrate="
<< output_bitrate_kbps << " kbps)\n";
return writer;
}
writer.release();
if (encoder == "gstreamer")
throw std::runtime_error("GStreamer video writer failed for: " + path);
}
for (const auto& codec : codecs) {
int fourcc = cv::VideoWriter::fourcc(codec[0], codec[1], codec[2], codec[3]);
cv::VideoWriter writer(path, fourcc, fps, frame_size);
if (writer.isOpened()) {
std::cerr << "[video] opened OpenCV encoder (codec=" << codec << ")\n";
return writer;
}
writer.release();
}
throw std::runtime_error("Unable to open video writer: " + path);
}
} // namespace cc
-140
View File
@@ -1,140 +0,0 @@
#include "chicken_counter/batch_runner.hpp"
#include <chrono>
#include <cstdio>
#include <ctime>
#include <filesystem>
#include <iostream>
#include <string>
#include <vector>
#include "chicken_counter/batch_discovery.hpp"
#include "chicken_counter/compress.hpp"
#include "chicken_counter/pipeline.hpp"
#include "chicken_counter/report.hpp"
#include "chicken_counter/tracking.hpp"
#include "chicken_counter/types.hpp"
namespace cc {
static std::string today_iso() {
auto now = std::chrono::system_clock::now();
auto t = std::chrono::system_clock::to_time_t(now);
char buf[16];
std::strftime(buf, sizeof(buf), "%Y-%m-%d", std::localtime(&t));
return buf;
}
std::string run_daily_batch(const BatchSettings& settings,
const std::string& date,
bool verbose,
bool no_video,
bool show_progress) {
namespace fs = std::filesystem;
std::string run_date = date.empty() ? today_iso() : date;
auto day_dir = fs::path(settings.batch.root_dir) / run_date;
auto output_dir = day_dir / settings.batch.output_subdir;
fs::create_directories(output_dir);
std::fprintf(stderr, "[batch] starting daily run for %s\n", run_date.c_str());
std::fprintf(stderr, "[batch] input folder: %s\n", day_dir.c_str());
std::fprintf(stderr, "[batch] output folder: %s\n", output_dir.c_str());
if (no_video)
std::fprintf(stderr, "[batch] --no-video: skipping video output, overlay, and compression\n");
auto discovery = discover_camera_videos(day_dir.string(), settings);
using pair_t = std::pair<std::string, CameraPreset>;
std::vector<pair_t> sorted_cams(settings.cameras.begin(), settings.cameras.end());
std::sort(sorted_cams.begin(), sorted_cams.end(),
[](const pair_t& a, const pair_t& b) {
return a.second.camera_num < b.second.camera_num;
});
std::string first_id;
for (const auto& [id, _] : sorted_cams) {
if (discovery.found.count(id)) { first_id = id; break; }
}
auto first_source = discovery.found[first_id];
auto init_out = no_video ? "" : (output_dir / (first_id + "_vis.mp4")).string();
auto init_ckpt = (output_dir / "checkpoints" / first_id).string();
auto init_cfg = build_camera_config_from_batch(
settings, first_id, first_source, init_out, init_ckpt);
DetectionTracker shared_tracker(init_cfg);
std::vector<CameraBatchResult> camera_results;
auto report_path = output_dir / ("counts_" + run_date + ".json");
for (const auto& [camera_id, _] : sorted_cams) {
if (discovery.skipped.count(camera_id)) {
auto reason = discovery.skipped.at(camera_id);
std::fprintf(stderr, "[batch] skipping %s: %s\n", camera_id.c_str(), reason.c_str());
CameraBatchResult cr;
cr.camera_id = camera_id;
cr.skipped = true;
cr.skip_reason = reason;
camera_results.push_back(cr);
persist_batch_reports(run_date, camera_results, output_dir.string());
continue;
}
auto source_path = discovery.found.at(camera_id);
auto vis_path = no_video ? "" : (output_dir / (camera_id + "_vis.mp4")).string();
auto checkpoint_dir = (output_dir / "checkpoints" / camera_id).string();
std::fprintf(stderr, "[batch] processing %s from %s\n",
camera_id.c_str(),
fs::path(source_path).filename().c_str());
auto cam_cfg = build_camera_config_from_batch(
settings, camera_id, source_path, vis_path, checkpoint_dir);
cam_cfg.performance.verbose = verbose;
auto result = run_pipeline(cam_cfg, &shared_tracker, show_progress);
CameraBatchResult cr;
cr.camera_id = camera_id;
cr.pipeline = result;
camera_results.push_back(cr);
std::fprintf(stderr, "[batch] finished %s: total_entered=%d frames=%d reason=%s\n",
camera_id.c_str(), result.total_entered_count,
result.frames_processed, result.stopped_reason.c_str());
persist_batch_reports(run_date, camera_results, output_dir.string());
}
if (no_video) {
auto report = build_batch_report(run_date, camera_results, output_dir.string());
std::fprintf(stderr, "[batch] complete for %s: total_entered_sum=%d report=%s\n",
run_date.c_str(), report.total_entered_sum, report_path.c_str());
return report_path.string();
}
std::fprintf(stderr, "[batch] all cameras complete; starting compression\n");
for (auto& item : camera_results) {
if (item.skipped) continue;
auto vis_path = item.pipeline.vis_video_path;
if (vis_path.empty()) continue;
auto compressed_path = (output_dir / (item.camera_id + "_compressed.mp4")).string();
double size_mb = compress_video_to_target(
vis_path, compressed_path,
settings.batch.compress_max_mb);
item.compressed_video_path = compressed_path;
item.compressed_size_mb = size_mb;
if (settings.batch.delete_intermediate)
fs::remove(vis_path);
persist_batch_reports(run_date, camera_results, output_dir.string());
}
auto report = build_batch_report(run_date, camera_results, output_dir.string());
std::fprintf(stderr, "[batch] complete for %s: total_entered_sum=%d report=%s\n",
run_date.c_str(), report.total_entered_sum, report_path.c_str());
return report_path.string();
}
} // namespace cc
-284
View File
@@ -1,284 +0,0 @@
#include <algorithm>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
#include <arpa/inet.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
static const int DEFAULT_PORT = 8080;
static const char* DEFAULT_SHM = "/dev/shm";
static const int DEFAULT_POLL_MS = 500;
static const char* HTML = R"~(<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Chicken Counter - Live Dashboard</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:hidden}
#app{display:flex;height:100vh}
#sidebar{width:260px;background:#16161e;padding:16px;overflow-y:auto;flex-shrink:0}
#sidebar h1{font-size:18px;color:#80dc5a;margin-bottom:16px}
#sidebar .stat{margin-bottom:12px}
#sidebar .stat label{display:block;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:1px}
#sidebar .stat .value{font-size:22px;font-weight:700;color:#e0e0e0}
#sidebar .stat .value.warn{color:#ff9f43}
#sidebar .stat .value.good{color:#80dc5a}
#cam-list{list-style:none;margin-top:16px}
#cam-list li{padding:8px 10px;margin:2px 0;border-radius:6px;cursor:pointer;font-size:13px;transition:background .2s}
#cam-list li:hover{background:#222}
#cam-list li.active{background:#1a3a2a;color:#80dc5a;font-weight:700}
#cam-list li .cam-badge{float:right;font-size:10px;padding:1px 6px;border-radius:8px;background:#222;color:#888}
#cam-list li.active .cam-badge{background:#2a5a3a;color:#80dc5a}
#main{flex:1;display:flex;flex-direction:column}
#frame-container{flex:1;display:flex;align-items:center;justify-content:center;background:#000;position:relative}
#frame-img{max-width:100%;max-height:100%;object-fit:contain}
#no-frame{color:#555;font-size:18px}
#top-bar{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:#16161e;font-size:12px}
#top-bar .refresh{color:#888}
#top-bar .status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
#top-bar .status-dot.online{background:#80dc5a;box-shadow:0 0 6px #80dc5a}
#top-bar .status-dot.offline{background:#555}
.refresh-btn{padding:4px 12px;border-radius:4px;background:#222;border:1px solid #444;color:#ccc;cursor:pointer;font-size:11px}
.refresh-btn:hover{background:#333}
</style>
</head>
<body>
<div id="app">
<div id="sidebar">
<h1>Chicken Counter</h1>
<div class="stat"><label>Total Entered</label><div class="value good" id="stat-total">--</div></div>
<div class="stat"><label>Inside Box</label><div class="value" id="stat-inside">--</div></div>
<div class="stat"><label>Tracks</label><div class="value" id="stat-tracks">--</div></div>
<div class="stat"><label>Frame</label><div class="value" id="stat-frame">--</div></div>
<div class="stat"><label>Motion Speed</label><div class="value" id="stat-speed">--</div></div>
<div class="stat"><label>Status</label><div class="value" id="stat-status">--</div></div>
<ul id="cam-list"></ul>
</div>
<div id="main">
<div id="top-bar">
<span><span class="status-dot" id="status-dot"></span><span id="status-text">waiting for pipeline...</span></span>
<span><span class="refresh" id="refresh-counter"></span> ago &nbsp;
<button class="refresh-btn" onclick="load()">Refresh</button></span>
</div>
<div id="frame-container">
<img id="frame-img" alt="live stream">
<div id="no-frame"></div>
</div>
</div>
</div>
<script>
var POLL_MS=%%POLL%%;
var SHM="%%SHM%%";
var cameras=[],activeCam=null,lastUpdate=0;
var img=document.getElementById("frame-img");
var noFrame=document.getElementById("no-frame");
function loadCameras(){fetch("/api/cameras").then(r=>r.json()).then(data=>{cameras=data.cameras||[];renderCamList();if(cameras.length&&!activeCam)selectCam(cameras[0]);if(!cameras.length){noFrame.textContent="No cameras in "+SHM;img.style.display="none";}});}
function renderCamList(){var ul=document.getElementById("cam-list");ul.innerHTML=cameras.map(function(c){return'<li class="'+(c===activeCam?"active":"")+'" onclick="selectCam(\''+c+'\')">'+c+'<span class="cam-badge">&#9654;</span></li>';}).join("");}
function selectCam(id){activeCam=id;renderCamList();load();}
function load(){if(!activeCam)return;var t=Date.now();img.src="/shm/"+activeCam+"/frame.jpg?t="+t;fetch("/shm/"+activeCam+"/stats.json?t="+t).then(function(r){if(!r.ok){setOffline();return;}return r.json();}).then(function(s){if(!s)return;lastUpdate=Date.now();document.getElementById("stat-total").textContent=s.total_entered_count;document.getElementById("stat-inside").textContent=s.inside_box_count;document.getElementById("stat-tracks").textContent=s.track_count;document.getElementById("stat-frame").textContent=s.frame_index;document.getElementById("stat-speed").textContent=s.smoothed_speed;document.getElementById("stat-status").textContent=s.backward_active?"BACKWARD STOP":"RUNNING";var el=document.getElementById("stat-status");el.className="value"+(s.backward_active?" warn":" good");document.getElementById("status-dot").className="status-dot online";document.getElementById("status-text").textContent=activeCam+" - frame "+s.frame_index;});}
function setOffline(){document.getElementById("status-dot").className="status-dot offline";document.getElementById("status-text").textContent=activeCam+" - offline";}
function updateRefresh(){var ago=Math.round((Date.now()-lastUpdate)/1000);document.getElementById("refresh-counter").textContent=ago+"s";}
img.onerror=function(){img.style.display="none";noFrame.style.display="block";noFrame.textContent="Waiting for frame...";};
img.onload=function(){img.style.display="block";noFrame.style.display="none";};
setInterval(function(){load();},POLL_MS);
setInterval(loadCameras,3000);
setInterval(updateRefresh,1000);
loadCameras();
</script>
</body>
</html>)~";
static std::string url_decode(const std::string& s) {
std::string r;
for (size_t i = 0; i < s.size(); ++i) {
if (s[i] == '%' && i + 2 < s.size()) {
int v;
sscanf(s.c_str() + i + 1, "%2x", &v);
r += static_cast<char>(v);
i += 2;
} else {
r += s[i];
}
}
return r;
}
static std::string read_file(const std::string& path) {
std::ifstream f(path, std::ios::binary | std::ios::ate);
if (!f) return "";
auto sz = f.tellg();
f.seekg(0);
std::string data(sz, 0);
f.read(data.data(), sz);
return data;
}
static bool ends_with(const std::string& s, const std::string& suffix) {
return s.size() >= suffix.size() && s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0;
}
static bool starts_with(const std::string& s, const std::string& prefix) {
return s.size() >= prefix.size() && s.compare(0, prefix.size(), prefix) == 0;
}
static std::string get_mime(const std::string& path) {
if (ends_with(path, ".jpg") || ends_with(path, ".jpeg")) return "image/jpeg";
if (ends_with(path, ".json")) return "application/json";
if (ends_with(path, ".html")) return "text/html; charset=utf-8";
return "application/octet-stream";
}
static std::string http_response(int code, const std::string& ct,
const std::string& body) {
std::ostringstream r;
r << "HTTP/1.0 " << code << " OK\r\n";
r << "Content-Type: " << ct << "\r\n";
r << "Content-Length: " << body.size() << "\r\n";
r << "Cache-Control: no-cache, no-store, must-revalidate\r\n";
r << "Connection: close\r\n";
r << "\r\n" << body;
return r.str();
}
static std::string str_replace(std::string s, const std::string& from,
const std::string& to) {
size_t pos = s.find(from);
if (pos != std::string::npos) s.replace(pos, from.size(), to);
return s;
}
static std::string json_escape(const std::string& s) {
std::ostringstream r;
r << '"';
for (char c : s) {
if (c == '"') r << "\\\"";
else if (c == '\\') r << "\\\\";
else r << c;
}
r << '"';
return r.str();
}
static void handle_client(int fd, const std::string& shm_dir, int poll_ms) {
char buf[8192];
ssize_t n = recv(fd, buf, sizeof(buf) - 1, 0);
if (n <= 0) { close(fd); return; }
buf[n] = 0;
std::string req(buf);
if (req.find("GET ") != 0) { close(fd); return; }
// parse path
size_t p1 = req.find(' ');
size_t p2 = req.find(' ', p1 + 1);
std::string path = url_decode(req.substr(p1 + 1, p2 - p1 - 1));
// strip query string
size_t q = path.find('?');
if (q != std::string::npos) path = path.substr(0, q);
std::string resp;
if (path == "/" || path == "/index.html") {
std::string html = HTML;
html = str_replace(html, "%%POLL%%", std::to_string(poll_ms));
html = str_replace(html, "%%SHM%%", shm_dir);
resp = http_response(200, "text/html; charset=utf-8", html);
} else if (path == "/api/cameras") {
std::string cams = "[]";
if (std::filesystem::is_directory(shm_dir)) {
std::ostringstream arr;
arr << "[";
bool first = true;
for (auto& entry : std::filesystem::directory_iterator(shm_dir)) {
if (!entry.is_directory()) continue;
std::string name = entry.path().filename().string();
if (!starts_with(name, "chicken_counter_")) continue;
if (!first) arr << ","; first = false;
arr << json_escape(name.substr(17)); // strip "chicken_counter_"
}
arr << "]";
cams = arr.str();
}
resp = http_response(200, "application/json", "{\"cameras\":" + cams + "}");
} else if (starts_with(path, "/shm/")) {
std::string rel = path.substr(5);
size_t slash = rel.find('/');
if (slash != std::string::npos) {
std::string cam = "chicken_counter_" + rel.substr(0, slash);
std::string file = rel.substr(slash + 1);
std::string fpath = shm_dir + "/" + cam + "/" + file;
// security: avoid path traversal
auto resolved = std::filesystem::weakly_canonical(fpath);
auto base = std::filesystem::weakly_canonical(shm_dir);
if (starts_with(resolved.string(), base.string())) {
auto data = read_file(resolved.string());
if (!data.empty()) {
resp = http_response(200, get_mime(file), data);
}
}
}
}
if (resp.empty())
resp = "HTTP/1.0 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
send(fd, resp.data(), resp.size(), 0);
close(fd);
}
int main(int argc, char** argv) {
int port = DEFAULT_PORT;
std::string shm_dir = DEFAULT_SHM;
int poll_ms = DEFAULT_POLL_MS;
for (int i = 1; i < argc; ++i) {
std::string arg = argv[i];
if (arg == "--port" && i + 1 < argc) port = std::stoi(argv[++i]);
else if (arg == "--shm-dir" && i + 1 < argc) shm_dir = argv[++i];
else if (arg == "--poll-ms" && i + 1 < argc) poll_ms = std::stoi(argv[++i]);
}
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0) { perror("socket"); return 1; }
int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(port);
if (bind(sock, (sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind"); return 1;
}
listen(sock, 16);
std::cerr << "[dashboard] serving at http://0.0.0.0:" << port << "\n";
std::cerr << "[dashboard] shm_dir=" << shm_dir << " poll=" << poll_ms << "ms\n";
while (true) {
sockaddr_in client{};
socklen_t len = sizeof(client);
int client_fd = accept(sock, (sockaddr*)&client, &len);
if (client_fd < 0) continue;
std::thread(handle_client, client_fd, shm_dir, poll_ms).detach();
}
close(sock);
return 0;
}
-59
View File
@@ -1,59 +0,0 @@
#include <cstring>
#include <iostream>
#include <string>
#include "chicken_counter/batch_runner.hpp"
#include "chicken_counter/config.hpp"
#include "chicken_counter/pipeline.hpp"
static void print_usage() {
std::cerr <<
"Usage: chicken_counter run --config PATH [--camera-id ID] [--verbose] [--progress-bar]\n"
" chicken_counter batch --config PATH [--date YYYY-MM-DD] [--verbose] [--no-video] [--progress-bar]\n";
}
int main(int argc, char** argv) {
if (argc < 2) { print_usage(); return 1; }
std::string command = argv[1];
// parse optional args
std::string config_path, camera_id, date;
bool verbose = false, no_video = false, progress_bar = false;
for (int i = 2; i < argc; ++i) {
std::string arg = argv[i];
if (arg == "--config" && i + 1 < argc) config_path = argv[++i];
else if (arg == "--camera-id" && i + 1 < argc) camera_id = argv[++i];
else if (arg == "--date" && i + 1 < argc) date = argv[++i];
else if (arg == "--verbose") verbose = true;
else if (arg == "--no-video") no_video = true;
else if (arg == "--progress-bar") progress_bar = true;
}
if (config_path.empty()) {
std::cerr << "error: --config is required\n";
return 1;
}
if (command == "batch") {
auto settings = cc::load_batch_config(config_path);
cc::run_daily_batch(settings, date, verbose, no_video, progress_bar);
return 0;
}
if (command == "run") {
auto cfg = cc::load_camera_config(config_path, camera_id);
cfg.performance.verbose = verbose;
auto result = cc::run_pipeline(cfg, nullptr, progress_bar);
std::cout << "[done] camera=" << result.camera_id
<< " total_entered=" << result.total_entered_count
<< " frames=" << result.frames_processed
<< " reason=" << result.stopped_reason << "\n";
return 0;
}
std::cerr << "error: unknown command '" << command << "'\n";
print_usage();
return 1;
}
-480
View File
@@ -1,480 +0,0 @@
#include "chicken_counter/pipeline.hpp"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <filesystem>
#include <iostream>
#include <string>
#include <vector>
#include <opencv2/highgui.hpp>
#include <opencv2/imgcodecs.hpp>
#include <opencv2/videoio.hpp>
#include "chicken_counter/capture.hpp"
#include "chicken_counter/overlay.hpp"
#include "chicken_counter/video_writer.hpp"
namespace cc {
// ---------------------------------------------------------------------------
// Progress bar (same as Python _ProgressBar, stderr inline)
// ---------------------------------------------------------------------------
static std::string format_duration(double seconds) {
if (seconds < 60.0) return std::to_string(static_cast<int>(seconds)) + "s";
int total = static_cast<int>(seconds);
int minutes = total / 60;
int secs = total % 60;
if (minutes < 60) return std::to_string(minutes) + "m" + (secs < 10 ? "0" : "") + std::to_string(secs) + "s";
int hours = minutes / 60;
minutes %= 60;
return std::to_string(hours) + "h" + (minutes < 10 ? "0" : "") + std::to_string(minutes) + "m";
}
class ProgressBar {
public:
ProgressBar(int total, int width = 30) : _total(total), _width(width) {}
void render(int frame_idx, double elapsed, double fps,
int inside, int total_entered, bool backward) {
double now = std::chrono::duration<double>(
std::chrono::steady_clock::now().time_since_epoch()).count();
if (now - _last_render < 0.2 && frame_idx > 1 && _checkpoint_msg.empty()) return;
_last_render = now;
std::string cp = build_checkpoint_suffix();
std::string status = backward ? "backward" : "running";
std::string elapsed_s = format_duration(elapsed);
std::string line;
if (_total > 0) {
int pct = std::min(100, frame_idx * 100 / _total);
int filled = _width * pct / 100;
std::string bar = "[" + std::string(filled, '=') + ">" + std::string(_width - filled, ' ') + "]";
double eta_s = fps > 0 ? (_total - frame_idx) / fps : 0.0;
char buf[256];
snprintf(buf, sizeof(buf), "\r%s %3d%% %d/%d %s eta=%s %.1ffps count=%d/%d %s%s",
bar.c_str(), pct, frame_idx, _total,
elapsed_s.c_str(), format_duration(eta_s).c_str(),
fps, inside, total_entered, status.c_str(), cp.c_str());
line = buf;
} else {
char buf[256];
snprintf(buf, sizeof(buf), "\rframe=%d %s %.1ffps count=%d/%d %s%s",
frame_idx, elapsed_s.c_str(), fps,
inside, total_entered, status.c_str(), cp.c_str());
line = buf;
}
int pad = std::max(0, _last_line_len - static_cast<int>(line.size()));
_last_line_len = static_cast<int>(line.size());
std::fprintf(stderr, "%s%s", line.c_str(), std::string(pad, ' ').c_str());
std::fflush(stderr);
}
void emit(const std::string& msg) {
_checkpoint_msg = msg;
_last_render = 0.0;
}
void finish() {
std::fprintf(stderr, "\n");
std::fflush(stderr);
}
bool has_pending() const { return !_checkpoint_msg.empty(); }
private:
int _total, _width;
double _last_render = 0.0;
int _last_line_len = 0;
std::string _checkpoint_msg;
std::string build_checkpoint_suffix() {
if (_checkpoint_msg.empty()) return "";
std::string m = _checkpoint_msg;
_checkpoint_msg.clear();
return " [" + m + "]";
}
};
// ---------------------------------------------------------------------------
// build_pipeline
// ---------------------------------------------------------------------------
PipelineArtifacts build_pipeline(const CameraConfig& config,
DetectionTracker* tracker) {
PipelineArtifacts art{};
art.capture = open_capture(config.source);
art.owns_tracker = (tracker == nullptr);
art.tracker = tracker ? tracker : new DetectionTracker(config);
art.counting_zone = CountingZone(
config.roi, config.gate,
config.overlay.trail_length,
config.tracker.track_buffer,
config.detection.min_box_area_px,
config.detection.validate_while_inside,
config.performance.verbose);
art.motion_detector = BackwardMotionDetector(
config.motion, config.roi, config.performance.verbose);
int width = static_cast<int>(art.capture.get(cv::CAP_PROP_FRAME_WIDTH));
int height = static_cast<int>(art.capture.get(cv::CAP_PROP_FRAME_HEIGHT));
int fc = static_cast<int>(art.capture.get(cv::CAP_PROP_FRAME_COUNT));
art.total_source_frames = (fc > 0) ? fc : 0;
if (config.detection_zone.enabled && width > 0 && height > 0) {
art.detection_zone_rect = config.detection_zone.compute_rect(config.roi, width, height);
art.has_detection_zone = true;
std::fprintf(stderr, "[detection_zone] enabled crop=(%d,%d)-(%d,%d)\n",
art.detection_zone_rect.x, art.detection_zone_rect.y,
art.detection_zone_rect.x + art.detection_zone_rect.width,
art.detection_zone_rect.y + art.detection_zone_rect.height);
}
if (config.performance.overlay_buffer_reuse && width > 0 && height > 0)
art.overlay_buffer = cv::Mat(height, width, CV_8UC3);
art.has_writer = !config.display.output_path.empty();
if (art.has_writer) {
double fps = config.display.write_fps > 0
? static_cast<double>(config.display.write_fps)
: art.capture.get(cv::CAP_PROP_FPS);
if (fps <= 0) fps = 30.0;
art.writer = make_video_writer(
config.display.output_path, {width, height}, fps,
config.display.encoder, config.display.output_bitrate_kbps,
config.display.codec_preference);
}
if (config.stream.enabled) {
namespace fs = std::filesystem;
auto cam_dir = fs::path(config.stream.shm_dir) / ("chicken_counter_" + config.camera_id);
if (fs::exists(cam_dir)) {
fs::remove_all(cam_dir);
std::fprintf(stderr, "[stream] cleaned %s\n", cam_dir.c_str());
}
}
art.run_start_time = std::chrono::duration<double>(
std::chrono::steady_clock::now().time_since_epoch()).count();
return art;
}
// ---------------------------------------------------------------------------
// Helper: should_emit_feedback
// ---------------------------------------------------------------------------
static bool should_emit_feedback(const CameraConfig& cfg, int frame_idx) {
if (!cfg.feedback.enabled || cfg.feedback.every_n_frames <= 0) return false;
return frame_idx % cfg.feedback.every_n_frames == 0;
}
// ---------------------------------------------------------------------------
// Helper: emit_periodic_feedback
// ---------------------------------------------------------------------------
static void emit_periodic_feedback(const CameraConfig& cfg,
const PipelineArtifacts& art,
const cv::Mat& annotated,
const FrameResult& result,
ProgressBar* progress) {
if (cfg.feedback.log_to_terminal) {
double elapsed = std::chrono::duration<double>(
std::chrono::steady_clock::now().time_since_epoch()).count()
- art.run_start_time;
double fps = result.frame_index / elapsed;
std::string status = result.motion_state.backward_active ? "backward_stop" : "running";
char buf[512];
if (art.total_source_frames > 0) {
double eta_s = fps > 0 ? (art.total_source_frames - result.frame_index) / fps : 0.0;
snprintf(buf, sizeof(buf),
"[checkpoint] frame=%d/%d elapsed=%s fps=%.1f inside_box=%d "
"total_entered=%d backward_active=%d status=%s eta=%s",
result.frame_index, art.total_source_frames,
format_duration(elapsed).c_str(), fps,
result.inside_box_count, result.total_entered_count,
result.motion_state.backward_active, status.c_str(),
format_duration(eta_s).c_str());
} else {
snprintf(buf, sizeof(buf),
"[checkpoint] frame=%d elapsed=%s fps=%.1f inside_box=%d "
"total_entered=%d backward_active=%d status=%s",
result.frame_index, format_duration(elapsed).c_str(), fps,
result.inside_box_count, result.total_entered_count,
result.motion_state.backward_active, status.c_str());
}
if (progress) progress->emit(buf);
else std::fprintf(stderr, "\r\033[K%s\n", buf);
}
if (cfg.feedback.save_images) {
namespace fs = std::filesystem;
fs::create_directories(cfg.feedback.image_output_dir);
char fname[512];
snprintf(fname, sizeof(fname), "%s/frame_%06d.jpg",
cfg.feedback.image_output_dir.c_str(), result.frame_index);
cv::imwrite(fname, annotated);
}
}
// ---------------------------------------------------------------------------
// Helper: write_stream_frame
// ---------------------------------------------------------------------------
static void write_stream_frame(const std::string& shm_dir,
const std::string& camera_id,
const cv::Mat& frame,
const FrameResult& result) {
namespace fs = std::filesystem;
auto cam_dir = fs::path(shm_dir) / ("chicken_counter_" + camera_id);
fs::create_directories(cam_dir);
auto jpg_path = cam_dir / "frame.jpg";
auto tmp_jpg = cam_dir / ".frame_tmp.jpg";
cv::imwrite(tmp_jpg.string(), frame, {cv::IMWRITE_JPEG_QUALITY, 75});
fs::rename(tmp_jpg, jpg_path);
// Write stats.json atomically
auto stats_path = cam_dir / "stats.json";
auto tmp_stats = cam_dir / ".stats_tmp.json";
{
std::ofstream f(tmp_stats);
f << "{\"frame_index\":" << result.frame_index
<< ",\"inside_box_count\":" << result.inside_box_count
<< ",\"total_entered_count\":" << result.total_entered_count
<< ",\"track_count\":" << result.tracks.size()
<< ",\"backward_active\":" << (result.motion_state.backward_active ? "true" : "false")
<< ",\"smoothed_speed\":" << result.motion_state.smoothed_speed
<< ",\"count_events\":" << result.count_events.size() << "}";
}
fs::rename(tmp_stats, stats_path);
}
// ---------------------------------------------------------------------------
// run_pipeline (main loop)
// ---------------------------------------------------------------------------
PipelineResult run_pipeline(const CameraConfig& config,
DetectionTracker* tracker,
bool show_progress) {
if (tracker) {
tracker->config = config;
tracker->reset_tracking();
}
auto art = build_pipeline(config, tracker);
std::unique_ptr<DetectionTracker> owned_tracker;
if (art.owns_tracker) owned_tracker.reset(art.tracker);
int inference_stride = std::max(1, config.performance.inference_stride);
std::fprintf(stderr, "[perf] inference_stride=%d motion.stride_frames=%d motion.flow_scale=%.1f\n",
inference_stride, std::max(1, config.motion.stride_frames),
config.motion.flow_scale);
int frame_index = 0;
cv::Mat last_annotated;
std::vector<TrackObservation> last_tracks;
std::string stopped_reason = "eof";
bool user_quit = false;
bool verbose = config.performance.verbose;
int total_source_frames = art.total_source_frames;
int verbose_interval = std::max(1, inference_stride * 30);
ProgressBar* progress = nullptr;
if (show_progress) progress = new ProgressBar(total_source_frames);
// verbose timing accumulators
double cum_read = 0, cum_infer = 0, cum_motion = 0, cum_count = 0, cum_overlay = 0, cum_write = 0;
int timed_frames = 0;
auto t_loop_start = std::chrono::steady_clock::now();
try {
while (true) {
auto t0 = verbose ? std::chrono::steady_clock::now() : t_loop_start;
cv::Mat frame;
if (!art.capture.read(frame)) break;
++frame_index;
auto t_read = std::chrono::steady_clock::now();
if (frame_index % inference_stride == 0 || last_tracks.empty()) {
cv::Rect crop = art.has_detection_zone ? art.detection_zone_rect : cv::Rect{};
last_tracks = art.tracker->infer(frame, crop);
}
auto t_infer = std::chrono::steady_clock::now();
auto& tracks = last_tracks;
auto motion_state = art.motion_detector.update(frame, tracks, frame_index);
auto t_motion = std::chrono::steady_clock::now();
auto count_events = art.counting_zone.update(
tracks, frame_index, motion_state.backward_active);
auto t_count = std::chrono::steady_clock::now();
bool needs_overlay = config.display.show_window
|| art.has_writer
|| config.stream.enabled;
cv::Mat annotated;
if (needs_overlay) {
annotated = draw_overlay(frame, config, art.counting_zone,
tracks, motion_state, frame_index,
art.overlay_buffer.empty() ? nullptr : &art.overlay_buffer);
} else {
annotated = frame;
}
auto t_overlay = std::chrono::steady_clock::now();
FrameResult result;
result.frame_index = frame_index;
result.tracks = tracks;
result.inside_box_count = art.counting_zone.inside_box_count;
result.total_entered_count = art.counting_zone.total_entered_count;
result.motion_state = motion_state;
result.count_events = count_events;
// consume result
if (config.display.show_window) {
cv::imshow(config.display.window_name, annotated);
}
if (art.has_writer && art.writer.isOpened()) {
art.writer.write(annotated);
}
for (const auto& ev : count_events) {
if (config.performance.verbose) {
char buf[256];
snprintf(buf, sizeof(buf),
"[frame %d] counted track=%d inside_box=%d total_entered=%d",
ev.frame_index, ev.track_id,
result.inside_box_count, ev.total_entered_after_event);
if (progress) progress->emit(buf);
else std::fprintf(stderr, "%s\n", buf);
}
}
if (should_emit_feedback(config, frame_index)) {
emit_periodic_feedback(config, art, annotated, result, progress);
}
last_annotated = annotated;
if (config.stream.enabled
&& frame_index % std::max(1, config.stream.interval_frames) == 0) {
write_stream_frame(config.stream.shm_dir, config.camera_id,
annotated, result);
}
auto t_write = std::chrono::steady_clock::now();
if (verbose) {
auto to_ms = [](auto start, auto end) {
return std::chrono::duration<double, std::milli>(end - start).count();
};
if (frame_index % inference_stride == 0) {
cum_read += to_ms(t0, t_read);
cum_infer += to_ms(t_read, t_infer);
cum_motion += to_ms(t_infer, t_motion);
cum_count += to_ms(t_motion, t_count);
cum_overlay += to_ms(t_count, t_overlay);
cum_write += to_ms(t_overlay, t_write);
++timed_frames;
}
if (frame_index % verbose_interval == 0 && timed_frames > 0) {
double n = timed_frames;
std::fprintf(stderr,
"[debug ~%df avg ms] read=%.1f infer=%.1f motion=%.1f "
"count=%.1f overlay=%.1f write=%.1f tracks=%zu inside=%d total=%d "
"motion_speed=%.1f backward=%d\n",
verbose_interval,
cum_read / n, cum_infer / n, cum_motion / n,
cum_count / n, cum_overlay / n, cum_write / n,
tracks.size(),
art.counting_zone.inside_box_count,
art.counting_zone.total_entered_count,
motion_state.smoothed_speed,
motion_state.backward_active);
cum_read = cum_infer = cum_motion = cum_count = cum_overlay = cum_write = 0;
timed_frames = 0;
}
}
if (progress) {
auto elapsed = std::chrono::duration<double>(
std::chrono::steady_clock::now().time_since_epoch()).count()
- art.run_start_time;
double fps = frame_index / elapsed;
progress->render(frame_index, elapsed, fps,
art.counting_zone.inside_box_count,
art.counting_zone.total_entered_count,
motion_state.backward_active);
}
if (motion_state.backward_active) {
stopped_reason = "backward";
char buf[128];
snprintf(buf, sizeof(buf),
"[stop] backward detection confirmed at frame=%d; ending pipeline",
frame_index);
if (progress) progress->emit(buf);
else std::fprintf(stderr, "%s\n", buf);
break;
}
if (config.display.max_frames > 0 && frame_index >= config.display.max_frames) {
stopped_reason = "max_frames";
break;
}
if (config.display.show_window && (cv::waitKey(1) & 0xFF) == 'q') {
stopped_reason = "user_quit";
user_quit = true;
break;
}
}
// freeze frame
if (art.has_writer && art.writer.isOpened() && !last_annotated.empty()) {
int freeze_count = static_cast<int>(((config.display.write_fps > 0
? config.display.write_fps : 30.0f) * 2));
freeze_count = std::max(1, freeze_count);
for (int i = 0; i < freeze_count; ++i)
art.writer.write(last_annotated);
}
} catch (...) {
art.capture.release();
if (art.has_writer && art.writer.isOpened()) art.writer.release();
if (config.display.show_window) cv::destroyAllWindows();
if (progress) { progress->finish(); delete progress; }
throw;
}
art.capture.release();
if (art.has_writer && art.writer.isOpened()) art.writer.release();
if (config.display.show_window) cv::destroyAllWindows();
double elapsed_seconds = std::chrono::duration<double>(
std::chrono::steady_clock::now().time_since_epoch()).count()
- art.run_start_time;
if (user_quit) stopped_reason = "user_quit";
if (progress) { progress->finish(); delete progress; }
PipelineResult pr;
pr.camera_id = config.camera_id;
pr.total_entered_count = art.counting_zone.total_entered_count;
pr.frames_processed = frame_index;
pr.stopped_reason = stopped_reason;
pr.vis_video_path = config.display.output_path;
pr.source_video = config.source;
pr.elapsed_seconds = elapsed_seconds;
return pr;
}
} // namespace cc
-56
View File
@@ -1,56 +0,0 @@
#include <cassert>
#include <iostream>
#include "chicken_counter/config.hpp"
int main() {
std::cout << "=== test_config ===" << std::endl;
auto raw = cc::load_data(
"/media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/configs/cameras/example_camera.yaml");
// print detection device type for debugging
std::cout << "device type: " << raw["detection"]["device"].type_name()
<< " value: " << raw["detection"]["device"] << std::endl;
auto cam = raw.get<cc::CameraConfig>();
assert(cam.camera_id == "coop_cam_03");
assert(cam.detection.conf > 0.0f);
assert(cam.roi.points.size() >= 2);
assert(cam.roi.is_polygon());
auto cpoly = cam.roi.counting_polygon();
assert(cpoly.size() == 4);
auto crect = cam.roi.counting_rect();
assert(crect.width >= 20 && crect.height >= 20);
std::cout << " camera_config: " << cam.camera_id << " OK" << std::endl;
std::cout << " detection model: " << cam.detection.model_path << std::endl;
std::cout << " device: " << cam.detection.device << std::endl;
std::cout << " counting rect: "
<< crect.x << "," << crect.y << " "
<< crect.width << "x" << crect.height << std::endl;
auto batch = cc::load_batch_config(
"/media/jetson/DATA/.Codes/chicken-counting-sukawarna-det/configs/cycle7_batch.yaml");
assert(!batch.cameras.empty());
assert(batch.batch.compress_max_mb > 0);
std::cout << " batch_config: " << batch.cameras.size() << " cameras OK" << std::endl;
auto first_id = batch.cameras.begin()->first;
auto built = cc::build_camera_config_from_batch(
batch, first_id,
"/tmp/test.mp4",
"/tmp/output/test.mp4",
"/tmp/checkpoints/" + first_id);
assert(built.camera_id == first_id);
assert(built.source == "/tmp/test.mp4");
assert(built.display.output_path == "/tmp/output/test.mp4");
assert(!built.display.show_window);
assert(built.feedback.enabled);
std::cout << " build_from_batch: " << built.camera_id << " OK" << std::endl;
std::cout << "=== all tests passed ===" << std::endl;
return 0;
}
-136
View File
@@ -1,136 +0,0 @@
#include <cassert>
#include <iostream>
#include <opencv2/core.hpp>
#include "chicken_counter/capture.hpp"
#include "chicken_counter/video_writer.hpp"
#include "chicken_counter/counting.hpp"
#include "chicken_counter/motion.hpp"
#include "chicken_counter/overlay.hpp"
#include "chicken_counter/batch_discovery.hpp"
#include "chicken_counter/report.hpp"
#include "chicken_counter/compress.hpp"
int main() {
std::cout << "=== test_modules ===" << std::endl;
// test CountingZone
{
cc::RoiConfig roi;
roi.points = {{100, 200}, {1600, 200}, {1600, 800}, {100, 800}};
roi.min_overlap_ratio = 0.3f;
cc::GateConfig gate;
gate.mode = "two_line";
gate.lines_y = {320, 600};
cc::CountingZone zone(roi, gate, 20, 75, 500, false, false);
cc::TrackObservation t;
t.track_id = 1;
t.bbox_x1 = 200; t.bbox_y1 = 300;
t.bbox_x2 = 350; t.bbox_y2 = 500;
t.centroid_x = 275; t.centroid_y = 400;
t.confidence = 0.9f;
auto events = zone.update({t}, 100, false);
assert(zone.total_entered_count == 1);
assert(events.size() == 1);
assert(events[0].track_id == 1);
assert(zone.is_inside(1));
assert(zone.is_validated(1));
auto trail = zone.trail_for(1);
assert(trail.size() == 1);
std::cout << " CountingZone OK" << std::endl;
}
// test BackwardMotionDetector (disabled)
{
cc::MotionConfig mc;
mc.enabled = false;
cc::RoiConfig roi;
roi.points = {{0, 0}, {100, 0}, {100, 100}, {0, 100}};
cc::BackwardMotionDetector detector(mc, roi, false);
cv::Mat frame(100, 100, CV_8UC3, cv::Scalar(0, 0, 0));
std::vector<cc::TrackObservation> tracks;
auto state = detector.update(frame, tracks, 0);
assert(!state.backward_active);
std::cout << " BackwardMotionDetector OK" << std::endl;
}
// test overlay
{
cc::RoiConfig roi;
roi.points = {{50, 50}, {200, 50}, {200, 200}, {50, 200}};
cc::GateConfig gate;
cc::CountingZone zone(roi, gate, 20, 75);
cc::CameraConfig cfg;
cfg.camera_id = "test";
cfg.roi = roi;
cfg.overlay.trail_length = 20;
cv::Mat frame(300, 400, CV_8UC3, cv::Scalar(60, 60, 60));
cc::MotionState ms;
cc::TrackObservation t;
t.track_id = 99;
t.bbox_x1 = 100; t.bbox_y1 = 80;
t.bbox_x2 = 150; t.bbox_y2 = 130;
t.centroid_x = 125; t.centroid_y = 105;
t.confidence = 0.9f;
zone.update({t}, 1, false);
auto annotated = cc::draw_overlay(frame, cfg, zone, {t}, ms, 1);
assert(annotated.rows == 300 && annotated.cols == 400);
std::cout << " Overlay OK" << std::endl;
}
// test report
{
cc::PipelineResult pr;
pr.camera_id = "CC1";
pr.total_entered_count = 42;
pr.frames_processed = 1000;
pr.stopped_reason = "eof";
pr.source_video = "/tmp/CC1.mp4";
pr.elapsed_seconds = 10.5;
cc::CameraBatchResult cr;
cr.camera_id = "CC1";
cr.pipeline = pr;
auto entry = cc::build_camera_report_entry(cr, "/tmp/output");
assert(entry["total_entered"] == 42);
std::cout << " Report OK" << std::endl;
}
// test batch_discovery
{
cc::CameraPreset preset;
preset.camera_id = "CC1";
preset.camera_num = 1;
preset.roi.points = {{0, 0}, {100, 100}};
cc::BatchSettings settings;
settings.batch.root_dir = "/tmp/batch";
settings.batch.camera_glob = "kandang_*_camera_{num}_*.mp4";
settings.cameras["CC1"] = preset;
// test pattern replacement (not actual filesystem)
auto pattern = cc::replace_glob_placeholder(settings.batch.camera_glob, 1);
assert(pattern == "kandang_*_camera_1_*.mp4");
std::cout << " BatchDiscovery OK" << std::endl;
}
std::cout << "=== all tests passed ===" << std::endl;
return 0;
}
+151 -226
View File
@@ -1,260 +1,185 @@
#!/usr/bin/env python3
"""Standalone live dashboard for chicken-counter pipeline.
"""Live dashboard for chicken-counter pipeline using Flask.
Serve from project root:
PYTHONPATH=src python3 dashboard.py [--port 8080]
python3 dashboard.py [--port 8080] [--date 2026-06-10] [--db chicken_counts.db]
"""
from __future__ import annotations
import argparse
import json
import os
from http.server import HTTPServer, SimpleHTTPRequestHandler
import sqlite3
from pathlib import Path
from socketserver import ThreadingMixIn
from urllib.parse import unquote, urlparse
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
from flask import Flask, Response, jsonify, render_template, send_file
DEFAULT_SHM_DIR = "/dev/shm"
DEFAULT_PORT = 8080
DASHBOARD_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Chicken Counter — Live Dashboard</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:hidden}
#app{display:flex;height:100vh}
#sidebar{width:260px;background:#16161e;padding:16px;overflow-y:auto;flex-shrink:0}
#sidebar h1{font-size:18px;color:#80dc5a;margin-bottom:16px}
#sidebar .stat{margin-bottom:12px}
#sidebar .stat label{display:block;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:1px}
#sidebar .stat .value{font-size:22px;font-weight:700;color:#e0e0e0}
#sidebar .stat .value.warn{color:#ff9f43}
#sidebar .stat .value.good{color:#80dc5a}
#cam-list{list-style:none;margin-top:16px}
#cam-list li{padding:8px 10px;margin:2px 0;border-radius:6px;cursor:pointer;font-size:13px;transition:background .2s}
#cam-list li:hover{background:#222}
#cam-list li.active{background:#1a3a2a;color:#80dc5a;font-weight:700}
#cam-list li .cam-badge{float:right;font-size:10px;padding:1px 6px;border-radius:8px;background:#222;color:#888}
#cam-list li.active .cam-badge{background:#2a5a3a;color:#80dc5a}
#main{flex:1;display:flex;flex-direction:column}
#frame-container{flex:1;display:flex;align-items:center;justify-content:center;background:#000;position:relative}
#frame-img{max-width:100%;max-height:100%;object-fit:contain}
#no-frame{color:#555;font-size:18px}
#top-bar{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:#16161e;font-size:12px}
#top-bar .refresh{color:#888}
#top-bar .status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
#top-bar .status-dot.online{background:#80dc5a;box-shadow:0 0 6px #80dc5a}
#top-bar .status-dot.offline{background:#555}
.refresh-btn{padding:4px 12px;border-radius:4px;background:#222;border:1px solid #444;color:#ccc;cursor:pointer;font-size:11px}
.refresh-btn:hover{background:#333}
</style>
</head>
<body>
<div id="app">
<div id="sidebar">
<h1>&#x1f414; Chicken Counter</h1>
<div class="stat"><label>Total Entered</label><div class="value good" id="stat-total">--</div></div>
<div class="stat"><label>Inside Box</label><div class="value" id="stat-inside">--</div></div>
<div class="stat"><label>Tracks</label><div class="value" id="stat-tracks">--</div></div>
<div class="stat"><label>Frame</label><div class="value" id="stat-frame">--</div></div>
<div class="stat"><label>Motion Speed</label><div class="value" id="stat-speed">--</div></div>
<div class="stat"><label>Status</label><div class="value" id="stat-status">--</div></div>
<ul id="cam-list"></ul>
</div>
<div id="main">
<div id="top-bar">
<span><span class="status-dot" id="status-dot"></span><span id="status-text">waiting for pipeline...</span></span>
<span><span class="refresh" id="refresh-counter"></span> ago &nbsp;
<button class="refresh-btn" onclick="load()">&#x21bb; Refresh</button></span>
</div>
<div id="frame-container">
<img id="frame-img" alt="live stream">
<div id="no-frame"></div>
</div>
</div>
</div>
<script>
var POLL_MS = %%POLL_MS%%;
var SHM = "%%SHM_DIR%%";
var cameras = [];
var activeCam = null;
var lastUpdate = 0;
var img = document.getElementById("frame-img");
var noFrame = document.getElementById("no-frame");
function loadCameras() {{
fetch("/api/cameras").then(r => r.json()).then(data => {{
cameras = data.cameras || [];
renderCamList();
if (cameras.length && !activeCam) selectCam(cameras[0]);
if (!cameras.length) {{ noFrame.textContent = "No cameras found in " + SHM; img.style.display = "none"; }}
}});
}}
function renderCamList() {{
var ul = document.getElementById("cam-list");
ul.innerHTML = cameras.map(function(c) {{
return '<li class="' + (c === activeCam ? "active" : "") + '" onclick="selectCam(\'' + c + '\')">' +
c + '<span class="cam-badge">&#x25b6;</span></li>';
}}).join("");
}}
function selectCam(id) {{
activeCam = id;
renderCamList();
load();
}}
function load() {{
if (!activeCam) return;
var t = Date.now();
img.src = "/shm/" + activeCam + "/frame.jpg?t=" + t;
fetch("/shm/" + activeCam + "/stats.json?t=" + t).then(function(r) {{
if (!r.ok) {{ setOffline(); return; }}
return r.json();
}}).then(function(s) {{
if (!s) return;
lastUpdate = Date.now();
document.getElementById("stat-total").textContent = s.total_entered_count;
document.getElementById("stat-inside").textContent = s.inside_box_count;
document.getElementById("stat-tracks").textContent = s.track_count;
document.getElementById("stat-frame").textContent = s.frame_index;
document.getElementById("stat-speed").textContent = s.smoothed_speed;
document.getElementById("stat-status").textContent = s.backward_active ? "BACKWARD STOP" : "RUNNING";
var el = document.getElementById("stat-status");
el.className = "value" + (s.backward_active ? " warn" : " good");
document.getElementById("status-dot").className = "status-dot online";
document.getElementById("status-text").textContent = activeCam + " \u2022 frame " + s.frame_index;
}});
}}
function setOffline() {{
document.getElementById("status-dot").className = "status-dot offline";
document.getElementById("status-text").textContent = activeCam + " \u2022 offline";
}}
function updateRefresh() {{
var ago = Math.round((Date.now() - lastUpdate) / 1000);
document.getElementById("refresh-counter").textContent = ago + "s";
}}
img.onerror = function() {{ img.style.display = "none"; noFrame.style.display = "block"; noFrame.textContent = "Waiting for frame..."; }};
img.onload = function() {{ img.style.display = "block"; noFrame.style.display = "none"; }};
setInterval(function() {{ load(); }}, POLL_MS);
setInterval(loadCameras, 3000);
setInterval(updateRefresh, 1000);
loadCameras();
</script>
</body>
</html>"""
app = Flask(__name__)
app.config["shm_dir"] = DEFAULT_SHM_DIR
app.config["poll_ms"] = 500
app.config["run_date"] = ""
app.config["db_path"] = ""
class DashboardHandler(SimpleHTTPRequestHandler):
shm_dir = DEFAULT_SHM_DIR
poll_ms = 500
def log_message(self, format, *args):
pass
def do_GET(self):
try:
self._handle_request()
except (BrokenPipeError, ConnectionResetError):
pass
def _handle_request(self):
parsed = urlparse(self.path)
path = unquote(parsed.path)
if path == "/" or path == "/index.html":
html = DASHBOARD_HTML.replace("%%POLL_MS%%", str(self.poll_ms)).replace("%%SHM_DIR%%", self.shm_dir)
self._send_html(html)
return
if path == "/api/cameras":
cameras = self._discover_cameras()
self._send_json({"cameras": cameras})
return
if path.startswith("/shm/"):
rel = path[len("/shm/"):]
parts = rel.split("/", 1)
if len(parts) >= 1:
parts[0] = f"chicken_counter_{parts[0]}"
rel = "/".join(parts)
shm_path = Path(self.shm_dir) / rel
resolved = shm_path.resolve()
if not str(resolved).startswith(str(Path(self.shm_dir).resolve())):
self.send_error(403)
return
if not resolved.exists():
self.send_error(404)
return
ct = "image/jpeg" if resolved.suffix in (".jpg", ".jpeg") else "application/json"
self.send_response(200)
self.send_header("Content-Type", ct)
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
self.end_headers()
self.wfile.write(resolved.read_bytes())
return
self.send_error(404)
def _discover_cameras(self):
shm = Path(self.shm_dir)
def _discover_cameras():
shm = Path(app.config["shm_dir"])
cameras = []
if shm.is_dir():
for entry in sorted(shm.iterdir()):
if entry.is_dir() and entry.name.startswith("chicken_counter_"):
cam_id = entry.name[len("chicken_counter_"):]
cameras.append(cam_id)
cameras.append(entry.name[len("chicken_counter_"):])
return cameras
def _send_html(self, html: str):
data = html.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _send_json(self, obj):
data = json.dumps(obj).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
@app.route("/")
def index():
return render_template(
"index.html",
poll_ms=app.config["poll_ms"],
shm_dir=app.config["shm_dir"],
date=app.config["run_date"] or "today",
db_path=app.config["db_path"],
)
@app.route("/api/cameras")
def api_cameras():
return jsonify({"cameras": _discover_cameras()})
@app.route("/shm/<camera_id>/stats.json")
def shm_stats(camera_id):
stats_path = Path(app.config["shm_dir"]) / f"chicken_counter_{camera_id}" / "stats.json"
if not stats_path.exists():
return jsonify({"error": "not found"}), 404
return jsonify(json.loads(stats_path.read_text()))
@app.route("/shm/<camera_id>/frame.jpg")
def shm_frame(camera_id):
frame_path = Path(app.config["shm_dir"]) / f"chicken_counter_{camera_id}" / "frame.jpg"
if not frame_path.exists():
return jsonify({"error": "not found"}), 404
return send_file(frame_path, mimetype="image/jpeg", max_age=0, download_name=None)
def _get_db():
db = app.config["db_path"]
if not db or not Path(db).exists():
return None
conn = sqlite3.connect(db)
conn.row_factory = sqlite3.Row
return conn
@app.route("/api/db/summary")
def db_summary():
conn = _get_db()
if not conn:
return jsonify({})
row = conn.execute("""
SELECT COUNT(DISTINCT date) AS days,
COUNT(DISTINCT location) AS locations,
COUNT(*) AS total_runs,
SUM(total_entered) AS total_chickens,
ROUND(SUM(elapsed_seconds)/3600.0, 1) AS total_hours
FROM batch_runs
""").fetchone()
conn.close()
return jsonify(dict(row))
@app.route("/api/db/history")
def db_history():
conn = _get_db()
if not conn:
return jsonify([])
rows = conn.execute("""
SELECT date, location,
COUNT(*) AS cams,
SUM(total_entered) AS total,
ROUND(SUM(elapsed_seconds)/60.0, 1) AS minutes
FROM batch_runs
GROUP BY date, location
ORDER BY date DESC, location
LIMIT 50
""").fetchall()
conn.close()
return jsonify([dict(r) for r in rows])
@app.route("/api/db/date/<date>")
def db_date(date):
conn = _get_db()
if not conn:
return jsonify({})
cameras = conn.execute("""
SELECT camera_id, total_entered, frames_processed,
ROUND(elapsed_seconds,1) AS elapsed_seconds,
stopped_reason, source_video, location
FROM batch_runs WHERE date=? ORDER BY camera_id
""", (date,)).fetchall()
total = conn.execute(
"SELECT SUM(total_entered) AS total, ROUND(SUM(elapsed_seconds)/60.0,1) AS minutes "
"FROM batch_runs WHERE date=?", (date,)).fetchone()
conn.close()
return jsonify({"date": date, "total": dict(total), "cameras": [dict(r) for r in cameras]})
@app.route("/api/db/camera/<camera_id>")
def db_camera(camera_id):
conn = _get_db()
if not conn:
return jsonify([])
rows = conn.execute("""
SELECT date, location, total_entered, frames_processed,
ROUND(elapsed_seconds,1) AS elapsed_seconds, stopped_reason
FROM batch_runs WHERE camera_id=? ORDER BY date DESC LIMIT 50
""", (camera_id,)).fetchall()
conn.close()
return jsonify([dict(r) for r in rows])
@app.route("/api/db/location/<location>")
def db_location(location):
conn = _get_db()
if not conn:
return jsonify({})
history = conn.execute("""
SELECT date, GROUP_CONCAT(camera_id,', ') AS cameras,
SUM(total_entered) AS total,
ROUND(SUM(elapsed_seconds)/60.0,1) AS minutes
FROM batch_runs WHERE location=? GROUP BY date ORDER BY date DESC LIMIT 50
""", (location,)).fetchall()
summary = conn.execute("""
SELECT COUNT(DISTINCT date) AS days, SUM(total_entered) AS total,
ROUND(SUM(elapsed_seconds)/3600.0,1) AS hours
FROM batch_runs WHERE location=?
""", (location,)).fetchone()
conn.close()
return jsonify({"location": location, "summary": dict(summary), "history": [dict(r) for r in history]})
def main():
parser = argparse.ArgumentParser(description="Chicken Counter live dashboard")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"HTTP port (default: {DEFAULT_PORT})")
parser.add_argument("--shm-dir", default=DEFAULT_SHM_DIR, help=f"Shared memory directory (default: {DEFAULT_SHM_DIR})")
parser.add_argument("--poll-ms", type=int, default=500, help="Image poll interval in ms (default: 500)")
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
parser.add_argument("--shm-dir", default=DEFAULT_SHM_DIR)
parser.add_argument("--poll-ms", type=int, default=500)
parser.add_argument("--date", default="", help="Processing date")
parser.add_argument("--db", default="chicken_counts.db", help="SQLite database path")
parser.add_argument("--debug", action="store_true")
args = parser.parse_args()
DashboardHandler.shm_dir = args.shm_dir
DashboardHandler.poll_ms = args.poll_ms
app.config["shm_dir"] = args.shm_dir
app.config["poll_ms"] = args.poll_ms
app.config["run_date"] = args.date
app.config["db_path"] = str(Path(args.db).resolve()) if args.db else ""
server = ThreadingHTTPServer(("0.0.0.0", args.port), DashboardHandler)
print(f"[dashboard] serving at http://0.0.0.0:{args.port}")
print(f"[dashboard] shm_dir={args.shm_dir} poll={args.poll_ms}ms")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n[dashboard] stopped")
server.server_close()
print(f"[dashboard] http://0.0.0.0:{args.port} shm={args.shm_dir} db={args.db}" + (f" date={args.date}" if args.date else ""))
app.run(host="0.0.0.0", port=args.port, debug=args.debug, threaded=True)
if __name__ == "__main__":
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Export a YOLO .pt model to TensorRT .engine.
Usage:
python3 export_engine.py chicken-detection-model-v26n-300e-best-2026-05-02-NEW.pt
python3 export_engine.py model.pt --imgsz 640 --half --workspace 4
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
def export_engine(
model_path: str | Path,
*,
imgsz: int = 640,
half: bool = True,
int8: bool = False,
batch: int = 1,
workspace: int = 4, # GB
simplify: bool = True,
opset: int = 17,
verbose: bool = True,
) -> str:
from ultralytics import YOLO
model = YOLO(model_path, task="detect")
output = model.export(
format="engine",
imgsz=imgsz,
half=half,
int8=int8,
batch=batch,
workspace=workspace,
simplify=simplify,
opset=opset,
verbose=verbose,
)
print(f"\nExported to: {output}")
return str(output)
def main():
parser = argparse.ArgumentParser(description="Export YOLO .pt → TensorRT .engine")
parser.add_argument("model", help="Path to .pt model file")
parser.add_argument("--imgsz", type=int, default=640, help="Input image size (default: 640)")
parser.add_argument("--half", action="store_true", default=True, help="FP16 precision (default: on)")
parser.add_argument("--no-half", dest="half", action="store_false", help="FP32 precision")
parser.add_argument("--int8", action="store_true", help="INT8 quantization (needs calibration)")
parser.add_argument("--batch", type=int, default=1, help="Batch size (default: 1)")
parser.add_argument("--workspace", type=int, default=4, help="GPU workspace in GB (default: 4)")
parser.add_argument("--opset", type=int, default=17, help="ONNX opset version (default: 17)")
parser.add_argument("--quiet", action="store_true", help="Suppress verbose output")
args = parser.parse_args()
if not Path(args.model).exists():
print(f"error: model file not found: {args.model}", file=sys.stderr)
sys.exit(1)
export_engine(
args.model,
imgsz=args.imgsz,
half=args.half,
int8=args.int8,
batch=args.batch,
workspace=args.workspace,
opset=args.opset,
verbose=not args.quiet,
)
if __name__ == "__main__":
main()
+59 -16
View File
@@ -10,10 +10,62 @@ from chicken_counter.compress import compress_video_to_target
from chicken_counter.config import BatchSettings, build_camera_config_from_batch
from chicken_counter.pipeline import run_pipeline
from chicken_counter.report import build_batch_report, persist_batch_reports
from chicken_counter.tracking import DetectionTracker
from chicken_counter.types import CameraBatchResult
def _store_to_db(report_path: Path, location: str, db_path: str) -> None:
if not location or not db_path:
return
import json
import sqlite3
try:
with open(report_path) as f:
report = json.load(f)
except Exception:
return
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("""CREATE TABLE IF NOT EXISTS batch_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL, location TEXT NOT NULL, camera_id TEXT NOT NULL,
total_entered INTEGER NOT NULL DEFAULT 0,
frames_processed INTEGER NOT NULL DEFAULT 0,
elapsed_seconds REAL NOT NULL DEFAULT 0.0,
stopped_reason TEXT NOT NULL DEFAULT '',
source_video TEXT NOT NULL DEFAULT '',
generated_at TEXT NOT NULL DEFAULT '',
UNIQUE(date, location, camera_id))""")
date = report["date"]
for camera_id, entry in report["cameras"].items():
if entry.get("skipped"):
continue
conn.execute("""INSERT INTO batch_runs
(date, location, camera_id, total_entered, frames_processed,
elapsed_seconds, stopped_reason, source_video, generated_at)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT(date, location, camera_id) DO UPDATE SET
total_entered=excluded.total_entered,
frames_processed=excluded.frames_processed,
elapsed_seconds=excluded.elapsed_seconds,
stopped_reason=excluded.stopped_reason,
source_video=excluded.source_video,
generated_at=excluded.generated_at""",
(date, location, camera_id,
entry.get("total_entered", 0),
entry.get("frames_processed", 0),
entry.get("elapsed_seconds", 0),
entry.get("stopped_reason", ""),
entry.get("source_video", ""),
report.get("generated_at", "")))
conn.commit()
conn.close()
print(f"[db] stored {date} ({location}) → {db_path}")
def run_daily_batch(settings: BatchSettings, date: str | None = None, *, verbose: bool = False, no_video: bool = False, show_progress: bool = False) -> Path:
run_date = date or date_type.today().isoformat()
day_dir = Path(settings.batch.root_dir) / run_date
@@ -29,20 +81,6 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None, *, verbose
discovery = discover_camera_videos(day_dir, settings)
camera_order = sorted(settings.cameras.items(), key=lambda item: item[1].camera_num)
first_camera_id = next(
camera_id for camera_id, _preset in camera_order if camera_id in discovery.found
)
first_source = discovery.found[first_camera_id]
init_output_path = output_dir / f"{first_camera_id}_vis.mp4" if not no_video else None
init_config = build_camera_config_from_batch(
settings,
first_camera_id,
source=first_source,
output_path=init_output_path,
checkpoint_dir=output_dir / "checkpoints" / first_camera_id,
)
shared_tracker = DetectionTracker(init_config)
camera_results: list[CameraBatchResult] = []
report_path = output_dir / f"counts_{run_date}.json"
@@ -58,6 +96,7 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None, *, verbose
)
)
persist_batch_reports(run_date, camera_results, output_dir)
_store_to_db(report_path, settings.batch.location, settings.batch.db_path)
continue
source_path = discovery.found[camera_id]
@@ -73,7 +112,7 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None, *, verbose
checkpoint_dir=checkpoint_dir,
)
camera_config.performance.verbose = verbose
pipeline_result = run_pipeline(camera_config, tracker=shared_tracker, show_progress=show_progress)
pipeline_result = run_pipeline(camera_config, show_progress=show_progress, run_date=run_date)
camera_results.append(
CameraBatchResult(
camera_id=camera_id,
@@ -85,13 +124,16 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None, *, verbose
f"frames={pipeline_result.frames_processed} reason={pipeline_result.stopped_reason}"
)
persist_batch_reports(run_date, camera_results, output_dir)
_store_to_db(report_path, settings.batch.location, settings.batch.db_path)
if no_video:
persist_batch_reports(run_date, camera_results, output_dir)
report = build_batch_report(run_date, camera_results, output_dir=output_dir)
print(
f"[batch] complete for {run_date}: total_entered_sum={report.total_entered_sum} "
f"report={report_path}"
)
_store_to_db(report_path, settings.batch.location, settings.batch.db_path)
return report_path
print("[batch] all cameras complete; starting compression")
@@ -120,4 +162,5 @@ def run_daily_batch(settings: BatchSettings, date: str | None = None, *, verbose
f"[batch] complete for {run_date}: total_entered_sum={report.total_entered_sum} "
f"report={report_path}"
)
_store_to_db(report_path, settings.batch.location, settings.batch.db_path)
return report_path
-8
View File
@@ -145,7 +145,6 @@ class OverlayConfig:
show_track_ring: bool = False
count_anchor: Point = (900, 120)
inside_box_only: bool = True
validated_only: bool = False
pending_blink: bool = True
pending_colors: list[Color] = field(
default_factory=lambda: [(255, 255, 0), (0, 255, 255)]
@@ -223,7 +222,6 @@ class CameraPreset:
count_anchor: Point | None = None
gate: GateConfig | None = None
motion: MotionConfig | None = None
detection_overrides: dict[str, Any] | None = None
@dataclass
@@ -332,7 +330,6 @@ def load_batch_config(path: str | Path) -> BatchSettings:
gate = GateConfig(**camera_raw["gate"]) if "gate" in camera_raw else None
motion = MotionConfig(**camera_raw["motion"]) if "motion" in camera_raw else None
detection_overrides = camera_raw.get("detection", None)
cameras[camera_id] = CameraPreset(
camera_id=camera_id,
@@ -341,7 +338,6 @@ def load_batch_config(path: str | Path) -> BatchSettings:
count_anchor=count_anchor,
gate=gate,
motion=motion,
detection_overrides=detection_overrides,
)
return BatchSettings(batch=batch, defaults=defaults, cameras=cameras)
@@ -391,10 +387,6 @@ def build_camera_config_from_batch(
raw.setdefault("overlay", {})
raw["overlay"]["count_anchor"] = list(preset.count_anchor)
if preset.detection_overrides is not None:
raw.setdefault("detection", {})
raw["detection"].update(preset.detection_overrides)
raw.setdefault("display", {})
raw["display"]["output_path"] = str(output_path) if output_path is not None else None
raw["display"]["show_window"] = False
+20 -2
View File
@@ -51,23 +51,40 @@ class CountingZone:
counting_paused: bool = False,
) -> list[CountEvent]:
events: list[CountEvent] = []
if not tracks:
self.inside_box_count = 0
self.current_inside_ids.clear()
self.prev_inside_ids.clear()
self._purge_stale(frame_index, set())
return events
active_ids = set()
inside_ids = set()
rx1, ry1, rx2, ry2 = self._counting_rect
for track in tracks:
active_ids.add(track.track_id)
self.last_seen_frame[track.track_id] = frame_index
if self.trail_length > 0:
self.histories[track.track_id].append(track.centroid)
# fast-reject: bounding rect check before pointPolygonTest
cx, cy = track.centroid
if not (rx1 <= cx <= rx2 and ry1 <= cy <= ry2):
continue
if self._inside_roi(track.centroid):
inside_ids.add(track.track_id)
if counting_paused:
continue
if track.track_id not in inside_ids or track.track_id in self.counted_ids:
if track.track_id in self.counted_ids:
continue
should_validate = False
if self.validate_while_inside:
should_validate = self._meets_validation_thresholds(track)
else:
@@ -102,6 +119,7 @@ class CountingZone:
self.inside_box_count = len(inside_ids)
self.current_inside_ids = inside_ids
self.prev_inside_ids = inside_ids
if frame_index % 30 == 0:
self._purge_stale(frame_index, active_ids)
return events
+10 -7
View File
@@ -52,8 +52,6 @@ def draw_overlay(
continue
validated = counting_zone.is_validated(track.track_id)
if config.overlay.validated_only and not validated:
continue
x1, y1, x2, y2 = track.bbox_xyxy
cx, cy = track.centroid
sequence_number = counting_zone.sequence_number_for(track.track_id)
@@ -68,11 +66,16 @@ def draw_overlay(
cv2.rectangle(annotated, (x1, y1), (x2, y2), box_color, 2)
if validated and sequence_number is not None:
label = str(sequence_number)
(tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.8, 3)
ox, oy = x1, max(24, y1 - 8)
cv2.rectangle(annotated, (ox - 2, oy - th - 2), (ox + tw + 2, oy + 2), BLACK, -1)
cv2.putText(annotated, label, (ox, oy), cv2.FONT_HERSHEY_SIMPLEX, 0.8, LIME, 2, cv2.LINE_AA)
_draw_outlined_text(
annotated,
str(sequence_number),
(x1, max(24, y1 - 8)),
font_scale=0.8,
fill_color=LIME,
outline_color=BLACK,
thickness=2,
outline_thickness=4,
)
if config.overlay.show_center_marker:
marker_color = ORANGE if validated else (pending_colors[0 if blink_on else 1 % len(pending_colors)])
+14 -47
View File
@@ -6,10 +6,8 @@ import json
import shutil
import sys
import time
from dataclasses import dataclass, field
from dataclasses import dataclass
from pathlib import Path
from queue import Queue
from threading import Thread
import cv2
import numpy as np
@@ -80,34 +78,6 @@ class _ProgressBar:
sys.stderr.flush()
class _VideoWriterThread:
"""Background thread that encodes frames without blocking the main loop."""
def __init__(self, writer: cv2.VideoWriter, max_queue: int = 60) -> None:
self._writer = writer
self._queue: Queue = Queue(maxsize=max_queue)
self._thread = Thread(target=self._run, daemon=True)
self._thread.start()
def _run(self) -> None:
while True:
frame = self._queue.get()
if frame is None:
break
self._writer.write(frame)
def write(self, frame: np.ndarray) -> None:
self._queue.put(frame.copy())
def stop(self) -> None:
self._queue.put(None)
self._thread.join(timeout=5.0)
@property
def queue_size(self) -> int:
return self._queue.qsize()
@dataclass
class PipelineArtifacts:
capture: cv2.VideoCapture
@@ -120,12 +90,14 @@ class PipelineArtifacts:
total_source_frames: int | None
owns_tracker: bool
detection_zone_rect: tuple[int, int, int, int] | None = None
writer_thread: _VideoWriterThread | None = None
run_date: str = ""
def build_pipeline(
config: CameraConfig,
tracker: DetectionTracker | None = None,
*,
run_date: str = "",
) -> PipelineArtifacts:
capture = open_capture(config.source)
owns_tracker = tracker is None
@@ -160,7 +132,6 @@ def build_pipeline(
overlay_buffer = np.empty((height, width, 3), dtype=np.uint8)
writer = None
writer_thread = None
if config.display.output_path:
fps = config.display.write_fps or capture.get(cv2.CAP_PROP_FPS) or 30.0
writer = make_video_writer(
@@ -171,8 +142,6 @@ def build_pipeline(
output_bitrate_kbps=config.display.output_bitrate_kbps,
codec_preference=config.display.codec_preference,
)
writer_thread = _VideoWriterThread(writer)
print("[video] writer thread started")
if config.stream.enabled:
cam_dir = Path(config.stream.shm_dir) / f"chicken_counter_{config.camera_id}"
@@ -191,7 +160,7 @@ def build_pipeline(
total_source_frames=total_source_frames,
owns_tracker=owns_tracker,
detection_zone_rect=detection_zone_rect,
writer_thread=writer_thread,
run_date=run_date,
)
@@ -200,12 +169,13 @@ def run_pipeline(
tracker: DetectionTracker | None = None,
*,
show_progress: bool = False,
run_date: str = "",
) -> PipelineResult:
if tracker is not None:
tracker.config = config
tracker.reset_tracking()
artifacts = build_pipeline(config, tracker=tracker)
artifacts = build_pipeline(config, tracker=tracker, run_date=run_date)
inference_stride = max(1, config.performance.inference_stride)
print(
f"[perf] inference_stride={inference_stride} "
@@ -287,7 +257,7 @@ def run_pipeline(
last_annotated = annotated
if config.stream.enabled and frame_index % max(1, config.stream.interval_frames) == 0:
_write_stream_frame(config.stream.shm_dir, config.camera_id, annotated, result)
_write_stream_frame(config.stream.shm_dir, config.camera_id, annotated, result, run_date=artifacts.run_date)
if verbose:
t_write = time.monotonic()
@@ -345,16 +315,12 @@ def run_pipeline(
user_quit = True
break
if artifacts.writer_thread is not None and last_annotated is not None:
if artifacts.writer is not None and last_annotated is not None:
freeze_frame_count = int((config.display.write_fps or 30.0) * 2)
for _ in range(max(1, freeze_frame_count)):
artifacts.writer_thread.write(last_annotated)
artifacts.writer.write(last_annotated)
finally:
artifacts.capture.release()
if artifacts.writer_thread is not None:
print(f"[video] flushing {artifacts.writer_thread.queue_size} queued frames...")
artifacts.writer_thread.stop()
print("[video] writer thread stopped")
if artifacts.writer is not None:
artifacts.writer.release()
if config.display.show_window:
@@ -387,8 +353,8 @@ def _consume_result(
) -> None:
if config.display.show_window:
cv2.imshow(config.display.window_name, annotated)
if artifacts.writer_thread is not None:
artifacts.writer_thread.write(annotated)
if artifacts.writer is not None:
artifacts.writer.write(annotated)
for event in result.count_events:
if config.performance.verbose:
@@ -405,7 +371,7 @@ def _consume_result(
_emit_periodic_feedback(config, artifacts, annotated, result, progress)
def _write_stream_frame(shm_dir: str, camera_id: str, frame: np.ndarray, result: FrameResult) -> None:
def _write_stream_frame(shm_dir: str, camera_id: str, frame: np.ndarray, result: FrameResult, *, run_date: str = "") -> None:
cam_dir = Path(shm_dir) / f"chicken_counter_{camera_id}"
cam_dir.mkdir(parents=True, exist_ok=True)
@@ -422,6 +388,7 @@ def _write_stream_frame(shm_dir: str, camera_id: str, frame: np.ndarray, result:
"backward_active": result.motion_state.backward_active,
"smoothed_speed": round(result.motion_state.smoothed_speed, 1),
"count_events": len(result.count_events),
"run_date": run_date,
}
stats_path = cam_dir / "stats.json"
stats_tmp = cam_dir / ".stats_tmp.json"
+1 -1
View File
@@ -17,7 +17,7 @@ class DetectionTracker:
self.config = config
model_path = Path(config.detection.model_path)
self.model_kind = model_path.suffix.lower().lstrip(".") or "unknown"
self.model = YOLO(config.detection.model_path)
self.model = YOLO(config.detection.model_path, task="detect")
self.tracker_config_path = str(Path(config.tracker.tracker_config_path))
self.verbose = config.performance.verbose
self._infer_count = 0
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Store batch run results into a SQLite database.
Reads the aggregate JSON report written by the batch runner and inserts
all camera-level + summary data. Safe to run multiple times — uses
(date, location, camera_id) as the unique key, so re-runs update
existing rows instead of duplicating.
Usage:
python3 store_results.py /path/to/output/counts_2026-06-10.json --location kandang-atas
python3 store_results.py /path/to/output/counts_2026-06-10.json --db /var/lib/chickens.db
"""
from __future__ import annotations
import argparse
import json
import sqlite3
import sys
from pathlib import Path
CREATE_TABLE = """
CREATE TABLE IF NOT EXISTS batch_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
location TEXT NOT NULL,
camera_id TEXT NOT NULL,
total_entered INTEGER NOT NULL DEFAULT 0,
frames_processed INTEGER NOT NULL DEFAULT 0,
elapsed_seconds REAL NOT NULL DEFAULT 0.0,
stopped_reason TEXT NOT NULL DEFAULT '',
source_video TEXT NOT NULL DEFAULT '',
generated_at TEXT NOT NULL DEFAULT '',
UNIQUE(date, location, camera_id)
)
"""
INSERT_SQL = """
INSERT INTO batch_runs
(date, location, camera_id, total_entered, frames_processed,
elapsed_seconds, stopped_reason, source_video, generated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(date, location, camera_id) DO UPDATE SET
total_entered = excluded.total_entered,
frames_processed = excluded.frames_processed,
elapsed_seconds = excluded.elapsed_seconds,
stopped_reason = excluded.stopped_reason,
source_video = excluded.source_video,
generated_at = excluded.generated_at
"""
SUMMARY_QUERY = """
SELECT
date,
location,
COUNT(*) AS camera_count,
SUM(total_entered) AS total_chickens,
SUM(elapsed_seconds) AS total_seconds,
ROUND(SUM(elapsed_seconds) / 60.0, 1) AS total_minutes
FROM batch_runs
WHERE date = ? AND location = ?
GROUP BY date, location
"""
def store_report(report_path: str, location: str, db_path: str) -> None:
with open(report_path) as f:
report = json.load(f)
date = report["date"]
cameras = report["cameras"]
generated_at = report.get("generated_at", "")
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute(CREATE_TABLE)
rows = 0
for camera_id, entry in cameras.items():
if entry.get("skipped"):
continue
conn.execute(INSERT_SQL, (
date, location, camera_id,
entry.get("total_entered", 0),
entry.get("frames_processed", 0),
entry.get("elapsed_seconds", 0),
entry.get("stopped_reason", ""),
entry.get("source_video", ""),
generated_at,
))
rows += 1
conn.commit()
# print summary
row = conn.execute(SUMMARY_QUERY, (date, location)).fetchone()
if row:
print(f"\n[db] {row[0]} | {row[1]} | {row[2]} cameras | "
f"{row[3]} chickens | {row[4]:.0f}s ({row[5]} min)")
# also print per-camera breakdown
cur = conn.execute(
"SELECT camera_id, total_entered, elapsed_seconds "
"FROM batch_runs WHERE date=? AND location=? ORDER BY camera_id",
(date, location))
for cam_id, count, secs in cur:
print(f" {cam_id}: {count} chickens, {secs:.0f}s")
conn.close()
print(f"\n[db] wrote {rows} rows to {db_path}")
def main():
parser = argparse.ArgumentParser(
description="Store batch run results into SQLite")
parser.add_argument("report", help="Path to counts_YYYY-MM-DD.json")
parser.add_argument("--location", required=True, help="Location name (e.g. kandang-atas)")
parser.add_argument("--db", default="chicken_counts.db", help="SQLite database path")
args = parser.parse_args()
if not Path(args.report).exists():
print(f"error: report not found: {args.report}", file=sys.stderr)
sys.exit(1)
store_report(args.report, args.location, args.db)
if __name__ == "__main__":
main()
+181
View File
@@ -0,0 +1,181 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Chicken Counter — Live</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:hidden;height:100vh;display:flex;flex-direction:column}
#top-bar{display:flex;justify-content:space-between;align-items:center;padding:8px 16px;background:#16161e;font-size:12px;flex-shrink:0}
#top-bar h1{font-size:16px;color:#80dc5a}
#top-bar .dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin:0 6px}
#top-bar .dot.online{background:#80dc5a;box-shadow:0 0 4px #80dc5a}
#top-bar .dot.offline{background:#555}
#main{flex:1;display:flex}
#frame-area{flex:1;background:#000;display:flex;align-items:center;justify-content:center;position:relative}
#frame-area img{max-width:100%;max-height:100%;object-fit:contain}
#frame-area .overlay{position:absolute;top:12px;left:12px;pointer-events:none}
#frame-area .overlay .tag{display:inline-block;padding:4px 12px;border-radius:6px;font-size:12px;font-weight:700;margin-bottom:4px}
#frame-area .overlay .tag.live{background:#1a3a2a;color:#80dc5a}
#frame-area .overlay .tag.off{background:#333;color:#888}
#frame-area .overlay .count{font-size:36px;font-weight:900;color:#fff;text-shadow:0 0 16px rgba(0,0,0,.8)}
#sidebar{width:280px;background:#12121a;padding:16px;overflow-y:auto;flex-shrink:0;display:flex;flex-direction:column;gap:12px}
#sidebar .stat{padding:12px;background:#16161e;border-radius:8px}
#sidebar .stat label{display:block;font-size:9px;color:#555;text-transform:uppercase;letter-spacing:1px;margin-bottom:2px}
#sidebar .stat .val{font-size:22px;font-weight:700}
#sidebar .stat .val.good{color:#80dc5a}
#sidebar .stat .val.warn{color:#ff9f43}
#sidebar .cam-list{}
#sidebar .cam-list .cam-row{display:flex;justify-content:space-between;align-items:center;padding:6px 10px;margin:2px 0;border-radius:6px;font-size:12px;cursor:pointer;transition:background .15s}
#sidebar .cam-list .cam-row:hover{background:#1a1a24}
#sidebar .cam-list .cam-row.act{background:#1a3a2a;color:#80dc5a;font-weight:700}
#sidebar .cam-list .cam-row .cam-total{font-size:11px;color:#666}
#sidebar .cam-list .cam-row.act .cam-total{color:#5a9a4a}
#db-panel{padding:12px;background:#16161e;border-radius:8px;font-size:11px;max-height:200px;overflow-y:auto}
#db-panel h3{font-size:10px;color:#555;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px}
#db-panel .db-row{display:flex;justify-content:space-between;padding:2px 0;color:#888}
#db-panel .db-row .db-total{color:#aaa;font-weight:600}
</style>
</head>
<body>
<div id="top-bar">
<h1>&#x1f414; Chicken Counter</h1>
<span style="color:#888"><span id="stat-date">{{ date }}</span> &nbsp;|&nbsp; <span id="clock"></span></span>
</div>
<div id="main">
<div id="frame-area">
<img id="frame-img" src="" alt="live stream">
<div class="overlay">
<div class="tag live" id="cam-tag">CC1 &#x2022; RUNNING</div>
<div class="count" id="cam-count">--</div>
</div>
</div>
<div id="sidebar">
<div class="stat"><label>Total Entered</label><div class="val good" id="s-total">--</div></div>
<div class="stat"><label>Inside Box</label><div class="val" id="s-inside">--</div></div>
<div class="stat"><label>Tracks</label><div class="val" id="s-tracks">--</div></div>
<div class="stat"><label>Frame</label><div class="val" id="s-frame">--</div></div>
<div class="stat"><label>Motion</label><div class="val" id="s-speed">--</div></div>
<div class="cam-list" id="cam-list"></div>
<div class="db-panel" id="db-panel">
<h3>&#x1f4ca; History</h3>
</div>
</div>
</div>
<script>
var POLL_MS = {{ poll_ms }};
var SHM = "{{ shm_dir }}";
var DB_PATH = "{{ db_path }}";
var cameras = [], activeCam = null, lastUpdate = 0;
var perCam = {}, lastFramePerCam = {};
function boot() {
loadCameras();
setInterval(loadCameras, 3000);
setInterval(poll, POLL_MS);
setInterval(updateClock, 1000);
loadHistory();
setInterval(loadHistory, 30000);
updateClock();
}
function loadCameras() {
fetch("/api/cameras").then(r => r.json()).then(data => {
cameras = data.cameras || [];
renderCamList();
if (cameras.length && !activeCam) selectCam(cameras[0]);
detectActive();
});
}
function detectActive() {
var pending = cameras.length;
cameras.forEach(function(cam) {
fetch("/shm/" + cam + "/stats.json?t=" + Date.now()).then(r => r.ok ? r.json() : null).then(s => {
pending--;
if (!s) return;
var prev = lastFramePerCam[cam] || 0;
perCam[cam] = { total: s.total_entered_count, inside: s.inside_box_count, frame: s.frame_index, tracks: s.track_count, speed: s.smoothed_speed };
if (s.frame_index > prev) {
lastFramePerCam[cam] = s.frame_index;
if (activeCam !== cam) selectCam(cam);
}
}).finally(function() { if (pending === 0) renderCamList(); });
});
}
function selectCam(id) {
activeCam = id;
renderCamList();
refreshNow();
}
function poll() {
if (!activeCam) return;
var t = Date.now();
var img = document.getElementById("frame-img");
img.src = "/shm/" + activeCam + "/frame.jpg?t=" + t;
fetch("/shm/" + activeCam + "/stats.json?t=" + t).then(r => r.ok ? r.json() : null).then(s => {
if (!s) { setOffline(); return; }
lastUpdate = Date.now();
lastFramePerCam[activeCam] = s.frame_index;
perCam[activeCam] = { total: s.total_entered_count, inside: s.inside_box_count, frame: s.frame_index, tracks: s.track_count, speed: s.smoothed_speed };
document.getElementById("s-total").textContent = s.total_entered_count;
document.getElementById("s-inside").textContent = s.inside_box_count;
document.getElementById("s-tracks").textContent = s.track_count;
document.getElementById("s-frame").textContent = s.frame_index;
document.getElementById("s-speed").textContent = s.smoothed_speed.toFixed(1);
var tag = document.getElementById("cam-tag");
var status = s.backward_active ? "BACKWARD STOP" : "RUNNING";
tag.textContent = activeCam + " \u2022 " + status;
tag.className = "tag " + (s.backward_active ? "off" : "live");
document.getElementById("cam-count").textContent = s.total_entered_count;
});
}
function refreshNow() {
poll();
// also update the frame immediately
var t = Date.now();
document.getElementById("frame-img").src = "/shm/" + activeCam + "/frame.jpg?t=" + t;
}
function setOffline() {
document.getElementById("cam-tag").textContent = activeCam + " \u2022 OFFLINE";
document.getElementById("cam-tag").className = "tag off";
}
function renderCamList() {
var ul = document.getElementById("cam-list");
ul.innerHTML = cameras.map(function(c) {
var info = perCam[c] || {};
var cls = c === activeCam ? " act" : "";
return '<div class="cam-row' + cls + '" onclick="selectCam(\'' + c + '\')">' +
'<span>' + c + '</span>' +
'<span class="cam-total">' + (info.total || 0) + '</span></div>';
}).join("");
}
function updateClock() {
document.getElementById("clock").textContent = new Date().toLocaleTimeString();
}
function loadHistory() {
if (!DB_PATH) return;
fetch("/api/db/history").then(r => r.json()).then(rows => {
var panel = document.getElementById("db-panel");
if (!rows.length) { panel.style.display = "none"; return; }
panel.style.display = "block";
panel.innerHTML = "<h3>&#x1f4ca; History</h3>" + rows.map(function(r) {
return '<div class="db-row"><span>' + r.date + ' ' + r.location + '</span>' +
'<span class="db-total">' + r.total.toLocaleString() + '</span></div>';
}).join("");
});
}
setTimeout(function() { if (activeCam) refreshNow(); }, 500);
boot();
</script>
</body>
</html>