diff --git a/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine.cache b/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine.cache new file mode 100644 index 0000000..8f08090 Binary files /dev/null and b/chicken-detection-model-v26n-300e-best-2026-05-02-NEW.engine.cache differ diff --git a/configs/cycle7_batch.yaml b/configs/cycle7_batch.yaml index d50284f..9a7acbc 100755 --- a/configs/cycle7_batch.yaml +++ b/configs/cycle7_batch.yaml @@ -11,7 +11,7 @@ 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.35 + conf: 0.45 iou: 0.55 imgsz: 640 device: "0" @@ -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: 75 + track_buffer: 45 gate: mode: two_line lines_y: [420, 730] @@ -40,7 +40,7 @@ defaults: debounce_frames: 12 min_features: 60 max_corners: 80 - stride_frames: 2 + stride_frames: 3 flow_scale: 0.5 quality_level: 0.01 min_distance: 8 @@ -49,7 +49,7 @@ defaults: show_boxes: true show_track_trails: false trail_length: 20 - show_center_marker: true + show_center_marker: false show_track_ring: false count_anchor: [780, 120] inside_box_only: true @@ -60,14 +60,14 @@ defaults: display: show_window: false encoder: auto - output_bitrate_kbps: 4000 + output_bitrate_kbps: 2000 codec_preference: [avc1, mp4v, H264] performance: half: false overlay_buffer_reuse: true inference_stride: 2 stream: - enabled: true + enabled: false shm_dir: /dev/shm interval_frames: 5 feedback: diff --git a/configs/trackers/botsort_chicken.yaml b/configs/trackers/botsort_chicken.yaml index d0beb51..14bb22d 100755 --- a/configs/trackers/botsort_chicken.yaml +++ b/configs/trackers/botsort_chicken.yaml @@ -1,9 +1,9 @@ tracker_type: botsort -track_high_thresh: 0.5 +track_high_thresh: 0.55 track_low_thresh: 0.1 -new_track_thresh: 0.6 +new_track_thresh: 0.7 track_buffer: 75 -match_thresh: 0.8 +match_thresh: 0.9 fuse_score: true gmc_method: none proximity_thresh: 0.5 diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt new file mode 100644 index 0000000..af8e8ba --- /dev/null +++ b/cpp/CMakeLists.txt @@ -0,0 +1,57 @@ +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) + +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}) + +add_executable(chicken_counter_cli + src/main.cpp +) +target_link_libraries(chicken_counter_cli PRIVATE chicken_counter_lib) + +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) diff --git a/cpp/include/chicken_counter/batch_discovery.hpp b/cpp/include/chicken_counter/batch_discovery.hpp new file mode 100644 index 0000000..f227d3f --- /dev/null +++ b/cpp/include/chicken_counter/batch_discovery.hpp @@ -0,0 +1,102 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "chicken_counter/config.hpp" + +namespace cc { + +struct CameraDiscoveryResult { + std::unordered_map found; + std::unordered_map 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 glob_filenames(const std::string& dir, const std::string& pattern) { + namespace fs = std::filesystem; + std::vector 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(settings.cameras.size()); + + using pair_t = std::pair; + std::vector 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(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 diff --git a/cpp/include/chicken_counter/batch_runner.hpp b/cpp/include/chicken_counter/batch_runner.hpp new file mode 100644 index 0000000..9d036c3 --- /dev/null +++ b/cpp/include/chicken_counter/batch_runner.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include + +#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 diff --git a/cpp/include/chicken_counter/capture.hpp b/cpp/include/chicken_counter/capture.hpp new file mode 100644 index 0000000..c4d639d --- /dev/null +++ b/cpp/include/chicken_counter/capture.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +#include + +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 diff --git a/cpp/include/chicken_counter/compress.hpp b/cpp/include/chicken_counter/compress.hpp new file mode 100644 index 0000000..7f1b209 --- /dev/null +++ b/cpp/include/chicken_counter/compress.hpp @@ -0,0 +1,106 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +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(std::filesystem::file_size(path)) / (1024.0 * 1024.0); +} + +inline void run_ffmpeg(const std::vector& 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((max_mb * 8192) / duration * 0.92)); + + for (int attempt = 0; attempt < max_attempts; ++attempt) { + int attempt_kbps = std::max(300, + static_cast(target_kbps * std::pow(0.85, attempt))); + + if (fs::exists(output_path)) fs::remove(output_path); + + std::vector> 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 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 diff --git a/cpp/include/chicken_counter/config.hpp b/cpp/include/chicken_counter/config.hpp new file mode 100644 index 0000000..5316a1b --- /dev/null +++ b/cpp/include/chicken_counter/config.hpp @@ -0,0 +1,605 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#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(); + p.y = j.at(1).get(); +} +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(), j.at(1).get(), j.at(2).get()); +} +} // 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(); + try { + double dval = node.as(); + int ival = static_cast(dval); + if (dval == static_cast(ival)) return ival; + return dval; + } catch (const YAML::BadConversion&) { + std::string val = node.as(); + 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()] = 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 classes = {0}; + std::vector 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{0}); + c.ignored_classes = j.value("ignored_classes", std::vector{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 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 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>(); + 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 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{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 pending_blink = true; + std::vector 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}, {"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.pending_blink = j.value("pending_blink", true); + c.pending_colors = j.value("pending_colors", + std::vector{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 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); + c.output_path = j.value("output_path", ""); + 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{"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}; // (-1,-1) = not set + GateConfig gate; + MotionConfig motion; + bool has_gate = false; + bool has_motion = false; +}; + +struct BatchSettings { + BatchConfig batch; + nlohmann::json defaults = nlohmann::json::object(); + std::unordered_map 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(); +} + +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(); + 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(); + preset.roi.points = cam["roi"]["points"].get>(); + + if (cam.contains("count_anchor")) { + preset.count_anchor = cam["count_anchor"].get(); + } else if (cam.contains("overlay") && cam["overlay"].contains("count_anchor")) { + preset.count_anchor = cam["overlay"]["count_anchor"].get(); + } + + if (cam.contains("gate")) { + preset.gate = cam["gate"].get(); + preset.has_gate = true; + } + if (cam.contains("motion")) { + preset.motion = cam["motion"].get(); + preset.has_motion = true; + } + + 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 (!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(); + raw["display"]["output_path"] = output_path.empty() ? nlohmann::json(nullptr) : nlohmann::json(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(); +} + +} // namespace cc diff --git a/cpp/include/chicken_counter/counting.hpp b/cpp/include/chicken_counter/counting.hpp new file mode 100644 index 0000000..8c44891 --- /dev/null +++ b/cpp/include/chicken_counter/counting.hpp @@ -0,0 +1,199 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +#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 update( + const std::vector& tracks, + int frame_index, + bool counting_paused = false) + { + std::vector events; + std::unordered_set 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(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(inside_ids.size()); + current_inside_ids = inside_ids; + prev_inside_ids = inside_ids; + purge_stale(frame_index, active_ids); + return events; + } + + std::vector 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 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 counting_polygon; + cv::Rect counting_rect; + std::unordered_map> histories; + std::unordered_map last_seen_frame; + std::unordered_set counted_ids; + std::unordered_set prev_inside_ids; + std::unordered_set current_inside_ids; + std::unordered_map 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& active_ids) { + std::vector 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 diff --git a/cpp/include/chicken_counter/motion.hpp b/cpp/include/chicken_counter/motion.hpp new file mode 100644 index 0000000..5eecee3 --- /dev/null +++ b/cpp/include/chicken_counter/motion.hpp @@ -0,0 +1,146 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +#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& 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(gray_roi.cols * scale)); + int th = std::max(1, static_cast(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((std::max(0, track.bbox_x1 - r) - roi_bounds.x) * scale); + int ly1 = static_cast((std::max(0, track.bbox_y1 - r) - roi_bounds.y) * scale); + int lx2 = static_cast((std::min(roi_bounds.x + roi_bounds.width, track.bbox_x2 + r) - roi_bounds.x) * scale); + int ly2 = static_cast((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 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(points.size()) < config.min_features) { + previous_gray = gray_roi.clone(); + return state; + } + + std::vector next_points; + std::vector status; + std::vector 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( + 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 diff --git a/cpp/include/chicken_counter/overlay.hpp b/cpp/include/chicken_counter/overlay.hpp new file mode 100644 index 0000000..cdc8eeb --- /dev/null +++ b/cpp/include/chicken_counter/overlay.hpp @@ -0,0 +1,161 @@ +#pragma once + +#include +#include + +#include +#include + +#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& 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 pts = config.roi.counting_polygon(); + std::vector> 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& 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{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); + 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(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(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 diff --git a/cpp/include/chicken_counter/pipeline.hpp b/cpp/include/chicken_counter/pipeline.hpp new file mode 100644 index 0000000..4ec4d26 --- /dev/null +++ b/cpp/include/chicken_counter/pipeline.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +#include +#include + +#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 diff --git a/cpp/include/chicken_counter/report.hpp b/cpp/include/chicken_counter/report.hpp new file mode 100644 index 0000000..bd21858 --- /dev/null +++ b/cpp/include/chicken_counter/report.hpp @@ -0,0 +1,139 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#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& 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& 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 diff --git a/cpp/include/chicken_counter/tracking.hpp b/cpp/include/chicken_counter/tracking.hpp new file mode 100644 index 0000000..057bb35 --- /dev/null +++ b/cpp/include/chicken_counter/tracking.hpp @@ -0,0 +1,420 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +#include "chicken_counter/config.hpp" +#include "chicken_counter/types.hpp" + +namespace cc { + +template struct TRTDeleter { void operator()(T* p) const { delete p; } }; +template using TRT_ptr = std::unique_ptr>; + +struct CudaStreamDeleter { void operator()(cudaStream_t* s) const { cudaStreamDestroy(*s); delete s; } }; +using Cuda_stream_ptr = std::unique_ptr; + +struct CudaHostDeleter { template void operator()(T* p) const { cudaFreeHost(p); } }; +template using Cuda_host_ptr = std::unique_ptr; + +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 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::createInferBuilder(logger)); + auto network = TRT_ptr( + builder->createNetworkV2(1U << static_cast( + nvinfer1::NetworkDefinitionCreationFlag::kEXPLICIT_BATCH))); + auto parser = TRT_ptr(nvonnxparser::createParser(*network, logger)); + if (!parser->parseFromFile(onnx_path.c_str(), + static_cast(nvinfer1::ILogger::Severity::kWARNING))) + throw std::runtime_error("ONNX parse failed"); + + auto config = TRT_ptr(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( + 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(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; + int output_num_classes = 1, output_num_cells = 8400; + bool output_layout_packed = true; // true = [1,N,8400], false = [1,8400,N] + +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 runtime; + TRT_ptr engine; + TRT_ptr context; + std::unordered_map 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; + // cache output layout once + int d1 = (shape.nbDims >= 2) ? shape.d[1] : 1; + int d2 = (shape.nbDims >= 3) ? shape.d[2] : 1; + if (d2 > d1) { + output_layout_packed = true; // [1, N, cells] + output_num_classes = std::max(1, d1 - 4); + output_num_cells = d2; + } else { + output_layout_packed = false; // [1, cells, N] + output_num_classes = std::max(1, d2 - 4); + output_num_cells = d1; + } + } + } + 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) cls=" << output_num_classes << " cells=" << output_num_cells << "\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(onnx, path + ".cache"); + } else { + throw std::runtime_error("Unsupported model format: " + kind); + } + + // alloc pinned output buffer (reused every inference) + int out_floats = static_cast(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 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(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 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_(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(0) = b.x + b.width/2; + kf.statePost.at(1) = b.y + b.height/2; + kf.statePost.at(2) = b.area(); + kf.statePost.at(3) = b.width / b.height; + } + cv::Rect2f predict() { + cv::Mat p = kf.predict(); + float w = std::sqrt(std::max(1.0f, p.at(2) * p.at(3))); + float h = std::max(1.0f, p.at(2) / w); + bbox = cv::Rect2f(p.at(0) - w/2, p.at(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_(4, 1) << cx, cy, b.area(), b.width / b.height)); + bbox = b; hits++; time_since_update = 0; + } + }; + std::vector 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(imgsz) / std::max(w, h); + int nw = static_cast(w * scale), nh = static_cast(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 (uses cache layout) --- + std::vector decode(float scale, int pad_x, int pad_y, int ow, int oh) { + std::vector iboxes; iboxes.reserve(32); + std::vector scores; scores.reserve(32); + + int nc = trt->output_num_classes; + int cells = trt->output_num_cells; + int stride = nc + 4; // total channels per cell + const float* d = output_buf; + + if (trt->output_layout_packed) { + // layout [1, N, cells] — each channel is contiguous stride apart + for (int i = 0; i < cells; ++i) { + float maxc = 0; + for (int c = 0; c < nc; ++c) { + float v = d[(4 + c) * cells + i]; + if (v > maxc) maxc = v; + } + if (maxc < conf_thresh) continue; + float cx = d[i], cy = d[cells + i], w = d[2*cells + i], h = d[3*cells + i]; + float x = (cx - pad_x) / scale, y = (cy - pad_y) / scale; + float bw = w / scale, bh = h / scale; + int x1 = std::max(0, std::min(static_cast(x - bw/2), ow)); + int y1 = std::max(0, std::min(static_cast(y - bh/2), oh)); + int x2 = std::max(0, std::min(static_cast(x + bw/2), ow)); + int y2 = std::max(0, std::min(static_cast(y + bh/2), oh)); + if (x2 > x1 && y2 > y1) { iboxes.push_back({x1, y1, x2 - x1, y2 - y1}); scores.push_back(maxc); } + } + } else { + // layout [1, cells, N] — contiguous per row + for (int i = 0; i < cells; ++i) { + const float* row = d + i * stride; + float maxc = 0; + for (int c = 0; c < nc; ++c) { float v = row[4 + c]; if (v > maxc) maxc = v; } + if (maxc < conf_thresh) continue; + float cx = row[0], cy = row[1], w = row[2], h = row[3]; + float x = (cx - pad_x) / scale, y = (cy - pad_y) / scale; + int x1 = std::max(0, std::min(static_cast(x - w/scale/2), ow)); + int y1 = std::max(0, std::min(static_cast(y - h/scale/2), oh)); + int x2 = std::max(0, std::min(static_cast(x + w/scale/2), ow)); + int y2 = std::max(0, std::min(static_cast(y + h/scale/2), oh)); + if (x2 > x1 && y2 > y1) { iboxes.push_back({x1, y1, x2 - x1, y2 - y1}); scores.push_back(maxc); } + } + } + + std::vector idx; cv::dnn::NMSBoxes(iboxes, scores, conf_thresh, iou_thresh, idx); + std::vector out; out.reserve(idx.size()); + for (int i : idx) out.push_back(iboxes[i]); + return out; + } + + // --- SORT association --- + std::vector associate(const std::vector& dets, int ox, int oy) { + for (auto& t : active_tracks) { t.predict(); t.time_since_update++; } + int nd = static_cast(dets.size()), nt = static_cast(active_tracks.size()); + if (nd == 0) goto cleanup; + + { + std::vector> iou(nt, std::vector(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 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 used(nd, false); std::vector 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 out; + for (const auto& t : active_tracks) { + if (t.hits < 3) continue; + auto& b = t.bbox; + int x1 = static_cast(b.x) + ox, y1 = static_cast(b.y) + oy; + int x2 = static_cast(b.x + b.width) + ox, y2 = static_cast(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 diff --git a/cpp/include/chicken_counter/types.hpp b/cpp/include/chicken_counter/types.hpp new file mode 100644 index 0000000..1559046 --- /dev/null +++ b/cpp/include/chicken_counter/types.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include +#include + +#include + +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 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 tracks; + int inside_box_count = 0; + int total_entered_count = 0; + std::optional latest_validated_track_id; + MotionState motion_state; + std::vector 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 diff --git a/cpp/include/chicken_counter/video_writer.hpp b/cpp/include/chicken_counter/video_writer.hpp new file mode 100644 index 0000000..7a20560 --- /dev/null +++ b/cpp/include/chicken_counter/video_writer.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +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& 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{"avc1", "mp4v", "H264"} + : codec_preference; + + if (encoder == "auto" || encoder == "gstreamer") { + int fps_int = std::max(1, static_cast(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 diff --git a/cpp/src/batch_runner.cpp b/cpp/src/batch_runner.cpp new file mode 100644 index 0000000..96c8b5a --- /dev/null +++ b/cpp/src/batch_runner.cpp @@ -0,0 +1,140 @@ +#include "chicken_counter/batch_runner.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#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::vector 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 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 diff --git a/cpp/src/dashboard.cpp b/cpp/src/dashboard.cpp new file mode 100644 index 0000000..35a53ef --- /dev/null +++ b/cpp/src/dashboard.cpp @@ -0,0 +1,284 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +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"~( + + + + +Chicken Counter - Live Dashboard + + + +
+ +
+
+waiting for pipeline... + ago   + +
+
+live stream +
+
+
+
+ + +)~"; + +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(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; +} diff --git a/cpp/src/main.cpp b/cpp/src/main.cpp new file mode 100644 index 0000000..a2d61a4 --- /dev/null +++ b/cpp/src/main.cpp @@ -0,0 +1,59 @@ +#include +#include +#include + +#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; +} diff --git a/cpp/src/pipeline.cpp b/cpp/src/pipeline.cpp new file mode 100644 index 0000000..5305dbf --- /dev/null +++ b/cpp/src/pipeline.cpp @@ -0,0 +1,480 @@ +#include "chicken_counter/pipeline.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#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(seconds)) + "s"; + int total = static_cast(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( + 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(line.size())); + _last_line_len = static_cast(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(art.capture.get(cv::CAP_PROP_FRAME_WIDTH)); + int height = static_cast(art.capture.get(cv::CAP_PROP_FRAME_HEIGHT)); + int fc = static_cast(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(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( + 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( + 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 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 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(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( + 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(((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( + 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 diff --git a/cpp/tests/test_config.cpp b/cpp/tests/test_config.cpp new file mode 100644 index 0000000..5a30c06 --- /dev/null +++ b/cpp/tests/test_config.cpp @@ -0,0 +1,56 @@ +#include +#include + +#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(); + + 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; +} diff --git a/cpp/tests/test_modules.cpp b/cpp/tests/test_modules.cpp new file mode 100644 index 0000000..e8de983 --- /dev/null +++ b/cpp/tests/test_modules.cpp @@ -0,0 +1,136 @@ +#include +#include + +#include + +#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 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; +} diff --git a/src/chicken_counter/overlay.py b/src/chicken_counter/overlay.py index f40bd2f..2e4998d 100755 --- a/src/chicken_counter/overlay.py +++ b/src/chicken_counter/overlay.py @@ -66,16 +66,11 @@ def draw_overlay( cv2.rectangle(annotated, (x1, y1), (x2, y2), box_color, 2) if validated and sequence_number is not None: - _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, - ) + 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) if config.overlay.show_center_marker: marker_color = ORANGE if validated else (pending_colors[0 if blink_on else 1 % len(pending_colors)]) diff --git a/src/chicken_counter/pipeline.py b/src/chicken_counter/pipeline.py index 86ecb07..7c97de9 100755 --- a/src/chicken_counter/pipeline.py +++ b/src/chicken_counter/pipeline.py @@ -6,8 +6,10 @@ import json import shutil import sys import time -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path +from queue import Queue +from threading import Thread import cv2 import numpy as np @@ -78,6 +80,34 @@ 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 @@ -90,6 +120,7 @@ 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 def build_pipeline( @@ -129,6 +160,7 @@ 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( @@ -139,6 +171,8 @@ 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}" @@ -157,6 +191,7 @@ def build_pipeline( total_source_frames=total_source_frames, owns_tracker=owns_tracker, detection_zone_rect=detection_zone_rect, + writer_thread=writer_thread, ) @@ -310,12 +345,16 @@ def run_pipeline( user_quit = True break - if artifacts.writer is not None and last_annotated is not None: + if artifacts.writer_thread 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.write(last_annotated) + artifacts.writer_thread.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: @@ -348,8 +387,8 @@ def _consume_result( ) -> None: if config.display.show_window: cv2.imshow(config.display.window_name, annotated) - if artifacts.writer is not None: - artifacts.writer.write(annotated) + if artifacts.writer_thread is not None: + artifacts.writer_thread.write(annotated) for event in result.count_events: if config.performance.verbose: