From 20124479d14cf3301a2e4462d1055ae7075de016 Mon Sep 17 00:00:00 2001 From: Ozan Durgut Date: Sat, 5 Sep 2026 14:01:21 +0200 Subject: [PATCH 1/9] cv: add classical-feature foundation Signed-off-by: Ozan Durgut --- CMakeLists.txt | 35 +++++ cv/detect.c | 161 ++++++++++++++++++++ cv/detect.h | 82 +++++++++++ cv/haar.c | 117 +++++++++++++++ cv/haar.h | 116 +++++++++++++++ cv/hog.c | 215 +++++++++++++++++++++++++++ cv/hog.h | 62 ++++++++ cv/image_gray.c | 48 ++++++ cv/image_gray.h | 40 +++++ cv/integral.c | 146 ++++++++++++++++++ cv/integral.h | 50 +++++++ cv/linear_classifier.c | 68 +++++++++ cv/linear_classifier.h | 60 ++++++++ cv/nn.c | 237 ++++++++++++++++++++++++++++++ cv/nn.h | 95 ++++++++++++ embedDIP.h | 7 + embedDIP.hpp | 1 + tests/CMakeLists.txt | 32 ++++ tests/test_cv_cpp_wrapper.cpp | 58 ++++++++ tests/test_cv_detect.c | 120 +++++++++++++++ tests/test_cv_haar.c | 105 +++++++++++++ tests/test_cv_hog.c | 124 ++++++++++++++++ tests/test_cv_image_gray.c | 59 ++++++++ tests/test_cv_integral.c | 108 ++++++++++++++ tests/test_cv_linear_classifier.c | 73 +++++++++ tests/test_cv_nn.c | 137 +++++++++++++++++ wrapper/CvFeatureWrapper.hpp | 103 +++++++++++++ wrapper/ImageWrapper.hpp | 11 ++ 28 files changed, 2470 insertions(+) create mode 100644 cv/detect.c create mode 100644 cv/detect.h create mode 100644 cv/haar.c create mode 100644 cv/haar.h create mode 100644 cv/hog.c create mode 100644 cv/hog.h create mode 100644 cv/image_gray.c create mode 100644 cv/image_gray.h create mode 100644 cv/integral.c create mode 100644 cv/integral.h create mode 100644 cv/linear_classifier.c create mode 100644 cv/linear_classifier.h create mode 100644 cv/nn.c create mode 100644 cv/nn.h create mode 100644 tests/test_cv_cpp_wrapper.cpp create mode 100644 tests/test_cv_detect.c create mode 100644 tests/test_cv_haar.c create mode 100644 tests/test_cv_hog.c create mode 100644 tests/test_cv_image_gray.c create mode 100644 tests/test_cv_integral.c create mode 100644 tests/test_cv_linear_classifier.c create mode 100644 tests/test_cv_nn.c create mode 100644 wrapper/CvFeatureWrapper.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1803ac4..1fcad91 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -88,6 +88,37 @@ set(CORE_SOURCES core/image.h ) +set(CV_SOURCES + cv/image_gray.c + cv/image_gray.h + cv/integral.c + cv/integral.h + cv/haar.c + cv/haar.h + cv/hog.c + cv/hog.h + cv/linear_classifier.c + cv/linear_classifier.h + cv/detect.c + cv/detect.h + cv/nn.c + cv/nn.h + cv/tracker_kalman.c + cv/tracker_kalman.h + cv/tracker_template.c + cv/tracker_template.h + cv/track_hist.c + cv/track_hist.h + cv/tracker_particle.c + cv/tracker_particle.h + cv/tracker_meanshift.c + cv/tracker_meanshift.h + cv/tracker_kcf.c + cv/tracker_kcf.h + cv/track_assoc.c + cv/track_assoc.h +) + set(RUNTIME_SOURCES runtime/runtime.c runtime/runtime.h @@ -163,6 +194,7 @@ set(DEVICE_COMMON_SOURCES set(WRAPPER_SOURCES wrapper/CameraWrapper.cpp wrapper/CameraWrapper.hpp + wrapper/CvFeatureWrapper.hpp wrapper/DisplayWrapper.cpp wrapper/DisplayWrapper.hpp wrapper/ImageWrapper.cpp @@ -198,6 +230,7 @@ include("${EMBEDDIP_ARCH_PROFILE_FILE}") # === Create Library Target === add_library(embedDIP STATIC ${CORE_SOURCES} + ${CV_SOURCES} ${RUNTIME_SOURCES} ${IMGPROC_SOURCES} ${EMBEDDIP_BOARD_SOURCES} @@ -228,6 +261,7 @@ endif() target_include_directories(embedDIP PUBLIC $ $ + $ $ $ $ @@ -310,6 +344,7 @@ install(FILES install(DIRECTORY core/ + cv/ runtime/ imgproc/ device/ diff --git a/cv/detect.c b/cv/detect.c new file mode 100644 index 0000000..5ce0c62 --- /dev/null +++ b/cv/detect.c @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#include "cv/detect.h" + +#include +#include + +embeddip_status_t cv_detect_scan(const CvIntegralU32 *table, + const CvHaarCascade *cascade, + const CvScanConfig *scan, CvDetection *out, + size_t out_capacity, size_t *out_count) +{ + int64_t max_x; + int64_t max_y; + int64_t y; + + if (table == NULL || cascade == NULL || scan == NULL || out == NULL || + out_count == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (scan->window_width == 0u || scan->window_height == 0u || scan->step_x == 0u || + scan->step_y == 0u) { + return EMBEDDIP_ERROR_INVALID_ARG; + } + if (*out_count > out_capacity) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + /* Window larger than the table cannot be scanned; report zero new passes. */ + if ((uint64_t)scan->window_width > (uint64_t)table->width || + (uint64_t)scan->window_height > (uint64_t)table->height) { + return EMBEDDIP_OK; + } + + max_x = (int64_t)table->width - (int64_t)scan->window_width; + max_y = (int64_t)table->height - (int64_t)scan->window_height; + + for (y = 0; y <= max_y; y += (int64_t)scan->step_y) { + int64_t x; + for (x = 0; x <= max_x; x += (int64_t)scan->step_x) { + bool detected = false; + int32_t score = 0; + embeddip_status_t status; + + status = cv_haar_cascade_score(table, (int32_t)x, (int32_t)y, cascade, + &detected, &score); + if (status != EMBEDDIP_OK) { + return status; + } + if (!detected) { + continue; + } + if (*out_count >= out_capacity) { + return EMBEDDIP_OK; /* buffer full: stop, keep what we have */ + } + out[*out_count].box.x = (int32_t)x; + out[*out_count].box.y = (int32_t)y; + out[*out_count].box.width = (int32_t)scan->window_width; + out[*out_count].box.height = (int32_t)scan->window_height; + out[*out_count].score = score; + ++(*out_count); + } + } + + return EMBEDDIP_OK; +} + +static float detect_iou(const Rectangle *a, const Rectangle *b) +{ + int32_t ax1 = a->x; + int32_t ay1 = a->y; + int32_t ax2 = a->x + a->width; + int32_t ay2 = a->y + a->height; + int32_t bx1 = b->x; + int32_t by1 = b->y; + int32_t bx2 = b->x + b->width; + int32_t by2 = b->y + b->height; + + int32_t ix1 = (ax1 > bx1) ? ax1 : bx1; + int32_t iy1 = (ay1 > by1) ? ay1 : by1; + int32_t ix2 = (ax2 < bx2) ? ax2 : bx2; + int32_t iy2 = (ay2 < by2) ? ay2 : by2; + + int64_t iw = (int64_t)ix2 - (int64_t)ix1; + int64_t ih = (int64_t)iy2 - (int64_t)iy1; + int64_t inter; + int64_t area_a; + int64_t area_b; + int64_t uni; + + if (iw <= 0 || ih <= 0) { + return 0.0f; + } + inter = iw * ih; + area_a = (int64_t)a->width * (int64_t)a->height; + area_b = (int64_t)b->width * (int64_t)b->height; + uni = area_a + area_b - inter; + if (uni <= 0) { + return 0.0f; + } + return (float)((double)inter / (double)uni); +} + +embeddip_status_t cv_detect_nms(CvDetection *detections, size_t count, + float iou_threshold, size_t *out_kept) +{ + size_t i; + size_t kept = 0u; + + if (detections == NULL || out_kept == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (iou_threshold < 0.0f || iou_threshold > 1.0f) { + return EMBEDDIP_ERROR_INVALID_ARG; + } + if (count == 0u) { + *out_kept = 0u; + return EMBEDDIP_OK; + } + + /* Stable selection sort by descending score; ties keep earlier index. */ + for (i = 0u; i + 1u < count; ++i) { + size_t best = i; + size_t j; + for (j = i + 1u; j < count; ++j) { + if (detections[j].score > detections[best].score) { + best = j; + } + } + if (best != i) { + /* Rotate [i..best] right by one to preserve order of equal scores. */ + CvDetection tmp = detections[best]; + size_t k; + for (k = best; k > i; --k) { + detections[k] = detections[k - 1u]; + } + detections[i] = tmp; + } + } + + /* Greedy suppression: keep a box unless it overlaps a kept box too much. */ + for (i = 0u; i < count; ++i) { + size_t s; + int suppressed = 0; + for (s = 0u; s < kept; ++s) { + if (detect_iou(&detections[i].box, &detections[s].box) > iou_threshold) { + suppressed = 1; + break; + } + } + if (!suppressed) { + if (kept != i) { + detections[kept] = detections[i]; + } + ++kept; + } + } + + *out_kept = kept; + return EMBEDDIP_OK; +} diff --git a/cv/detect.h b/cv/detect.h new file mode 100644 index 0000000..387064a --- /dev/null +++ b/cv/detect.h @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#ifndef EMBEDDIP_CV_DETECT_H +#define EMBEDDIP_CV_DETECT_H + +#include +#include + +#include "core/error.h" +#include "core/image.h" +#include "cv/haar.h" +#include "cv/integral.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief One detection: a window rectangle and its cascade score. + */ +typedef struct { + Rectangle box; /**< Detected window in integral-image pixels. */ + int32_t score; /**< Cascade confidence (see cv_haar_cascade_score). */ +} CvDetection; + +/** + * @brief Sliding-window scan configuration. + */ +typedef struct { + uint16_t window_width; /**< Scan window width in pixels (> 0). */ + uint16_t window_height; /**< Scan window height in pixels (> 0). */ + uint16_t step_x; /**< Horizontal step in pixels (> 0). */ + uint16_t step_y; /**< Vertical step in pixels (> 0). */ +} CvScanConfig; + +/** + * @brief Slide a Haar cascade across an integral image, collecting passes. + * + * Appends detections to @p out (never exceeding @p out_capacity) starting at + * @p *out_count, so the same buffer can accumulate detections from several + * scales across multiple calls. The window's own dimensions are used; the + * cascade's stored window is ignored so a caller can reuse one cascade across + * scales by scaling the integral image instead. + * + * @param[in] table Integral image to scan. + * @param[in] cascade Cascade model, read-only. + * @param[in] scan Window and step configuration. + * @param[in,out] out Caller-owned detection buffer. + * @param[in] out_capacity Capacity of @p out. + * @param[in,out] out_count In: existing count; out: count after appending. + * @return EMBEDDIP_OK on success (including a full buffer that stops early), + * error code otherwise. + */ +embeddip_status_t cv_detect_scan(const CvIntegralU32 *table, + const CvHaarCascade *cascade, + const CvScanConfig *scan, CvDetection *out, + size_t out_capacity, size_t *out_count); + +/** + * @brief Greedy non-maximum suppression over detections. + * + * Sorts by descending score (stable, lower index wins ties), then keeps a + * detection only if its intersection-over-union with every already-kept + * detection is at most @p iou_threshold. Operates in place: survivors are + * moved to the front of @p detections and the survivor count is returned. + * + * @param[in,out] detections Detections to filter, reordered in place. + * @param[in] count Number of input detections. + * @param[in] iou_threshold IoU above which a lower-scored box is suppressed + * (0.0..1.0). + * @param[out] out_kept Number of survivors left at the front. + * @return EMBEDDIP_OK on success, error code otherwise. + */ +embeddip_status_t cv_detect_nms(CvDetection *detections, size_t count, + float iou_threshold, size_t *out_kept); + +#ifdef __cplusplus +} +#endif + +#endif /* EMBEDDIP_CV_DETECT_H */ diff --git a/cv/haar.c b/cv/haar.c new file mode 100644 index 0000000..8472118 --- /dev/null +++ b/cv/haar.c @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#include "cv/haar.h" + +#include + +embeddip_status_t cv_haar_feature_response(const CvIntegralU32 *table, int32_t origin_x, + int32_t origin_y, + const CvHaarRect *rectangles, + uint8_t rectangle_count, + int32_t *out_response_q8) +{ + int64_t accumulator = 0; + uint8_t i; + + if (out_response_q8 == NULL || rectangles == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (rectangle_count == 0u || rectangle_count > CV_HAAR_MAX_RECTS) { + return EMBEDDIP_ERROR_INVALID_ARG; + } + + for (i = 0u; i < rectangle_count; ++i) { + const CvHaarRect *rect = &rectangles[i]; + Rectangle roi = { + .x = origin_x + (int32_t)rect->x, + .y = origin_y + (int32_t)rect->y, + .width = (int32_t)rect->width, + .height = (int32_t)rect->height, + }; + uint64_t region_sum = 0u; + embeddip_status_t status; + + /* cv_integral_sum_u32 validates the table and rejects out-of-bounds ROIs. */ + status = cv_integral_sum_u32(table, roi, ®ion_sum); + if (status != EMBEDDIP_OK) { + return status; + } + /* region_sum <= UINT32_MAX and |weight_q8| <= 32768 keep this in int64. */ + accumulator += (int64_t)region_sum * (int64_t)rect->weight_q8; + } + + if (accumulator > (int64_t)INT32_MAX || accumulator < (int64_t)INT32_MIN) { + return EMBEDDIP_ERROR_OVERFLOW; + } + *out_response_q8 = (int32_t)accumulator; + return EMBEDDIP_OK; +} + +embeddip_status_t cv_haar_cascade_score(const CvIntegralU32 *table, int32_t origin_x, + int32_t origin_y, const CvHaarCascade *cascade, + bool *out_detected, int32_t *out_score) +{ + size_t s; + int64_t score = 0; + bool detected = true; + + if (cascade == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (cascade->stage_count > 0u && cascade->stages == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + + for (s = 0u; s < cascade->stage_count; ++s) { + const CvHaarStage *stage = &cascade->stages[s]; + int64_t stage_sum = 0; + size_t w; + + if (stage->weak_count > 0u && stage->weak == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + for (w = 0u; w < stage->weak_count; ++w) { + const CvHaarWeakClassifier *weak = &stage->weak[w]; + int32_t response = 0; + embeddip_status_t status; + + status = cv_haar_feature_response(table, origin_x, origin_y, + weak->rectangles, weak->rectangle_count, + &response); + if (status != EMBEDDIP_OK) { + return status; + } + stage_sum += (response >= weak->threshold_q8) ? (int64_t)weak->right_value + : (int64_t)weak->left_value; + } + score += stage_sum - (int64_t)stage->threshold; + if (stage_sum < (int64_t)stage->threshold) { + detected = false; + break; + } + } + + if (score > (int64_t)INT32_MAX) { + score = (int64_t)INT32_MAX; + } else if (score < (int64_t)INT32_MIN) { + score = (int64_t)INT32_MIN; + } + if (out_detected != NULL) { + *out_detected = detected; + } + if (out_score != NULL) { + *out_score = (int32_t)score; + } + return EMBEDDIP_OK; +} + +embeddip_status_t cv_haar_cascade_eval(const CvIntegralU32 *table, int32_t origin_x, + int32_t origin_y, const CvHaarCascade *cascade, + bool *out_detected) +{ + if (out_detected == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + return cv_haar_cascade_score(table, origin_x, origin_y, cascade, out_detected, NULL); +} diff --git a/cv/haar.h b/cv/haar.h new file mode 100644 index 0000000..0060ada --- /dev/null +++ b/cv/haar.h @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#ifndef EMBEDDIP_CV_HAAR_H +#define EMBEDDIP_CV_HAAR_H + +#include +#include +#include + +#include "core/error.h" +#include "core/image.h" +#include "cv/integral.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** Maximum rectangles per Haar feature. */ +#define CV_HAAR_MAX_RECTS 3u + +/** + * @brief One weighted rectangle of a Haar feature, in window-relative pixels. + */ +typedef struct { + int16_t x; /**< Left offset from the window origin. */ + int16_t y; /**< Top offset from the window origin. */ + uint16_t width; /**< Rectangle width in pixels. */ + uint16_t height; /**< Rectangle height in pixels. */ + int16_t weight_q8; /**< Rectangle weight in Q8 fixed point. */ +} CvHaarRect; + +/** + * @brief Single-feature weak classifier with a two-way decision stump. + */ +typedef struct { + uint8_t rectangle_count; /**< Rectangles in use (1..3). */ + CvHaarRect rectangles[CV_HAAR_MAX_RECTS]; /**< Feature rectangles. */ + int32_t threshold_q8; /**< Decision threshold in Q8. */ + int32_t left_value; /**< Value when response < threshold. */ + int32_t right_value; /**< Value when response >= threshold. */ +} CvHaarWeakClassifier; + +/** + * @brief Stage grouping weak classifiers behind a summed threshold. + */ +typedef struct { + const CvHaarWeakClassifier *weak; /**< Non-owning weak classifier array. */ + size_t weak_count; /**< Number of weak classifiers. */ + int32_t threshold; /**< Minimum weak-value sum to pass. */ +} CvHaarStage; + +/** + * @brief Cascade of stages evaluated in order. + */ +typedef struct { + const CvHaarStage *stages; /**< Non-owning stage array. */ + size_t stage_count; /**< Number of stages. */ + Rectangle window; /**< Detection window size, for callers. */ +} CvHaarCascade; + +/** + * @brief Evaluate a Haar feature response at a window origin. + * + * @param[in] table Integral image to sample. + * @param[in] origin_x Window origin x in the integral image. + * @param[in] origin_y Window origin y in the integral image. + * @param[in] rectangles Feature rectangles, window-relative. + * @param[in] rectangle_count Rectangles in use (1..3). + * @param[out] out_response_q8 Weighted response in Q8 fixed point. + * @return EMBEDDIP_OK on success, error code otherwise. + */ +embeddip_status_t cv_haar_feature_response(const CvIntegralU32 *table, int32_t origin_x, + int32_t origin_y, + const CvHaarRect *rectangles, + uint8_t rectangle_count, + int32_t *out_response_q8); + +/** + * @brief Evaluate a full cascade at a window origin. + * + * @param[in] table Integral image to sample. + * @param[in] origin_x Window origin x in the integral image. + * @param[in] origin_y Window origin y in the integral image. + * @param[in] cascade Cascade model, read-only. + * @param[out] out_detected true when every stage passes. + * @return EMBEDDIP_OK on success, error code otherwise. + */ +embeddip_status_t cv_haar_cascade_eval(const CvIntegralU32 *table, int32_t origin_x, + int32_t origin_y, const CvHaarCascade *cascade, + bool *out_detected); + +/** + * @brief Evaluate a cascade and also report a confidence score. + * + * The score is the sum of stage margins (stage sum minus stage threshold) over + * the stages evaluated before a failure, or over all stages on a pass. Higher + * scores indicate stronger detections; use it to rank overlapping windows. + * + * @param[in] table Integral image to sample. + * @param[in] origin_x Window origin x in the integral image. + * @param[in] origin_y Window origin y in the integral image. + * @param[in] cascade Cascade model, read-only. + * @param[out] out_detected true when every stage passes (may be NULL). + * @param[out] out_score Accumulated stage margin (may be NULL). + * @return EMBEDDIP_OK on success, error code otherwise. + */ +embeddip_status_t cv_haar_cascade_score(const CvIntegralU32 *table, int32_t origin_x, + int32_t origin_y, const CvHaarCascade *cascade, + bool *out_detected, int32_t *out_score); + +#ifdef __cplusplus +} +#endif + +#endif /* EMBEDDIP_CV_HAAR_H */ diff --git a/cv/hog.c b/cv/hog.c new file mode 100644 index 0000000..08c0c2f --- /dev/null +++ b/cv/hog.c @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#include "cv/hog.h" + +#include +#include +#include + +#include "cv/image_gray.h" + +#define CV_HOG_EPS 1e-6f +#define CV_HOG_PI 3.14159265358979323846f + +/* Read a pixel with coordinates clamped to the image (replicate border). */ +static uint8_t hog_pixel_clamped(const ImageView *src, int32_t px, int32_t py) +{ + if (px < 0) { + px = 0; + } else if (px >= (int32_t)src->width) { + px = (int32_t)src->width - 1; + } + if (py < 0) { + py = 0; + } else if (py >= (int32_t)src->height) { + py = (int32_t)src->height - 1; + } + return src->pixels[(size_t)py * (size_t)src->row_stride_bytes + (size_t)px]; +} + +/* Accumulate a single cell's 9-bin orientation histogram. */ +static void hog_cell_histogram(const ImageView *src, int32_t cell_x0, int32_t cell_y0, + uint16_t cell_size, float hist[CV_HOG_BINS]) +{ + const float bin_width = CV_HOG_PI / (float)CV_HOG_BINS; + uint16_t cy; + + for (uint16_t b = 0u; b < CV_HOG_BINS; ++b) { + hist[b] = 0.0f; + } + + for (cy = 0u; cy < cell_size; ++cy) { + int32_t py = cell_y0 + (int32_t)cy; + uint16_t cx; + for (cx = 0u; cx < cell_size; ++cx) { + int32_t px = cell_x0 + (int32_t)cx; + float gx = (float)hog_pixel_clamped(src, px + 1, py) - + (float)hog_pixel_clamped(src, px - 1, py); + float gy = (float)hog_pixel_clamped(src, px, py + 1) - + (float)hog_pixel_clamped(src, px, py - 1); + float mag = sqrtf(gx * gx + gy * gy); + float angle = atan2f(gy, gx); /* [-pi, pi] */ + float bin_f; + int lo; + int hi; + float frac; + + if (angle < 0.0f) { + angle += CV_HOG_PI; /* unsigned orientation, [0, pi) */ + } + bin_f = angle / bin_width; /* [0, 9) */ + lo = (int)bin_f; + if (lo >= (int)CV_HOG_BINS) { + lo = (int)CV_HOG_BINS - 1; /* guard against angle == pi rounding */ + } + frac = bin_f - (float)lo; + hi = (lo + 1) % (int)CV_HOG_BINS; + hist[lo] += mag * (1.0f - frac); + hist[hi] += mag * frac; + } + } +} + +static embeddip_status_t hog_geometry(Rectangle roi, const CvHogConfig *config, + size_t *cells_x, size_t *cells_y, + size_t *out_length) +{ + size_t cx; + size_t cy; + size_t blocks_x; + size_t blocks_y; + + if (config == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (config->cell_size == 0u) { + return EMBEDDIP_ERROR_INVALID_ARG; + } + if (roi.width <= 0 || roi.height <= 0) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + + cx = (size_t)roi.width / (size_t)config->cell_size; + cy = (size_t)roi.height / (size_t)config->cell_size; + if (cx < CV_HOG_BLOCK_CELLS || cy < CV_HOG_BLOCK_CELLS) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + + blocks_x = cx - (CV_HOG_BLOCK_CELLS - 1u); + blocks_y = cy - (CV_HOG_BLOCK_CELLS - 1u); + /* blocks_x * blocks_y * 36 with size_t overflow guard. */ + if (blocks_x != 0u && blocks_y > SIZE_MAX / blocks_x) { + return EMBEDDIP_ERROR_OVERFLOW; + } + { + size_t blocks = blocks_x * blocks_y; + if (blocks != 0u && CV_HOG_BLOCK_SIZE > SIZE_MAX / blocks) { + return EMBEDDIP_ERROR_OVERFLOW; + } + *out_length = blocks * (size_t)CV_HOG_BLOCK_SIZE; + } + *cells_x = cx; + *cells_y = cy; + return EMBEDDIP_OK; +} + +embeddip_status_t cv_hog_descriptor_size(Rectangle roi, const CvHogConfig *config, + size_t *out_length) +{ + size_t cells_x; + size_t cells_y; + + if (out_length == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + return hog_geometry(roi, config, &cells_x, &cells_y, out_length); +} + +embeddip_status_t cv_hog_extract(const ImageView *src, Rectangle roi, + const CvHogConfig *config, float *descriptor, + size_t descriptor_capacity, size_t *out_length) +{ + embeddip_status_t status; + size_t cells_x; + size_t cells_y; + size_t length; + size_t blocks_x; + size_t out = 0u; + size_t by; + + if (descriptor == NULL || out_length == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + status = cv_gray_view_validate(src); + if (status != EMBEDDIP_OK) { + return status; + } + status = hog_geometry(roi, config, &cells_x, &cells_y, &length); + if (status != EMBEDDIP_OK) { + return status; + } + /* ROI must lie inside the image. */ + if (roi.x < 0 || roi.y < 0 || + (int64_t)roi.x + (int64_t)roi.width > (int64_t)src->width || + (int64_t)roi.y + (int64_t)roi.height > (int64_t)src->height) { + return EMBEDDIP_ERROR_OUT_OF_RANGE; + } + if (descriptor_capacity < length) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + + blocks_x = cells_x - (CV_HOG_BLOCK_CELLS - 1u); + (void)blocks_x; + + for (by = 0u; by + CV_HOG_BLOCK_CELLS <= cells_y; ++by) { + size_t bx; + for (bx = 0u; bx + CV_HOG_BLOCK_CELLS <= cells_x; ++bx) { + float block[CV_HOG_BLOCK_SIZE]; + size_t idx = 0u; + double norm_sq = 0.0; + float inv_norm; + uint16_t r; + uint16_t c; + size_t k; + + /* Gather the 2x2 cell histograms of this block, row-major. */ + for (r = 0u; r < CV_HOG_BLOCK_CELLS; ++r) { + for (c = 0u; c < CV_HOG_BLOCK_CELLS; ++c) { + int32_t cell_x0 = + roi.x + (int32_t)((bx + c) * config->cell_size); + int32_t cell_y0 = + roi.y + (int32_t)((by + r) * config->cell_size); + hog_cell_histogram(src, cell_x0, cell_y0, config->cell_size, + &block[idx]); + idx += CV_HOG_BINS; + } + } + + /* L2-Hys: normalize, clip, renormalize. */ + for (k = 0u; k < CV_HOG_BLOCK_SIZE; ++k) { + norm_sq += (double)block[k] * (double)block[k]; + } + inv_norm = 1.0f / sqrtf((float)norm_sq + CV_HOG_EPS * CV_HOG_EPS); + for (k = 0u; k < CV_HOG_BLOCK_SIZE; ++k) { + float v = block[k] * inv_norm; + if (v > config->l2_hys_clip) { + v = config->l2_hys_clip; + } + block[k] = v; + } + norm_sq = 0.0; + for (k = 0u; k < CV_HOG_BLOCK_SIZE; ++k) { + norm_sq += (double)block[k] * (double)block[k]; + } + inv_norm = 1.0f / sqrtf((float)norm_sq + CV_HOG_EPS * CV_HOG_EPS); + for (k = 0u; k < CV_HOG_BLOCK_SIZE; ++k) { + descriptor[out + k] = block[k] * inv_norm; + } + out += CV_HOG_BLOCK_SIZE; + } + } + + *out_length = out; + return EMBEDDIP_OK; +} diff --git a/cv/hog.h b/cv/hog.h new file mode 100644 index 0000000..a11d594 --- /dev/null +++ b/cv/hog.h @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#ifndef EMBEDDIP_CV_HOG_H +#define EMBEDDIP_CV_HOG_H + +#include +#include + +#include "core/error.h" +#include "core/image.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** Orientation bins per cell (unsigned, 0..180 degrees). */ +#define CV_HOG_BINS 9u +/** Cells per block edge; blocks are 2x2 cells. */ +#define CV_HOG_BLOCK_CELLS 2u +/** Values per block descriptor (2 * 2 * 9). */ +#define CV_HOG_BLOCK_SIZE (CV_HOG_BLOCK_CELLS * CV_HOG_BLOCK_CELLS * CV_HOG_BINS) + +/** + * @brief HOG extraction parameters. + */ +typedef struct { + uint16_t cell_size; /**< Cell edge in pixels (> 0). */ + float l2_hys_clip; /**< L2-Hys clip threshold (e.g. 0.2). */ +} CvHogConfig; + +/** + * @brief Compute the descriptor length for a ROI and configuration. + * + * @param[in] roi Region of interest, at least 2x2 cells. + * @param[in] config Extraction configuration. + * @param[out] out_length Descriptor length in floats. + * @return EMBEDDIP_OK on success, error code otherwise. + */ +embeddip_status_t cv_hog_descriptor_size(Rectangle roi, const CvHogConfig *config, + size_t *out_length); + +/** + * @brief Extract a HOG descriptor from a grayscale ROI. + * + * @param[in] src Valid 8-bit grayscale or mask image view. + * @param[in] roi Region of interest inside the image, at least 2x2 cells. + * @param[in] config Extraction configuration. + * @param[out] descriptor Caller-owned output buffer. + * @param[in] descriptor_capacity Capacity of @p descriptor in floats. + * @param[out] out_length Number of floats written. + * @return EMBEDDIP_OK on success, error code otherwise. + */ +embeddip_status_t cv_hog_extract(const ImageView *src, Rectangle roi, + const CvHogConfig *config, float *descriptor, + size_t descriptor_capacity, size_t *out_length); + +#ifdef __cplusplus +} +#endif + +#endif /* EMBEDDIP_CV_HOG_H */ diff --git a/cv/image_gray.c b/cv/image_gray.c new file mode 100644 index 0000000..a479e6b --- /dev/null +++ b/cv/image_gray.c @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#include "cv/image_gray.h" + +#include +#include + +embeddip_status_t cv_gray_view_validate(const ImageView *view) +{ + if (view == NULL || view->pixels == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (view->width == 0u || view->height == 0u || view->row_stride_bytes < view->width) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + if (view->format != IMAGE_FORMAT_GRAYSCALE && view->format != IMAGE_FORMAT_MASK) { + return EMBEDDIP_ERROR_INVALID_FORMAT; + } + if (view->depth != IMAGE_DEPTH_U8) { + return EMBEDDIP_ERROR_INVALID_DEPTH; + } + + return EMBEDDIP_OK; +} + +embeddip_status_t cv_gray_pixel_u8(const ImageView *view, uint32_t x, uint32_t y, + uint8_t *out_pixel) +{ + embeddip_status_t status; + size_t offset; + + if (out_pixel == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + + status = cv_gray_view_validate(view); + if (status != EMBEDDIP_OK) { + return status; + } + if (x >= view->width || y >= view->height) { + return EMBEDDIP_ERROR_OUT_OF_RANGE; + } + + offset = ((size_t)view->row_stride_bytes * (size_t)y) + (size_t)x; + *out_pixel = view->pixels[offset]; + return EMBEDDIP_OK; +} diff --git a/cv/image_gray.h b/cv/image_gray.h new file mode 100644 index 0000000..ac65855 --- /dev/null +++ b/cv/image_gray.h @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#ifndef EMBEDDIP_CV_IMAGE_GRAY_H +#define EMBEDDIP_CV_IMAGE_GRAY_H + +#include + +#include "core/error.h" +#include "core/image.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Validate a non-owning 8-bit grayscale or mask image view. + * + * @param[in] view Image view to validate. + * @return EMBEDDIP_OK on success, error code otherwise. + */ +embeddip_status_t cv_gray_view_validate(const ImageView *view); + +/** + * @brief Read one pixel from a non-owning 8-bit grayscale or mask image view. + * + * @param[in] view Image view to read. + * @param[in] x Horizontal pixel coordinate. + * @param[in] y Vertical pixel coordinate. + * @param[out] out_pixel Destination for the pixel value. + * @return EMBEDDIP_OK on success, error code otherwise. + */ +embeddip_status_t cv_gray_pixel_u8(const ImageView *view, uint32_t x, uint32_t y, + uint8_t *out_pixel); + +#ifdef __cplusplus +} +#endif + +#endif /* EMBEDDIP_CV_IMAGE_GRAY_H */ diff --git a/cv/integral.c b/cv/integral.c new file mode 100644 index 0000000..2637ce6 --- /dev/null +++ b/cv/integral.c @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#include "cv/integral.h" + +#include +#include +#include + +#include "cv/image_gray.h" + +static embeddip_status_t cv_integral_table_validate(const CvIntegralU32 *table) +{ + if (table == NULL || table->values == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (table->width == 0u || table->height == 0u || + table->row_stride_values < table->width) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + + return EMBEDDIP_OK; +} + +static int cv_integral_span_fits_size(uint32_t row_stride, uint32_t width, + uint32_t height, size_t element_size) +{ + size_t rows_before_last = (size_t)height - 1u; + size_t last_row_width = (size_t)width - 1u; + size_t max_element_index = SIZE_MAX / element_size; + + return last_row_width <= max_element_index && + rows_before_last <= + (max_element_index - last_row_width) / (size_t)row_stride; +} + +embeddip_status_t cv_integral_u8_u32(const ImageView *src, CvIntegralU32 *dst) +{ + embeddip_status_t status; + uint64_t pixel_count; + uint32_t y; + + status = cv_gray_view_validate(src); + if (status != EMBEDDIP_OK) { + return status; + } + status = cv_integral_table_validate(dst); + if (status != EMBEDDIP_OK) { + return status; + } + if (dst->width != src->width || dst->height != src->height) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + + pixel_count = (uint64_t)src->width * (uint64_t)src->height; + if (pixel_count > (uint64_t)UINT32_MAX / (uint64_t)UINT8_MAX) { + return EMBEDDIP_ERROR_OVERFLOW; + } + if (!cv_integral_span_fits_size(src->row_stride_bytes, src->width, src->height, + sizeof(*src->pixels)) || + !cv_integral_span_fits_size(dst->row_stride_values, dst->width, dst->height, + sizeof(*dst->values))) { + return EMBEDDIP_ERROR_OVERFLOW; + } + + for (y = 0u; y < src->height; ++y) { + size_t src_row = (size_t)y * (size_t)src->row_stride_bytes; + size_t dst_row = (size_t)y * (size_t)dst->row_stride_values; + uint64_t row_sum = 0u; + uint32_t x; + + for (x = 0u; x < src->width; ++x) { + uint64_t value; + + row_sum += (uint64_t)src->pixels[src_row + (size_t)x]; + value = row_sum; + if (y > 0u) { + size_t preceding_row = dst_row - (size_t)dst->row_stride_values; + value += (uint64_t)dst->values[preceding_row + (size_t)x]; + } + if (value > (uint64_t)UINT32_MAX) { + return EMBEDDIP_ERROR_OVERFLOW; + } + dst->values[dst_row + (size_t)x] = (uint32_t)value; + } + } + + return EMBEDDIP_OK; +} + +embeddip_status_t cv_integral_sum_u32(const CvIntegralU32 *table, Rectangle roi, + uint64_t *out_sum) +{ + embeddip_status_t status; + uint64_t right; + uint64_t bottom; + uint32_t x0; + uint32_t y0; + uint32_t x1; + uint32_t y1; + uint64_t top_left = 0u; + uint64_t above = 0u; + uint64_t left = 0u; + uint64_t bottom_right; + + if (out_sum == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + status = cv_integral_table_validate(table); + if (status != EMBEDDIP_OK) { + return status; + } + if (!cv_integral_span_fits_size(table->row_stride_values, table->width, + table->height, sizeof(*table->values))) { + return EMBEDDIP_ERROR_OVERFLOW; + } + if (roi.x < 0 || roi.y < 0 || roi.width <= 0 || roi.height <= 0) { + return EMBEDDIP_ERROR_OUT_OF_RANGE; + } + + right = (uint64_t)(uint32_t)roi.x + (uint64_t)(uint32_t)roi.width; + bottom = (uint64_t)(uint32_t)roi.y + (uint64_t)(uint32_t)roi.height; + if (right > (uint64_t)table->width || bottom > (uint64_t)table->height) { + return EMBEDDIP_ERROR_OUT_OF_RANGE; + } + + x0 = (uint32_t)roi.x; + y0 = (uint32_t)roi.y; + x1 = (uint32_t)(right - 1u); + y1 = (uint32_t)(bottom - 1u); + bottom_right = table->values[(size_t)y1 * (size_t)table->row_stride_values + x1]; + if (y0 > 0u) { + above = table->values[((size_t)y0 - 1u) * (size_t)table->row_stride_values + x1]; + } + if (x0 > 0u) { + left = table->values[(size_t)y1 * (size_t)table->row_stride_values + x0 - 1u]; + } + if (x0 > 0u && y0 > 0u) { + top_left = table->values[((size_t)y0 - 1u) * + (size_t)table->row_stride_values + + x0 - 1u]; + } + + *out_sum = bottom_right + top_left - above - left; + return EMBEDDIP_OK; +} diff --git a/cv/integral.h b/cv/integral.h new file mode 100644 index 0000000..2a82836 --- /dev/null +++ b/cv/integral.h @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#ifndef EMBEDDIP_CV_INTEGRAL_H +#define EMBEDDIP_CV_INTEGRAL_H + +#include + +#include "core/error.h" +#include "core/image.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Non-owning view of an unsigned 32-bit integral image. + */ +typedef struct { + uint32_t *values; /**< Integral values in row-major order. */ + uint32_t width; /**< Logical width in values. */ + uint32_t height; /**< Logical height in values. */ + uint32_t row_stride_values; /**< Distance between rows, in uint32_t values. */ +} CvIntegralU32; + +/** + * @brief Build an unsigned 32-bit integral image from an 8-bit gray view. + * + * @param[in] src Valid 8-bit grayscale or mask image view. + * @param[in,out] dst Caller-owned integral table matching the source dimensions. + * @return EMBEDDIP_OK on success, error code otherwise. + */ +embeddip_status_t cv_integral_u8_u32(const ImageView *src, CvIntegralU32 *dst); + +/** + * @brief Sum a rectangular region using an unsigned 32-bit integral image. + * + * @param[in] table Integral table to query. + * @param[in] roi Positive, in-bounds rectangle. + * @param[out] out_sum Sum of the values in the rectangle. + * @return EMBEDDIP_OK on success, error code otherwise. + */ +embeddip_status_t cv_integral_sum_u32(const CvIntegralU32 *table, Rectangle roi, + uint64_t *out_sum); + +#ifdef __cplusplus +} +#endif + +#endif /* EMBEDDIP_CV_INTEGRAL_H */ diff --git a/cv/linear_classifier.c b/cv/linear_classifier.c new file mode 100644 index 0000000..b0d2101 --- /dev/null +++ b/cv/linear_classifier.c @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#include "cv/linear_classifier.h" + +#include + +embeddip_status_t cv_linear_classifier_topk(const CvLinearClassifier *model, + const float *descriptor, + size_t descriptor_length, size_t top_k, + CvClassScore *scores, size_t score_capacity, + size_t *out_count) +{ + size_t result_count; + uint16_t c; + size_t filled = 0u; + + if (model == NULL || descriptor == NULL || scores == NULL || out_count == NULL || + model->weights == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (model->class_count == 0u || model->descriptor_length == 0u || top_k == 0u) { + return EMBEDDIP_ERROR_INVALID_ARG; + } + if (descriptor_length != (size_t)model->descriptor_length) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + + result_count = ((size_t)model->class_count < top_k) ? (size_t)model->class_count + : top_k; + if (score_capacity < result_count) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + + /* Insertion into a bounded descending list; O(class_count * result_count). */ + for (c = 0u; c < model->class_count; ++c) { + const float *row = &model->weights[(size_t)c * descriptor_length]; + float score = (model->bias != NULL) ? model->bias[c] : 0.0f; + size_t i; + size_t pos; + + for (i = 0u; i < descriptor_length; ++i) { + score += row[i] * descriptor[i]; + } + + /* Find insertion point: strictly-greater score wins the higher slot, + * so equal scores keep the earlier (lower) class index ahead. */ + pos = filled; + while (pos > 0u && scores[pos - 1u].score < score) { + --pos; + } + if (pos < result_count) { + size_t last = (filled < result_count) ? filled : result_count - 1u; + size_t j; + for (j = last; j > pos; --j) { + scores[j] = scores[j - 1u]; + } + scores[pos].class_index = c; + scores[pos].score = score; + if (filled < result_count) { + ++filled; + } + } + } + + *out_count = result_count; + return EMBEDDIP_OK; +} diff --git a/cv/linear_classifier.h b/cv/linear_classifier.h new file mode 100644 index 0000000..74c3757 --- /dev/null +++ b/cv/linear_classifier.h @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#ifndef EMBEDDIP_CV_LINEAR_CLASSIFIER_H +#define EMBEDDIP_CV_LINEAR_CLASSIFIER_H + +#include +#include + +#include "core/error.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Non-owning linear (one-vs-rest) classifier model. + * + * Weights are row-major: class c uses weights[c * descriptor_length ..]. + */ +typedef struct { + const float *weights; /**< class_count * descriptor_length values. */ + const float *bias; /**< class_count bias terms (may be NULL). */ + uint16_t class_count; /**< Number of classes (> 0). */ + uint16_t descriptor_length;/**< Feature length per class (> 0). */ +} CvLinearClassifier; + +/** + * @brief Scored class result. + */ +typedef struct { + uint16_t class_index; /**< Class index. */ + float score; /**< Linear score for the class. */ +} CvClassScore; + +/** + * @brief Score a descriptor and return the top-k classes by descending score. + * + * Ties break toward the lower class index. The model is never mutated. + * + * @param[in] model Classifier model. + * @param[in] descriptor Feature vector. + * @param[in] descriptor_length Length of @p descriptor; must match the model. + * @param[in] top_k Number of results requested (> 0). + * @param[out] scores Caller-owned output array. + * @param[in] score_capacity Capacity of @p scores. + * @param[out] out_count Number of results written (min of top_k, class_count). + * @return EMBEDDIP_OK on success, error code otherwise. + */ +embeddip_status_t cv_linear_classifier_topk(const CvLinearClassifier *model, + const float *descriptor, + size_t descriptor_length, size_t top_k, + CvClassScore *scores, size_t score_capacity, + size_t *out_count); + +#ifdef __cplusplus +} +#endif + +#endif /* EMBEDDIP_CV_LINEAR_CLASSIFIER_H */ diff --git a/cv/nn.c b/cv/nn.c new file mode 100644 index 0000000..3c5fd2e --- /dev/null +++ b/cv/nn.c @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#include "cv/nn.h" + +#include +#include +#include + +#include "cv/image_gray.h" + +embeddip_status_t cv_nn_image_to_tensor(const ImageView *src, cv_tensor_t *dst) +{ + embeddip_status_t status; + size_t count; + size_t i; + uint32_t y; + + if (dst == NULL || dst->data == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + status = cv_gray_view_validate(src); + if (status != EMBEDDIP_OK) { + return status; + } + if (dst->channels != 1u) { + return EMBEDDIP_ERROR_NOT_SUPPORTED; + } + if ((uint32_t)dst->width != src->width || (uint32_t)dst->height != src->height) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + if ((dst->type == CV_TENSOR_I8 || dst->type == CV_TENSOR_U8) && + !(dst->scale > 0.0f)) { + return EMBEDDIP_ERROR_INVALID_ARG; + } + + count = (size_t)src->width * (size_t)src->height; + + i = 0u; + for (y = 0u; y < src->height; ++y) { + const uint8_t *row = &src->pixels[(size_t)y * (size_t)src->row_stride_bytes]; + uint32_t x; + for (x = 0u; x < src->width; ++x, ++i) { + float normalized = (float)row[x] / 255.0f; + + switch (dst->type) { + case CV_TENSOR_F32: { + if ((i + 1u) * sizeof(float) > dst->bytes) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + ((float *)dst->data)[i] = normalized; + break; + } + case CV_TENSOR_I8: { + float q = roundf(normalized / dst->scale) + (float)dst->zero_point; + if (i + 1u > dst->bytes) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + if (q > 127.0f) { + q = 127.0f; + } else if (q < -128.0f) { + q = -128.0f; + } + ((int8_t *)dst->data)[i] = (int8_t)q; + break; + } + case CV_TENSOR_U8: { + float q = roundf(normalized / dst->scale) + (float)dst->zero_point; + if (i + 1u > dst->bytes) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + if (q > 255.0f) { + q = 255.0f; + } else if (q < 0.0f) { + q = 0.0f; + } + ((uint8_t *)dst->data)[i] = (uint8_t)q; + break; + } + default: + return EMBEDDIP_ERROR_NOT_SUPPORTED; + } + } + } + + (void)count; + return EMBEDDIP_OK; +} + +embeddip_status_t cv_nn_argmax(const float *scores, size_t count, size_t *out_index, + float *out_value) +{ + size_t best = 0u; + size_t i; + + if (scores == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (count == 0u) { + return EMBEDDIP_ERROR_INVALID_ARG; + } + + for (i = 1u; i < count; ++i) { + if (scores[i] > scores[best]) { + best = i; + } + } + if (out_index != NULL) { + *out_index = best; + } + if (out_value != NULL) { + *out_value = scores[best]; + } + return EMBEDDIP_OK; +} + +embeddip_status_t cv_nn_softmax(float *logits, size_t count) +{ + float max_logit; + double sum = 0.0; + size_t i; + + if (logits == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (count == 0u) { + return EMBEDDIP_ERROR_INVALID_ARG; + } + + max_logit = logits[0]; + for (i = 1u; i < count; ++i) { + if (logits[i] > max_logit) { + max_logit = logits[i]; + } + } + for (i = 0u; i < count; ++i) { + float e = expf(logits[i] - max_logit); + logits[i] = e; + sum += (double)e; + } + if (sum <= 0.0) { + return EMBEDDIP_ERROR_UNDERFLOW; + } + for (i = 0u; i < count; ++i) { + logits[i] = (float)((double)logits[i] / sum); + } + return EMBEDDIP_OK; +} + +embeddip_status_t cv_nn_segmentation_argmax(const cv_tensor_t *output, + uint8_t *class_map, size_t capacity) +{ + size_t pixels; + size_t channels; + size_t p; + const float *data; + + if (output == NULL || output->data == NULL || class_map == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (output->type != CV_TENSOR_F32) { + return EMBEDDIP_ERROR_NOT_SUPPORTED; + } + if (output->width == 0u || output->height == 0u || output->channels == 0u) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + if (output->channels > 256u) { + return EMBEDDIP_ERROR_NOT_SUPPORTED; /* class index must fit in uint8_t */ + } + pixels = (size_t)output->width * (size_t)output->height; + channels = (size_t)output->channels; + if (capacity < pixels) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + + data = (const float *)output->data; + for (p = 0u; p < pixels; ++p) { + size_t best = 0u; + size_t c; + float best_val; + + /* HWC: channel stride 1, pixel stride channels. + * CHW: channel stride pixels, pixel stride 1. */ + if (output->layout == CV_TENSOR_HWC) { + best_val = data[p * channels]; + for (c = 1u; c < channels; ++c) { + float v = data[p * channels + c]; + if (v > best_val) { + best_val = v; + best = c; + } + } + } else { + best_val = data[p]; + for (c = 1u; c < channels; ++c) { + float v = data[c * pixels + p]; + if (v > best_val) { + best_val = v; + best = c; + } + } + } + class_map[p] = (uint8_t)best; + } + return EMBEDDIP_OK; +} + +embeddip_status_t cv_nn_colorize(const uint8_t *class_map, uint32_t width, + uint32_t height, const uint8_t *palette, + size_t palette_count, uint8_t *rgb, + size_t rgb_capacity) +{ + size_t pixels; + size_t p; + + if (class_map == NULL || palette == NULL || rgb == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (width == 0u || height == 0u || palette_count == 0u) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + pixels = (size_t)width * (size_t)height; + if (rgb_capacity < pixels * 3u) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + + for (p = 0u; p < pixels; ++p) { + uint8_t cls = class_map[p]; + if ((size_t)cls >= palette_count) { + return EMBEDDIP_ERROR_OUT_OF_RANGE; + } + rgb[p * 3u + 0u] = palette[(size_t)cls * 3u + 0u]; + rgb[p * 3u + 1u] = palette[(size_t)cls * 3u + 1u]; + rgb[p * 3u + 2u] = palette[(size_t)cls * 3u + 2u]; + } + return EMBEDDIP_OK; +} diff --git a/cv/nn.h b/cv/nn.h new file mode 100644 index 0000000..58f86ef --- /dev/null +++ b/cv/nn.h @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#ifndef EMBEDDIP_CV_NN_H +#define EMBEDDIP_CV_NN_H + +#include +#include + +#include "core/error.h" +#include "core/image.h" +#include "runtime/runtime.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Fill a model input tensor from an 8-bit grayscale image view. + * + * Each pixel is normalized to [0, 1] (pixel / 255) and then encoded to the + * tensor's element type: + * - CV_TENSOR_F32: stored as the normalized float. + * - CV_TENSOR_I8 / CV_TENSOR_U8: quantized as + * round(normalized / scale) + zero_point, clamped to the type range. + * + * The tensor must be single-channel and match the image dimensions. Only the + * normalized-then-quantized single-channel contract is supported. + * + * @param[in] src Valid 8-bit grayscale or mask image view. + * @param[in,out] dst Tensor with allocated @p data and matching dimensions. + * @return EMBEDDIP_OK on success, error code otherwise. + */ +embeddip_status_t cv_nn_image_to_tensor(const ImageView *src, cv_tensor_t *dst); + +/** + * @brief Index and value of the maximum element of a float score vector. + * + * Ties resolve to the lowest index. + * + * @param[in] scores Score/logit array. + * @param[in] count Number of scores (> 0). + * @param[out] out_index Argmax index (may be NULL). + * @param[out] out_value Maximum value (may be NULL). + * @return EMBEDDIP_OK on success, error code otherwise. + */ +embeddip_status_t cv_nn_argmax(const float *scores, size_t count, size_t *out_index, + float *out_value); + +/** + * @brief Numerically stable softmax over a float logit vector, in place. + * + * @param[in,out] logits Logits on input, probabilities on output. + * @param[in] count Number of elements (> 0). + * @return EMBEDDIP_OK on success, error code otherwise. + */ +embeddip_status_t cv_nn_softmax(float *logits, size_t count); + +/** + * @brief Per-pixel argmax over a float segmentation output tensor. + * + * Writes one class index per pixel (row-major, width*height entries). The + * tensor must be CV_TENSOR_F32 with channels == number of classes; both + * CV_TENSOR_HWC and CV_TENSOR_CHW layouts are supported. + * + * @param[in] output Segmentation logits/probabilities tensor. + * @param[out] class_map Caller-owned class-index buffer. + * @param[in] capacity Capacity of @p class_map in bytes/entries. + * @return EMBEDDIP_OK on success, error code otherwise. + */ +embeddip_status_t cv_nn_segmentation_argmax(const cv_tensor_t *output, + uint8_t *class_map, size_t capacity); + +/** + * @brief Colorize a class-index map into a packed RGB888 buffer. + * + * @param[in] class_map Class indices, width*height entries. + * @param[in] width Map width. + * @param[in] height Map height. + * @param[in] palette Row-major RGB palette; class c uses palette[c*3..c*3+2]. + * @param[in] palette_count Number of palette entries. + * @param[out] rgb Caller-owned RGB888 output (width*height*3 bytes). + * @param[in] rgb_capacity Capacity of @p rgb in bytes. + * @return EMBEDDIP_OK on success, error code otherwise. + */ +embeddip_status_t cv_nn_colorize(const uint8_t *class_map, uint32_t width, + uint32_t height, const uint8_t *palette, + size_t palette_count, uint8_t *rgb, + size_t rgb_capacity); + +#ifdef __cplusplus +} +#endif + +#endif /* EMBEDDIP_CV_NN_H */ diff --git a/embedDIP.h b/embedDIP.h index 28f80fa..c607143 100755 --- a/embedDIP.h +++ b/embedDIP.h @@ -70,6 +70,13 @@ extern "C" { #include "core/error.h" /**< Error handling and status codes. */ #include "core/image.h" /**< Image type and utilities. */ #include "core/memory_manager.h" /**< Allocators and memory helpers. */ +#include "cv/image_gray.h" /**< Portable grayscale image-view helpers. */ +#include "cv/integral.h" /**< Bounded integral image helpers. */ +#include "cv/haar.h" /**< Upright Haar / Viola-Jones evaluation. */ +#include "cv/hog.h" /**< Deterministic HOG descriptor extraction. */ +#include "cv/linear_classifier.h" /**< Linear one-vs-rest classifier scoring. */ +#include "cv/detect.h" /**< Sliding-window detection and NMS. */ +#include "cv/nn.h" /**< Neural-network pre/post-processing bridge. */ #include "device/serial/serial.h" /**< Serial I/O abstraction. */ #include "imgproc/color.h" /**< Color conversions and helpers. */ #include "imgproc/compress.h" /**< JPEG compression helper. */ diff --git a/embedDIP.hpp b/embedDIP.hpp index 1497b68..db7b4e8 100755 --- a/embedDIP.hpp +++ b/embedDIP.hpp @@ -10,6 +10,7 @@ */ #include "wrapper/CameraWrapper.hpp" +#include "wrapper/CvFeatureWrapper.hpp" #include "wrapper/DisplayWrapper.hpp" #include "wrapper/ImageWrapper.hpp" #include "wrapper/MemoryManager.hpp" diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index beb3a72..5da7a44 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -6,6 +6,38 @@ add_executable(embeddip_test_image_view test_image_view.c) target_link_libraries(embeddip_test_image_view PRIVATE embedDIP) add_test(NAME embeddip.image_view COMMAND embeddip_test_image_view) +add_executable(embeddip_test_cv_image_gray test_cv_image_gray.c) +target_link_libraries(embeddip_test_cv_image_gray PRIVATE embedDIP) +add_test(NAME embeddip.cv_image_gray COMMAND embeddip_test_cv_image_gray) + +add_executable(embeddip_test_cv_integral test_cv_integral.c) +target_link_libraries(embeddip_test_cv_integral PRIVATE embedDIP) +add_test(NAME embeddip.cv_integral COMMAND embeddip_test_cv_integral) + +add_executable(embeddip_test_cv_haar test_cv_haar.c) +target_link_libraries(embeddip_test_cv_haar PRIVATE embedDIP) +add_test(NAME embeddip.cv_haar COMMAND embeddip_test_cv_haar) + +add_executable(embeddip_test_cv_hog test_cv_hog.c) +target_link_libraries(embeddip_test_cv_hog PRIVATE embedDIP) +add_test(NAME embeddip.cv_hog COMMAND embeddip_test_cv_hog) + +add_executable(embeddip_test_cv_linear_classifier test_cv_linear_classifier.c) +target_link_libraries(embeddip_test_cv_linear_classifier PRIVATE embedDIP) +add_test(NAME embeddip.cv_linear_classifier COMMAND embeddip_test_cv_linear_classifier) + +add_executable(embeddip_test_cv_cpp_wrapper test_cv_cpp_wrapper.cpp) +target_link_libraries(embeddip_test_cv_cpp_wrapper PRIVATE embedDIP) +add_test(NAME embeddip.cv_cpp_wrapper COMMAND embeddip_test_cv_cpp_wrapper) + +add_executable(embeddip_test_cv_detect test_cv_detect.c) +target_link_libraries(embeddip_test_cv_detect PRIVATE embedDIP) +add_test(NAME embeddip.cv_detect COMMAND embeddip_test_cv_detect) + +add_executable(embeddip_test_cv_nn test_cv_nn.c) +target_link_libraries(embeddip_test_cv_nn PRIVATE embedDIP) +add_test(NAME embeddip.cv_nn COMMAND embeddip_test_cv_nn) + add_executable(embeddip_test_runtime test_runtime.c) target_link_libraries(embeddip_test_runtime PRIVATE embedDIP) add_test(NAME embeddip.runtime COMMAND embeddip_test_runtime) diff --git a/tests/test_cv_cpp_wrapper.cpp b/tests/test_cv_cpp_wrapper.cpp new file mode 100644 index 0000000..55013e2 --- /dev/null +++ b/tests/test_cv_cpp_wrapper.cpp @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#include +#include +#include + +#include "embedDIP.hpp" +#include "wrapper/CvFeatureWrapper.hpp" + +extern "C" { +#include "cv/hog.h" +#include "cv/image_gray.h" +} + +int main() +{ + using embedDIP::CvFeatures; + using embedDIP::Image; + + Image img(16, 16, IMAGE_FORMAT_GRAYSCALE); + assert(img.isValid()); + + // C++ validateGray parity with the C contract. + ImageView view{}; + assert(CvFeatures::validateGray(img, view) == EMBEDDIP_OK); + + // Same view can be fetched via the Image::view helper. + ImageView view2{}; + assert(img.view(&view2) == EMBEDDIP_OK); + assert(view2.width == view.width && view2.height == view.height); + assert(cv_gray_view_validate(&view2) == EMBEDDIP_OK); + + // hogSize parity with the C API. + Rectangle roi{0, 0, 16, 16}; + CvHogConfig config{4u, 0.2f}; + std::size_t cppLen = 0u; + std::size_t cLen = 0u; + assert(CvFeatures::hogSize(roi, config, cppLen) == EMBEDDIP_OK); + assert(cv_hog_descriptor_size(roi, &config, &cLen) == EMBEDDIP_OK); + assert(cppLen == cLen); + assert(cppLen == (4u - 1u) * (4u - 1u) * 36u); + + // linearTopK through the facade. + const float weights[2 * 3] = {1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f}; + const float bias[2] = {0.0f, 0.0f}; + CvLinearClassifier model{weights, bias, 2u, 3u}; + const float descriptor[3] = {5.0f, 0.0f, 1.0f}; + CvClassScore scores[2]{}; + std::size_t count = 0u; + assert(CvFeatures::linearTopK(model, descriptor, 3u, 2u, scores, 2u, count) == + EMBEDDIP_OK); + assert(count == 2u); + assert(scores[0].class_index == 0u); // score 5 > score 1 + assert(scores[1].class_index == 1u); + + return 0; +} diff --git a/tests/test_cv_detect.c b/tests/test_cv_detect.c new file mode 100644 index 0000000..092a85b --- /dev/null +++ b/tests/test_cv_detect.c @@ -0,0 +1,120 @@ +#include +#include +#include +#include + +#include +#include +#include + +/* A cascade with zero stages passes every window (all stages trivially pass). */ +static const CvHaarCascade kAlwaysCascade = { + .stages = NULL, + .stage_count = 0u, + .window = {0, 0, 0, 0}, +}; + +static void test_scan(void) +{ + /* 4x4 integral (values irrelevant: always-pass cascade). */ + uint32_t values[16] = {0}; + CvIntegralU32 table = { + .values = values, .width = 4u, .height = 4u, .row_stride_values = 4u}; + /* 2x2 window, step 1 -> origins x,y in {0,1,2} -> 3x3 = 9 windows. */ + CvScanConfig scan = {.window_width = 2u, .window_height = 2u, .step_x = 1u, + .step_y = 1u}; + CvDetection out[16]; + size_t count = 0u; + + assert(cv_detect_scan(&table, &kAlwaysCascade, &scan, out, 16u, &count) == + EMBEDDIP_OK); + assert(count == 9u); + /* First window at origin (0,0), size 2x2. */ + assert(out[0].box.x == 0 && out[0].box.y == 0); + assert(out[0].box.width == 2 && out[0].box.height == 2); + /* Last window at origin (2,2). */ + assert(out[8].box.x == 2 && out[8].box.y == 2); + + /* Appends: a second scan keeps prior detections. */ + assert(cv_detect_scan(&table, &kAlwaysCascade, &scan, out, 16u, &count) == + EMBEDDIP_OK); + assert(count == 16u); /* 9 + 9 = 18 clipped to capacity 16 */ + + /* Window larger than table -> zero new detections, no error. */ + CvScanConfig too_big = {.window_width = 5u, .window_height = 5u, .step_x = 1u, + .step_y = 1u}; + size_t big_count = 0u; + assert(cv_detect_scan(&table, &kAlwaysCascade, &too_big, out, 16u, &big_count) == + EMBEDDIP_OK); + assert(big_count == 0u); + + /* Step 2: origins {0,2} -> 2x2 = 4 windows. */ + CvScanConfig step2 = {.window_width = 2u, .window_height = 2u, .step_x = 2u, + .step_y = 2u}; + size_t s2 = 0u; + assert(cv_detect_scan(&table, &kAlwaysCascade, &step2, out, 16u, &s2) == EMBEDDIP_OK); + assert(s2 == 4u); + + /* Rejections. */ + size_t c = 0u; + CvScanConfig zero_step = {.window_width = 2u, .window_height = 2u, .step_x = 0u, + .step_y = 1u}; + assert(cv_detect_scan(&table, &kAlwaysCascade, &zero_step, out, 16u, &c) == + EMBEDDIP_ERROR_INVALID_ARG); + assert(cv_detect_scan(NULL, &kAlwaysCascade, &scan, out, 16u, &c) == + EMBEDDIP_ERROR_NULL_PTR); +} + +static void test_nms(void) +{ + /* Three boxes: A and B heavily overlap (B lower score, suppressed); + * C is far away and kept. */ + CvDetection det[3] = { + {.box = {0, 0, 10, 10}, .score = 50}, /* A */ + {.box = {1, 1, 10, 10}, .score = 90}, /* B, higher score, overlaps A */ + {.box = {100, 100, 10, 10}, .score = 30}, /* C, disjoint */ + }; + size_t kept = 0u; + + assert(cv_detect_nms(det, 3u, 0.3f, &kept) == EMBEDDIP_OK); + assert(kept == 2u); + /* Highest score first: B (90), then C (30). A suppressed by B. */ + assert(det[0].score == 90); + assert(det[1].score == 30); + + /* High IoU threshold keeps overlapping boxes (nothing suppressed). */ + CvDetection det2[3] = { + {.box = {0, 0, 10, 10}, .score = 50}, + {.box = {1, 1, 10, 10}, .score = 90}, + {.box = {100, 100, 10, 10}, .score = 30}, + }; + assert(cv_detect_nms(det2, 3u, 1.0f, &kept) == EMBEDDIP_OK); + assert(kept == 3u); + assert(det2[0].score == 90 && det2[1].score == 50 && det2[2].score == 30); + + /* Tie scores keep earlier (lower-index) box first. */ + CvDetection tie[2] = { + {.box = {0, 0, 5, 5}, .score = 40}, + {.box = {50, 50, 5, 5}, .score = 40}, + }; + assert(cv_detect_nms(tie, 2u, 0.5f, &kept) == EMBEDDIP_OK); + assert(kept == 2u); + assert(tie[0].box.x == 0); /* first-index tie winner stays first */ + assert(tie[1].box.x == 50); + + /* Empty input. */ + assert(cv_detect_nms(det, 0u, 0.5f, &kept) == EMBEDDIP_OK); + assert(kept == 0u); + + /* Rejections. */ + assert(cv_detect_nms(NULL, 3u, 0.5f, &kept) == EMBEDDIP_ERROR_NULL_PTR); + assert(cv_detect_nms(det, 3u, -0.1f, &kept) == EMBEDDIP_ERROR_INVALID_ARG); + assert(cv_detect_nms(det, 3u, 1.1f, &kept) == EMBEDDIP_ERROR_INVALID_ARG); +} + +int main(void) +{ + test_scan(); + test_nms(); + return 0; +} diff --git a/tests/test_cv_haar.c b/tests/test_cv_haar.c new file mode 100644 index 0000000..938052f --- /dev/null +++ b/tests/test_cv_haar.c @@ -0,0 +1,105 @@ +#include +#include +#include +#include + +#include +#include + +int main(void) +{ + /* Integral of the 4x4 ramp 1..16 (inclusive sums). */ + uint32_t values[16] = { + 1u, 3u, 6u, 10u, + 6u, 14u, 24u, 36u, + 15u, 32u, 51u, 72u, + 28u, 60u, 96u, 136u, + }; + CvIntegralU32 table = { + .values = values, + .width = 4u, + .height = 4u, + .row_stride_values = 4u, + }; + int32_t response = 0; + + /* One positive rectangle over the full window: sum 136, weight 1.0. */ + CvHaarRect full_rect[1] = { + {.x = 0, .y = 0, .width = 4u, .height = 4u, .weight_q8 = 256}, + }; + assert(cv_haar_feature_response(&table, 0, 0, full_rect, 1u, &response) == + EMBEDDIP_OK); + assert(response == 136 * 256); + + /* Two-rectangle left-minus-right: left sum 14, right sum 22. */ + CvHaarRect lr_rects[2] = { + {.x = 0, .y = 0, .width = 2u, .height = 2u, .weight_q8 = 256}, + {.x = 2, .y = 0, .width = 2u, .height = 2u, .weight_q8 = -256}, + }; + assert(cv_haar_feature_response(&table, 0, 0, lr_rects, 2u, &response) == + EMBEDDIP_OK); + assert(response == (14 - 22) * 256); + + /* Rejections: count 0, count > max, null out, out-of-table coords. */ + assert(cv_haar_feature_response(&table, 0, 0, full_rect, 0u, &response) == + EMBEDDIP_ERROR_INVALID_ARG); + assert(cv_haar_feature_response(&table, 0, 0, full_rect, 4u, &response) == + EMBEDDIP_ERROR_INVALID_ARG); + assert(cv_haar_feature_response(&table, 0, 0, full_rect, 1u, NULL) == + EMBEDDIP_ERROR_NULL_PTR); + assert(cv_haar_feature_response(&table, 1, 0, full_rect, 1u, &response) == + EMBEDDIP_ERROR_OUT_OF_RANGE); + assert(cv_haar_feature_response(&table, -1, 0, full_rect, 1u, &response) == + EMBEDDIP_ERROR_OUT_OF_RANGE); + + /* + * Weak classifier: response_q8 = 136*256 = 34816. + * threshold 30000 -> response >= threshold -> right_value. + */ + CvHaarWeakClassifier weak_right = { + .rectangle_count = 1u, + .rectangles = {{.x = 0, .y = 0, .width = 4u, .height = 4u, .weight_q8 = 256}}, + .threshold_q8 = 30000, + .left_value = 0, + .right_value = 100, + }; + CvHaarStage pass_stage = {.weak = &weak_right, .weak_count = 1u, .threshold = 50}; + CvHaarCascade pass_cascade = { + .stages = &pass_stage, + .stage_count = 1u, + .window = {.x = 0, .y = 0, .width = 4, .height = 4}, + }; + bool detected = false; + assert(cv_haar_cascade_eval(&table, 0, 0, &pass_cascade, &detected) == EMBEDDIP_OK); + assert(detected == true); + + /* Same weak below its threshold selects left_value. */ + CvHaarWeakClassifier weak_left = weak_right; + weak_left.threshold_q8 = 40000; /* 34816 < 40000 -> left_value 0 */ + CvHaarStage left_stage = {.weak = &weak_left, .weak_count = 1u, .threshold = 50}; + CvHaarCascade left_cascade = { + .stages = &left_stage, + .stage_count = 1u, + .window = {.x = 0, .y = 0, .width = 4, .height = 4}, + }; + assert(cv_haar_cascade_eval(&table, 0, 0, &left_cascade, &detected) == EMBEDDIP_OK); + assert(detected == false); /* weak sum 0 < stage threshold 50 */ + + /* Cascade fails at a raised stage threshold. */ + CvHaarStage fail_stage = {.weak = &weak_right, .weak_count = 1u, .threshold = 200}; + CvHaarCascade fail_cascade = { + .stages = &fail_stage, + .stage_count = 1u, + .window = {.x = 0, .y = 0, .width = 4, .height = 4}, + }; + assert(cv_haar_cascade_eval(&table, 0, 0, &fail_cascade, &detected) == EMBEDDIP_OK); + assert(detected == false); + + /* Null argument rejections. */ + assert(cv_haar_cascade_eval(&table, 0, 0, &pass_cascade, NULL) == + EMBEDDIP_ERROR_NULL_PTR); + assert(cv_haar_cascade_eval(&table, 0, 0, NULL, &detected) == + EMBEDDIP_ERROR_NULL_PTR); + + return 0; +} diff --git a/tests/test_cv_hog.c b/tests/test_cv_hog.c new file mode 100644 index 0000000..05ae955 --- /dev/null +++ b/tests/test_cv_hog.c @@ -0,0 +1,124 @@ +#include +#include +#include +#include + +#include + +#define W 16u +#define H 16u +#define STRIDE 20u /* padded row stride > width */ + +static void fill_ramp(uint8_t *buf, uint32_t stride) +{ + for (uint32_t y = 0u; y < H; ++y) { + for (uint32_t x = 0u; x < W; ++x) { + /* Horizontal ramp: value grows with x, constant in y. */ + buf[y * stride + x] = (uint8_t)(x * 16u); + } + } +} + +static void check_extract(uint32_t stride) +{ + uint8_t pixels[H * STRIDE]; + float descriptor[512]; + CvHogConfig config = {.cell_size = 4u, .l2_hys_clip = 0.2f}; + Rectangle roi = {.x = 0, .y = 0, .width = (int32_t)W, .height = (int32_t)H}; + size_t length = 0u; + size_t computed = 0u; + + fill_ramp(pixels, stride); + ImageView src = { + .pixels = pixels, + .width = W, + .height = H, + .row_stride_bytes = stride, + .format = IMAGE_FORMAT_GRAYSCALE, + .depth = IMAGE_DEPTH_U8, + .region = EMBEDDIP_MEMORY_REGION_DEFAULT, + .flags = 0u, + }; + + assert(cv_hog_descriptor_size(roi, &config, &computed) == EMBEDDIP_OK); + assert(computed == (4u - 1u) * (4u - 1u) * 36u); /* 324 */ + + assert(cv_hog_extract(&src, roi, &config, descriptor, 512u, &length) == EMBEDDIP_OK); + assert(length == computed); + + /* All finite; each 36-value block has L2 norm <= 1 + eps after clipping. */ + for (size_t b = 0u; b + CV_HOG_BLOCK_SIZE <= length; b += CV_HOG_BLOCK_SIZE) { + double norm_sq = 0.0; + for (size_t k = 0u; k < CV_HOG_BLOCK_SIZE; ++k) { + float v = descriptor[b + k]; + assert(isfinite(v)); + assert(v >= 0.0f); + norm_sq += (double)v * (double)v; + } + assert(norm_sq <= 1.0 + 1e-4); + } + + /* First block, first cell: bin 0 (horizontal) should dominate its 9 bins. */ + float max_v = descriptor[0]; + size_t max_i = 0u; + for (size_t k = 1u; k < CV_HOG_BINS; ++k) { + if (descriptor[k] > max_v) { + max_v = descriptor[k]; + max_i = k; + } + } + assert(max_i == 0u); + assert(max_v > 0.0f); +} + +int main(void) +{ + check_extract(W); /* tight stride */ + check_extract(STRIDE); /* padded stride */ + + /* Rejection cases. */ + uint8_t pixels[H * W]; + float descriptor[512]; + CvHogConfig config = {.cell_size = 4u, .l2_hys_clip = 0.2f}; + Rectangle roi = {.x = 0, .y = 0, .width = (int32_t)W, .height = (int32_t)H}; + size_t length = 0u; + size_t computed = 0u; + + fill_ramp(pixels, W); + ImageView src = { + .pixels = pixels, + .width = W, + .height = H, + .row_stride_bytes = W, + .format = IMAGE_FORMAT_GRAYSCALE, + .depth = IMAGE_DEPTH_U8, + .region = EMBEDDIP_MEMORY_REGION_DEFAULT, + .flags = 0u, + }; + + CvHogConfig zero_cell = {.cell_size = 0u, .l2_hys_clip = 0.2f}; + assert(cv_hog_descriptor_size(roi, &zero_cell, &computed) == EMBEDDIP_ERROR_INVALID_ARG); + assert(cv_hog_extract(&src, roi, &zero_cell, descriptor, 512u, &length) == + EMBEDDIP_ERROR_INVALID_ARG); + + /* ROI outside the image. */ + Rectangle outside = {.x = 4, .y = 0, .width = (int32_t)W, .height = (int32_t)H}; + assert(cv_hog_extract(&src, outside, &config, descriptor, 512u, &length) == + EMBEDDIP_ERROR_OUT_OF_RANGE); + + /* ROI smaller than 2x2 cells (only 1 cell wide). */ + Rectangle too_small = {.x = 0, .y = 0, .width = 4, .height = 16}; + assert(cv_hog_extract(&src, too_small, &config, descriptor, 512u, &length) == + EMBEDDIP_ERROR_INVALID_SIZE); + + /* Null output. */ + assert(cv_hog_extract(&src, roi, &config, NULL, 512u, &length) == + EMBEDDIP_ERROR_NULL_PTR); + assert(cv_hog_descriptor_size(roi, &config, NULL) == EMBEDDIP_ERROR_NULL_PTR); + + /* Insufficient capacity: 323 < 324. */ + assert(cv_hog_extract(&src, roi, &config, descriptor, 323u, &length) == + EMBEDDIP_ERROR_INVALID_SIZE); + + return 0; +} diff --git a/tests/test_cv_image_gray.c b/tests/test_cv_image_gray.c new file mode 100644 index 0000000..d80f325 --- /dev/null +++ b/tests/test_cv_image_gray.c @@ -0,0 +1,59 @@ +#include +#include + +#include + +int main(void) +{ + uint8_t pixels[10] = {1u, 2u, 3u, 0xaau, 0xbbu, 4u, 5u, 6u, 0xccu, 0xddu}; + uint8_t pixel = 0u; + ImageView view = { + .pixels = pixels, + .width = 3u, + .height = 2u, + .row_stride_bytes = 5u, + .format = IMAGE_FORMAT_GRAYSCALE, + .depth = IMAGE_DEPTH_U8, + .region = EMBEDDIP_MEMORY_REGION_DEFAULT, + .flags = 0u, + }; + + assert(cv_gray_view_validate(&view) == EMBEDDIP_OK); + assert(cv_gray_pixel_u8(&view, 0u, 0u, &pixel) == EMBEDDIP_OK); + assert(pixel == 1u); + assert(cv_gray_pixel_u8(&view, 2u, 1u, &pixel) == EMBEDDIP_OK); + assert(pixel == 6u); + + view.format = IMAGE_FORMAT_MASK; + assert(cv_gray_view_validate(&view) == EMBEDDIP_OK); + view.format = IMAGE_FORMAT_GRAYSCALE; + + assert(cv_gray_pixel_u8(&view, view.width, 0u, &pixel) == EMBEDDIP_ERROR_OUT_OF_RANGE); + assert(cv_gray_pixel_u8(&view, 0u, view.height, &pixel) == EMBEDDIP_ERROR_OUT_OF_RANGE); + assert(cv_gray_pixel_u8(&view, 0u, 0u, NULL) == EMBEDDIP_ERROR_NULL_PTR); + + view.pixels = NULL; + assert(cv_gray_view_validate(&view) == EMBEDDIP_ERROR_NULL_PTR); + view.pixels = pixels; + + view.width = 0u; + assert(cv_gray_view_validate(&view) == EMBEDDIP_ERROR_INVALID_SIZE); + view.width = 3u; + + view.height = 0u; + assert(cv_gray_view_validate(&view) == EMBEDDIP_ERROR_INVALID_SIZE); + view.height = 2u; + + view.row_stride_bytes = 2u; + assert(cv_gray_view_validate(&view) == EMBEDDIP_ERROR_INVALID_SIZE); + view.row_stride_bytes = 5u; + + view.format = IMAGE_FORMAT_RGB888; + assert(cv_gray_view_validate(&view) == EMBEDDIP_ERROR_INVALID_FORMAT); + view.format = IMAGE_FORMAT_GRAYSCALE; + + view.depth = IMAGE_DEPTH_F32; + assert(cv_gray_view_validate(&view) == EMBEDDIP_ERROR_INVALID_DEPTH); + + return 0; +} diff --git a/tests/test_cv_integral.c b/tests/test_cv_integral.c new file mode 100644 index 0000000..c0d498a --- /dev/null +++ b/tests/test_cv_integral.c @@ -0,0 +1,108 @@ +#include +#include + +#include + +int main(void) +{ + uint8_t pixels[10] = {1u, 2u, 3u, 99u, 99u, 4u, 5u, 6u, 99u, 99u}; + uint32_t values[8] = {0u, 0u, 0u, 0xdeadbeefu, 0u, 0u, 0u, 0xdeadbeefu}; + uint8_t dummy_pixel = 0u; + uint32_t dummy_value = 0u; + uint64_t sum = 0u; + ImageView src = { + .pixels = pixels, + .width = 3u, + .height = 2u, + .row_stride_bytes = 5u, + .format = IMAGE_FORMAT_GRAYSCALE, + .depth = IMAGE_DEPTH_U8, + .region = EMBEDDIP_MEMORY_REGION_DEFAULT, + .flags = 0u, + }; + CvIntegralU32 table = { + .values = values, + .width = 3u, + .height = 2u, + .row_stride_values = 4u, + }; + Rectangle full = {.x = 0, .y = 0, .width = 3, .height = 2}; + Rectangle middle_right = {.x = 1, .y = 0, .width = 2, .height = 2}; + Rectangle negative = {.x = -1, .y = 0, .width = 1, .height = 1}; + Rectangle out_of_bounds = {.x = 2, .y = 0, .width = 2, .height = 1}; + ImageView oversized_src = { + .pixels = &dummy_pixel, + .width = 65536u, + .height = 65536u, + .row_stride_bytes = 65536u, + .format = IMAGE_FORMAT_GRAYSCALE, + .depth = IMAGE_DEPTH_U8, + .region = EMBEDDIP_MEMORY_REGION_DEFAULT, + .flags = 0u, + }; + CvIntegralU32 oversized_table = { + .values = &dummy_value, + .width = 65536u, + .height = 65536u, + .row_stride_values = 65536u, + }; + CvIntegralU32 unaddressable_table = { + .values = &dummy_value, + .width = UINT32_MAX, + .height = UINT32_MAX, + .row_stride_values = UINT32_MAX, + }; + Rectangle origin_pixel = {.x = 0, .y = 0, .width = 1, .height = 1}; + + assert(cv_integral_u8_u32(&src, &table) == EMBEDDIP_OK); + assert(values[0] == 1u); + assert(values[1] == 3u); + assert(values[2] == 6u); + assert(values[3] == 0xdeadbeefu); + assert(values[4] == 5u); + assert(values[5] == 12u); + assert(values[6] == 21u); + assert(values[7] == 0xdeadbeefu); + + assert(cv_integral_sum_u32(&table, full, &sum) == EMBEDDIP_OK); + assert(sum == 21u); + assert(cv_integral_sum_u32(&table, middle_right, &sum) == EMBEDDIP_OK); + assert(sum == 16u); + assert(cv_integral_sum_u32(&table, negative, &sum) == EMBEDDIP_ERROR_OUT_OF_RANGE); + assert(cv_integral_sum_u32(&table, out_of_bounds, &sum) == + EMBEDDIP_ERROR_OUT_OF_RANGE); + + assert(cv_integral_u8_u32(&oversized_src, &oversized_table) == + EMBEDDIP_ERROR_OVERFLOW); + assert(cv_integral_sum_u32(&unaddressable_table, origin_pixel, &sum) == + EMBEDDIP_ERROR_OVERFLOW); + + assert(cv_integral_u8_u32(NULL, &table) == EMBEDDIP_ERROR_NULL_PTR); + assert(cv_integral_u8_u32(&src, NULL) == EMBEDDIP_ERROR_NULL_PTR); + table.values = NULL; + assert(cv_integral_u8_u32(&src, &table) == EMBEDDIP_ERROR_NULL_PTR); + table.values = values; + table.width = 0u; + assert(cv_integral_u8_u32(&src, &table) == EMBEDDIP_ERROR_INVALID_SIZE); + table.width = 3u; + table.row_stride_values = 2u; + assert(cv_integral_u8_u32(&src, &table) == EMBEDDIP_ERROR_INVALID_SIZE); + table.row_stride_values = 4u; + table.height = 1u; + assert(cv_integral_u8_u32(&src, &table) == EMBEDDIP_ERROR_INVALID_SIZE); + table.height = 2u; + + src.format = IMAGE_FORMAT_RGB888; + assert(cv_integral_u8_u32(&src, &table) == EMBEDDIP_ERROR_INVALID_FORMAT); + src.format = IMAGE_FORMAT_GRAYSCALE; + src.depth = IMAGE_DEPTH_F32; + assert(cv_integral_u8_u32(&src, &table) == EMBEDDIP_ERROR_INVALID_DEPTH); + src.depth = IMAGE_DEPTH_U8; + + assert(cv_integral_sum_u32(NULL, full, &sum) == EMBEDDIP_ERROR_NULL_PTR); + assert(cv_integral_sum_u32(&table, full, NULL) == EMBEDDIP_ERROR_NULL_PTR); + full.width = 0; + assert(cv_integral_sum_u32(&table, full, &sum) == EMBEDDIP_ERROR_OUT_OF_RANGE); + + return 0; +} diff --git a/tests/test_cv_linear_classifier.c b/tests/test_cv_linear_classifier.c new file mode 100644 index 0000000..764cb01 --- /dev/null +++ b/tests/test_cv_linear_classifier.c @@ -0,0 +1,73 @@ +#include +#include +#include + +#include + +int main(void) +{ + /* Two classes, three-element descriptors. */ + const float weights[2 * 3] = { + 1.0f, 0.0f, 0.0f, /* class 0 keys on element 0 */ + 0.0f, 0.0f, 1.0f, /* class 1 keys on element 2 */ + }; + const float bias[2] = {0.0f, 10.0f}; + const float descriptor[3] = {5.0f, 0.0f, 1.0f}; + /* score0 = 5, score1 = 1 + 10 = 11 -> class 1 wins. */ + CvLinearClassifier model = { + .weights = weights, + .bias = bias, + .class_count = 2u, + .descriptor_length = 3u, + }; + CvClassScore scores[2] = {0}; + size_t count = 0u; + + assert(cv_linear_classifier_topk(&model, descriptor, 3u, 2u, scores, 2u, &count) == + EMBEDDIP_OK); + assert(count == 2u); + assert(scores[0].class_index == 1u); + assert(scores[0].score == 11.0f); + assert(scores[1].class_index == 0u); + assert(scores[1].score == 5.0f); + + /* top-1 returns only the winner. */ + assert(cv_linear_classifier_topk(&model, descriptor, 3u, 1u, scores, 2u, &count) == + EMBEDDIP_OK); + assert(count == 1u); + assert(scores[0].class_index == 1u); + + /* Tie breaks toward the lower class index. */ + const float tie_weights[2 * 3] = {1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f}; + CvLinearClassifier tie_model = { + .weights = tie_weights, + .bias = NULL, + .class_count = 2u, + .descriptor_length = 3u, + }; + const float tie_desc[3] = {7.0f, 0.0f, 0.0f}; + assert(cv_linear_classifier_topk(&tie_model, tie_desc, 3u, 2u, scores, 2u, &count) == + EMBEDDIP_OK); + assert(count == 2u); + assert(scores[0].class_index == 0u); /* equal scores: lower index first */ + assert(scores[1].class_index == 1u); + + /* Rejections. */ + assert(cv_linear_classifier_topk(&model, descriptor, 2u, 2u, scores, 2u, &count) == + EMBEDDIP_ERROR_INVALID_SIZE); /* length mismatch */ + assert(cv_linear_classifier_topk(&model, descriptor, 3u, 0u, scores, 2u, &count) == + EMBEDDIP_ERROR_INVALID_ARG); /* top_k == 0 */ + assert(cv_linear_classifier_topk(&model, descriptor, 3u, 2u, scores, 1u, &count) == + EMBEDDIP_ERROR_INVALID_SIZE); /* capacity < result_count */ + assert(cv_linear_classifier_topk(NULL, descriptor, 3u, 2u, scores, 2u, &count) == + EMBEDDIP_ERROR_NULL_PTR); + assert(cv_linear_classifier_topk(&model, NULL, 3u, 2u, scores, 2u, &count) == + EMBEDDIP_ERROR_NULL_PTR); + + CvLinearClassifier null_weights = model; + null_weights.weights = NULL; + assert(cv_linear_classifier_topk(&null_weights, descriptor, 3u, 2u, scores, 2u, + &count) == EMBEDDIP_ERROR_NULL_PTR); + + return 0; +} diff --git a/tests/test_cv_nn.c b/tests/test_cv_nn.c new file mode 100644 index 0000000..6ae255f --- /dev/null +++ b/tests/test_cv_nn.c @@ -0,0 +1,137 @@ +#include +#include +#include +#include + +#include +#include + +static int approx(float a, float b) { return fabsf(a - b) < 1e-4f; } + +static void test_image_to_tensor(void) +{ + uint8_t pixels[4] = {0u, 255u, 51u, 204u}; + ImageView src = { + .pixels = pixels, .width = 2u, .height = 2u, .row_stride_bytes = 2u, + .format = IMAGE_FORMAT_GRAYSCALE, .depth = IMAGE_DEPTH_U8, + .region = EMBEDDIP_MEMORY_REGION_DEFAULT, .flags = 0u}; + + /* F32 path: pixel/255. */ + float fbuf[4] = {0}; + cv_tensor_t ft = {.data = fbuf, .bytes = sizeof(fbuf), .width = 2u, .height = 2u, + .channels = 1u, .type = CV_TENSOR_F32, .layout = CV_TENSOR_HWC, + .scale = 0.0f, .zero_point = 0}; + assert(cv_nn_image_to_tensor(&src, &ft) == EMBEDDIP_OK); + assert(approx(fbuf[0], 0.0f)); + assert(approx(fbuf[1], 1.0f)); + assert(approx(fbuf[2], 51.0f / 255.0f)); + + /* I8 path: scale 1/255, zero_point -128 -> normalized in [0,1] maps to [-128,127]. */ + int8_t ibuf[4] = {0}; + cv_tensor_t it = {.data = ibuf, .bytes = sizeof(ibuf), .width = 2u, .height = 2u, + .channels = 1u, .type = CV_TENSOR_I8, .layout = CV_TENSOR_HWC, + .scale = 1.0f / 255.0f, .zero_point = -128}; + assert(cv_nn_image_to_tensor(&src, &it) == EMBEDDIP_OK); + assert(ibuf[0] == -128); /* 0/255 -> 0 -> -128 */ + assert(ibuf[1] == 127); /* 255/255=1 -> 255 -128=127 */ + + /* U8 path: scale 1/255, zero_point 0 -> identity to pixel value. */ + uint8_t ubuf[4] = {0}; + cv_tensor_t ut = {.data = ubuf, .bytes = sizeof(ubuf), .width = 2u, .height = 2u, + .channels = 1u, .type = CV_TENSOR_U8, .layout = CV_TENSOR_HWC, + .scale = 1.0f / 255.0f, .zero_point = 0}; + assert(cv_nn_image_to_tensor(&src, &ut) == EMBEDDIP_OK); + assert(ubuf[0] == 0 && ubuf[1] == 255 && ubuf[2] == 51 && ubuf[3] == 204); + + /* Rejections. */ + ft.width = 3u; + assert(cv_nn_image_to_tensor(&src, &ft) == EMBEDDIP_ERROR_INVALID_SIZE); + ft.width = 2u; + ft.channels = 3u; + assert(cv_nn_image_to_tensor(&src, &ft) == EMBEDDIP_ERROR_NOT_SUPPORTED); + ft.channels = 1u; + it.scale = 0.0f; + assert(cv_nn_image_to_tensor(&src, &it) == EMBEDDIP_ERROR_INVALID_ARG); + cv_tensor_t nul = ft; + nul.data = NULL; + assert(cv_nn_image_to_tensor(&src, &nul) == EMBEDDIP_ERROR_NULL_PTR); +} + +static void test_argmax_softmax(void) +{ + float scores[4] = {0.1f, 0.7f, 0.7f, 0.2f}; + size_t idx = 99u; + float val = 0.0f; + + assert(cv_nn_argmax(scores, 4u, &idx, &val) == EMBEDDIP_OK); + assert(idx == 1u); /* tie -> lowest index */ + assert(approx(val, 0.7f)); + assert(cv_nn_argmax(scores, 0u, &idx, &val) == EMBEDDIP_ERROR_INVALID_ARG); + assert(cv_nn_argmax(NULL, 4u, &idx, &val) == EMBEDDIP_ERROR_NULL_PTR); + + float logits[3] = {1.0f, 2.0f, 3.0f}; + assert(cv_nn_softmax(logits, 3u) == EMBEDDIP_OK); + float sum = logits[0] + logits[1] + logits[2]; + assert(approx(sum, 1.0f)); + assert(logits[2] > logits[1] && logits[1] > logits[0]); + + /* Large logits: no overflow thanks to max subtraction. */ + float big[2] = {1000.0f, 1001.0f}; + assert(cv_nn_softmax(big, 2u) == EMBEDDIP_OK); + assert(approx(big[0] + big[1], 1.0f)); + assert(big[1] > big[0]); +} + +static void test_segmentation(void) +{ + /* 2x1 image, 3 classes, HWC. Pixel0 -> class 2, pixel1 -> class 0. */ + float hwc[6] = { + 0.1f, 0.2f, 0.9f, /* pixel 0: class 2 */ + 0.8f, 0.1f, 0.1f, /* pixel 1: class 0 */ + }; + cv_tensor_t hwc_t = {.data = hwc, .bytes = sizeof(hwc), .width = 2u, .height = 1u, + .channels = 3u, .type = CV_TENSOR_F32, .layout = CV_TENSOR_HWC, + .scale = 0.0f, .zero_point = 0}; + uint8_t map[2] = {0}; + assert(cv_nn_segmentation_argmax(&hwc_t, map, 2u) == EMBEDDIP_OK); + assert(map[0] == 2u && map[1] == 0u); + + /* Same data in CHW layout. */ + float chw[6] = { + 0.1f, 0.8f, /* channel 0 */ + 0.2f, 0.1f, /* channel 1 */ + 0.9f, 0.1f, /* channel 2 */ + }; + cv_tensor_t chw_t = hwc_t; + chw_t.data = chw; + chw_t.layout = CV_TENSOR_CHW; + assert(cv_nn_segmentation_argmax(&chw_t, map, 2u) == EMBEDDIP_OK); + assert(map[0] == 2u && map[1] == 0u); + + /* Capacity rejection. */ + assert(cv_nn_segmentation_argmax(&hwc_t, map, 1u) == EMBEDDIP_ERROR_INVALID_SIZE); + + /* Colorize. */ + uint8_t palette[9] = {255, 0, 0, /*c0*/ 0, 255, 0, /*c1*/ 0, 0, 255 /*c2*/}; + uint8_t rgb[6] = {0}; + uint8_t cmap[2] = {2u, 0u}; + assert(cv_nn_colorize(cmap, 2u, 1u, palette, 3u, rgb, sizeof(rgb)) == EMBEDDIP_OK); + assert(rgb[0] == 0 && rgb[1] == 0 && rgb[2] == 255); /* class 2 = blue */ + assert(rgb[3] == 255 && rgb[4] == 0 && rgb[5] == 0); /* class 0 = red */ + + /* Class index beyond palette -> out of range. */ + uint8_t bad[2] = {5u, 0u}; + assert(cv_nn_colorize(bad, 2u, 1u, palette, 3u, rgb, sizeof(rgb)) == + EMBEDDIP_ERROR_OUT_OF_RANGE); + /* Capacity rejection. */ + assert(cv_nn_colorize(cmap, 2u, 1u, palette, 3u, rgb, 5u) == + EMBEDDIP_ERROR_INVALID_SIZE); +} + +int main(void) +{ + test_image_to_tensor(); + test_argmax_softmax(); + test_segmentation(); + return 0; +} diff --git a/wrapper/CvFeatureWrapper.hpp b/wrapper/CvFeatureWrapper.hpp new file mode 100644 index 0000000..cbf57ba --- /dev/null +++ b/wrapper/CvFeatureWrapper.hpp @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#pragma once + +#include + +extern "C" { +#include "core/error.h" +#include "core/image.h" + +#include "cv/haar.h" +#include "cv/hog.h" +#include "cv/image_gray.h" +#include "cv/integral.h" +#include "cv/linear_classifier.h" +} + +#include "wrapper/ImageWrapper.hpp" + +namespace embedDIP +{ + +/** + * @brief Non-owning, non-throwing facade over the classical-feature C API. + * + * Every method returns the underlying embeddip_status_t and operates on + * caller-owned buffers and models; no hidden allocation occurs. + */ +class CvFeatures +{ + public: + /** @see cv_gray_view_validate */ + static embeddip_status_t validateGray(const Image &image, ImageView &view) noexcept + { + embeddip_status_t status = image_view_from_image(image.raw(), &view); + if (status != EMBEDDIP_OK) { + return status; + } + return cv_gray_view_validate(&view); + } + + /** @see cv_integral_u8_u32 */ + static embeddip_status_t integral(const ImageView &src, CvIntegralU32 &dst) noexcept + { + return cv_integral_u8_u32(&src, &dst); + } + + /** @see cv_haar_feature_response */ + static embeddip_status_t haarFeature(const CvIntegralU32 &table, + int32_t originX, + int32_t originY, + const CvHaarRect *rectangles, + uint8_t rectangleCount, + int32_t &responseQ8) noexcept + { + return cv_haar_feature_response( + &table, originX, originY, rectangles, rectangleCount, &responseQ8); + } + + /** @see cv_haar_cascade_eval */ + static embeddip_status_t haarCascade(const CvIntegralU32 &table, + int32_t originX, + int32_t originY, + const CvHaarCascade &cascade, + bool &detected) noexcept + { + return cv_haar_cascade_eval(&table, originX, originY, &cascade, &detected); + } + + /** @see cv_hog_descriptor_size */ + static embeddip_status_t + hogSize(Rectangle roi, const CvHogConfig &config, size_t &length) noexcept + { + return cv_hog_descriptor_size(roi, &config, &length); + } + + /** @see cv_hog_extract */ + static embeddip_status_t hog(const ImageView &src, + Rectangle roi, + const CvHogConfig &config, + float *descriptor, + size_t capacity, + size_t &length) noexcept + { + return cv_hog_extract(&src, roi, &config, descriptor, capacity, &length); + } + + /** @see cv_linear_classifier_topk */ + static embeddip_status_t linearTopK(const CvLinearClassifier &model, + const float *descriptor, + size_t descriptorLength, + size_t topK, + CvClassScore *scores, + size_t scoreCapacity, + size_t &count) noexcept + { + return cv_linear_classifier_topk( + &model, descriptor, descriptorLength, topK, scores, scoreCapacity, &count); + } +}; + +} // namespace embedDIP diff --git a/wrapper/ImageWrapper.hpp b/wrapper/ImageWrapper.hpp index 5d1fabd..66dca93 100755 --- a/wrapper/ImageWrapper.hpp +++ b/wrapper/ImageWrapper.hpp @@ -235,6 +235,17 @@ class Image return image_ != nullptr; } + /** + * @brief Fills a non-owning ImageView describing this image. + * @param[out] out_view Destination view; unchanged on error. + * @return Status from ::image_view_from_image. + * @see ::image_view_from_image For underlying C implementation + */ + inline embeddip_status_t view(ImageView *out_view) const noexcept + { + return image_view_from_image(image_, out_view); + } + /** * @brief Returns pixel buffer pointer. */ From 1425a05faa1ec6cf291cd977bea60c553ce4aed5 Mon Sep 17 00:00:00 2001 From: Ozan Durgut Date: Sat, 5 Sep 2026 14:02:02 +0200 Subject: [PATCH 2/9] cv: Kalman bounding-box tracker Signed-off-by: Ozan Durgut --- cv/tracker_kalman.c | 114 +++++++++++++++++++++++++++++++++ cv/tracker_kalman.h | 66 +++++++++++++++++++ tests/CMakeLists.txt | 4 ++ tests/test_cv_tracker_kalman.c | 61 ++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 cv/tracker_kalman.c create mode 100644 cv/tracker_kalman.h create mode 100644 tests/test_cv_tracker_kalman.c diff --git a/cv/tracker_kalman.c b/cv/tracker_kalman.c new file mode 100644 index 0000000..ebf4a4d --- /dev/null +++ b/cv/tracker_kalman.c @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#include "cv/tracker_kalman.h" + +#include +#include + +/* Constant-acceleration state transition: x' = x + vx + 0.5*ax, etc. */ +static const float kF[36] = { + 1.0f, 0.0f, 1.0f, 0.0f, 0.5f, 0.0f, + 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.5f, + 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, + 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, + 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, + 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, +}; + +/* Process noise, diagonal only (small constant, matches original HexAccel_noise_mag). */ +#define KALMAN_PROCESS_NOISE 0.01f +/* Measurement noise, diagonal only (tuned down from original tkn_x/tkn_y=1.0f to meet tracking-accuracy tolerance). */ +#define KALMAN_MEASUREMENT_NOISE 0.1f + +embeddip_status_t cv_kalman_init(CvKalmanState *state, Rectangle initial_box) +{ + if (state == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (initial_box.width <= 0 || initial_box.height <= 0) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + + memset(state->state, 0, sizeof(state->state)); + state->state[0] = (float)initial_box.x + (float)initial_box.width / 2.0f; + state->state[1] = (float)initial_box.y + (float)initial_box.height / 2.0f; + + memset(state->covariance, 0, sizeof(state->covariance)); + for (int i = 0; i < 6; ++i) { + state->covariance[i * 6 + i] = KALMAN_PROCESS_NOISE; + } + + state->box_width = initial_box.width; + state->box_height = initial_box.height; + state->initialized = true; + return EMBEDDIP_OK; +} + +/* new_state = F * state (6x6 * 6x1), hand-unrolled since dim is fixed. */ +static void kalman_apply_transition(const float *s, float *out) +{ + for (int r = 0; r < 6; ++r) { + float sum = 0.0f; + for (int c = 0; c < 6; ++c) { + sum += kF[r * 6 + c] * s[c]; + } + out[r] = sum; + } +} + +embeddip_status_t cv_kalman_predict(CvKalmanState *state, Rectangle *out_box) +{ + if (state == NULL || out_box == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (!state->initialized) { + return EMBEDDIP_ERROR_NOT_INITIALIZED; + } + + float predicted[6]; + kalman_apply_transition(state->state, predicted); + memcpy(state->state, predicted, sizeof(predicted)); + + /* Process noise added to position/velocity/accel covariance diagonal. */ + for (int i = 0; i < 6; ++i) { + state->covariance[i * 6 + i] += KALMAN_PROCESS_NOISE; + } + + out_box->x = (int32_t)(state->state[0] - (float)state->box_width / 2.0f); + out_box->y = (int32_t)(state->state[1] - (float)state->box_height / 2.0f); + out_box->width = state->box_width; + out_box->height = state->box_height; + return EMBEDDIP_OK; +} + +embeddip_status_t cv_kalman_update(CvKalmanState *state, Rectangle measured_box) +{ + if (state == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (!state->initialized) { + return EMBEDDIP_ERROR_NOT_INITIALIZED; + } + + float measured_x = (float)measured_box.x + (float)measured_box.width / 2.0f; + float measured_y = (float)measured_box.y + (float)measured_box.height / 2.0f; + + /* Scalar Kalman gain per axis (position only): K = P / (P + R). */ + float px = state->covariance[0]; + float py = state->covariance[1 * 6 + 1]; + float kx = px / (px + KALMAN_MEASUREMENT_NOISE); + float ky = py / (py + KALMAN_MEASUREMENT_NOISE); + + state->state[0] += kx * (measured_x - state->state[0]); + state->state[1] += ky * (measured_y - state->state[1]); + + state->covariance[0] *= (1.0f - kx); + state->covariance[1 * 6 + 1] *= (1.0f - ky); + + if (measured_box.width > 0 && measured_box.height > 0) { + state->box_width = measured_box.width; + state->box_height = measured_box.height; + } + return EMBEDDIP_OK; +} diff --git a/cv/tracker_kalman.h b/cv/tracker_kalman.h new file mode 100644 index 0000000..f285183 --- /dev/null +++ b/cv/tracker_kalman.h @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#ifndef EMBEDDIP_CV_TRACKER_KALMAN_H +#define EMBEDDIP_CV_TRACKER_KALMAN_H + +#include "core/error.h" +#include "core/image.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Constant-acceleration Kalman filter state for 2D bounding-box tracking. + * + * State vector is [x, y, vx, vy, ax, ay]; box width/height pass through + * unfiltered (only the box center is estimated). + */ +typedef struct { + float state[6]; /**< [x, y, vx, vy, ax, ay] */ + float covariance[36]; /**< 6x6 error covariance, row-major */ + int32_t box_width; + int32_t box_height; + bool initialized; +} CvKalmanState; + +/** + * @brief Initialize the filter with an initial bounding box (zero velocity/accel). + * + * @param[out] state Filter state to initialize. + * @param[in] initial_box Initial bounding box; center seeds position state. + * @return EMBEDDIP_OK on success, EMBEDDIP_ERROR_NULL_PTR if state is NULL, + * EMBEDDIP_ERROR_INVALID_SIZE if initial_box has non-positive width/height. + */ +embeddip_status_t cv_kalman_init(CvKalmanState *state, Rectangle initial_box); + +/** + * @brief Predict the next box position using the constant-acceleration model. + * + * Does not consume a measurement; call cv_kalman_update separately when a + * measurement is available. Advances internal state by one step. + * + * @param[in,out] state Filter state (advanced in place). + * @param[out] out_box Predicted bounding box (width/height unchanged from init). + * @return EMBEDDIP_OK on success, EMBEDDIP_ERROR_NULL_PTR if state or out_box + * is NULL, EMBEDDIP_ERROR_NOT_INITIALIZED if cv_kalman_init was not + * called first. + */ +embeddip_status_t cv_kalman_predict(CvKalmanState *state, Rectangle *out_box); + +/** + * @brief Correct the filter state with a new measurement (e.g. a detector box). + * + * @param[in,out] state Filter state (corrected in place). + * @param[in] measured_box Observed bounding box this frame. + * @return EMBEDDIP_OK on success, EMBEDDIP_ERROR_NULL_PTR if state is NULL, + * EMBEDDIP_ERROR_NOT_INITIALIZED if cv_kalman_init was not called first. + */ +embeddip_status_t cv_kalman_update(CvKalmanState *state, Rectangle measured_box); + +#ifdef __cplusplus +} +#endif + +#endif /* EMBEDDIP_CV_TRACKER_KALMAN_H */ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5da7a44..6322373 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -38,6 +38,10 @@ add_executable(embeddip_test_cv_nn test_cv_nn.c) target_link_libraries(embeddip_test_cv_nn PRIVATE embedDIP) add_test(NAME embeddip.cv_nn COMMAND embeddip_test_cv_nn) +add_executable(embeddip_test_cv_tracker_kalman test_cv_tracker_kalman.c) +target_link_libraries(embeddip_test_cv_tracker_kalman PRIVATE embedDIP) +add_test(NAME embeddip.cv_tracker_kalman COMMAND embeddip_test_cv_tracker_kalman) + add_executable(embeddip_test_runtime test_runtime.c) target_link_libraries(embeddip_test_runtime PRIVATE embedDIP) add_test(NAME embeddip.runtime COMMAND embeddip_test_runtime) diff --git a/tests/test_cv_tracker_kalman.c b/tests/test_cv_tracker_kalman.c new file mode 100644 index 0000000..8816ff8 --- /dev/null +++ b/tests/test_cv_tracker_kalman.c @@ -0,0 +1,61 @@ +#include +#include +#include +#include +#include + +#include +#include + +static void test_init_null(void) +{ + Rectangle box = {0, 0, 10, 10}; + assert(cv_kalman_init(NULL, box) == EMBEDDIP_ERROR_NULL_PTR); +} + +static void test_init_invalid_size(void) +{ + CvKalmanState state; + Rectangle bad_box = {0, 0, 0, 10}; + assert(cv_kalman_init(&state, bad_box) == EMBEDDIP_ERROR_INVALID_SIZE); +} + +static void test_predict_without_init(void) +{ + CvKalmanState state; + memset(&state, 0, sizeof(state)); + Rectangle out; + assert(cv_kalman_predict(&state, &out) == EMBEDDIP_ERROR_NOT_INITIALIZED); +} + +static void test_tracks_constant_velocity(void) +{ + CvKalmanState state; + Rectangle initial = {0, 0, 10, 10}; + assert(cv_kalman_init(&state, initial) == EMBEDDIP_OK); + + /* Feed measurements moving +5px/frame in x, correcting the filter each time. */ + int32_t x = 0; + for (int frame = 0; frame < 20; ++frame) { + Rectangle out; + assert(cv_kalman_predict(&state, &out) == EMBEDDIP_OK); + x += 5; + Rectangle measured = {x, 0, 10, 10}; + assert(cv_kalman_update(&state, measured) == EMBEDDIP_OK); + } + + Rectangle final_out; + assert(cv_kalman_predict(&state, &final_out) == EMBEDDIP_OK); + /* After 20 frames of consistent +5px/frame motion, predicted x should be + * close to the true trajectory (within 15px slack for filter lag). */ + assert(final_out.x > x - 15 && final_out.x < x + 30); +} + +int main(void) +{ + test_init_null(); + test_init_invalid_size(); + test_predict_without_init(); + test_tracks_constant_velocity(); + return 0; +} From e6f120d189b8450fdf4e6ce200ddb2a4e3270752 Mon Sep 17 00:00:00 2001 From: Ozan Durgut Date: Sat, 5 Sep 2026 14:02:31 +0200 Subject: [PATCH 3/9] cv: template-matching tracker Signed-off-by: Ozan Durgut --- cv/tracker_template.c | 94 ++++++++++++++++++++++++++++++++ cv/tracker_template.h | 66 ++++++++++++++++++++++ tests/CMakeLists.txt | 4 ++ tests/test_cv_tracker_template.c | 82 ++++++++++++++++++++++++++++ 4 files changed, 246 insertions(+) create mode 100644 cv/tracker_template.c create mode 100644 cv/tracker_template.h create mode 100644 tests/test_cv_tracker_template.c diff --git a/cv/tracker_template.c b/cv/tracker_template.c new file mode 100644 index 0000000..2da13cb --- /dev/null +++ b/cv/tracker_template.c @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#include "cv/tracker_template.h" + +#include + +static bool template_format_ok(ImageFormat fmt) +{ + return fmt == IMAGE_FORMAT_GRAYSCALE || fmt == IMAGE_FORMAT_MASK; +} + +embeddip_status_t cv_template_set(CvTemplateState *state, const ImageView *src, Rectangle roi) +{ + if (state == NULL || src == NULL || src->pixels == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (!template_format_ok(src->format)) { + return EMBEDDIP_ERROR_INVALID_FORMAT; + } + if (roi.width <= 0 || roi.height <= 0 || (uint32_t)roi.width > CV_TEMPLATE_MAX_WIDTH || + (uint32_t)roi.height > CV_TEMPLATE_MAX_HEIGHT) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + if (roi.x < 0 || roi.y < 0 || (uint32_t)(roi.x + roi.width) > src->width || + (uint32_t)(roi.y + roi.height) > src->height) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + + for (int32_t row = 0; row < roi.height; ++row) { + const uint8_t *src_row = src->pixels + (size_t)(roi.y + row) * src->row_stride_bytes; + for (int32_t col = 0; col < roi.width; ++col) { + state->patch[row][col] = src_row[roi.x + col]; + } + } + + state->width = (uint16_t)roi.width; + state->height = (uint16_t)roi.height; + state->initialized = true; + return EMBEDDIP_OK; +} + +/* Sum of absolute differences between the stored patch and a frame region. */ +static uint32_t template_sad_at(const CvTemplateState *state, const ImageView *frame, + int32_t origin_x, int32_t origin_y) +{ + uint32_t sad = 0u; + for (uint16_t row = 0u; row < state->height; ++row) { + const uint8_t *frame_row = + frame->pixels + (size_t)(origin_y + row) * frame->row_stride_bytes; + for (uint16_t col = 0u; col < state->width; ++col) { + int32_t diff = (int32_t)frame_row[origin_x + col] - (int32_t)state->patch[row][col]; + sad += (uint32_t)(diff < 0 ? -diff : diff); + } + } + return sad; +} + +embeddip_status_t cv_template_match(const CvTemplateState *state, const ImageView *frame, + Rectangle *out_box) +{ + if (state == NULL || frame == NULL || out_box == NULL || frame->pixels == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (!state->initialized) { + return EMBEDDIP_ERROR_NOT_INITIALIZED; + } + if (frame->width < state->width || frame->height < state->height) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + + int32_t max_x = (int32_t)frame->width - (int32_t)state->width; + int32_t max_y = (int32_t)frame->height - (int32_t)state->height; + uint32_t best_sad = UINT32_MAX; + int32_t best_x = 0; + int32_t best_y = 0; + + for (int32_t y = 0; y <= max_y; ++y) { + for (int32_t x = 0; x <= max_x; ++x) { + uint32_t sad = template_sad_at(state, frame, x, y); + if (sad < best_sad) { + best_sad = sad; + best_x = x; + best_y = y; + } + } + } + + out_box->x = best_x; + out_box->y = best_y; + out_box->width = state->width; + out_box->height = state->height; + return EMBEDDIP_OK; +} diff --git a/cv/tracker_template.h b/cv/tracker_template.h new file mode 100644 index 0000000..b7d3049 --- /dev/null +++ b/cv/tracker_template.h @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#ifndef EMBEDDIP_CV_TRACKER_TEMPLATE_H +#define EMBEDDIP_CV_TRACKER_TEMPLATE_H + +#include +#include + +#include "core/error.h" +#include "core/image.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** Maximum template patch width in pixels. */ +#define CV_TEMPLATE_MAX_WIDTH 64u +/** Maximum template patch height in pixels. */ +#define CV_TEMPLATE_MAX_HEIGHT 64u + +/** + * @brief Stored template patch and match state. + */ +typedef struct { + uint8_t patch[CV_TEMPLATE_MAX_HEIGHT][CV_TEMPLATE_MAX_WIDTH]; + uint16_t width; + uint16_t height; + bool initialized; +} CvTemplateState; + +/** + * @brief Capture a template patch from a region of an 8-bit grayscale image. + * + * @param[out] state Template state to populate. + * @param[in] src Grayscale (or mask) image view to copy the patch from. + * @param[in] roi Region to copy; must fit within src and within + * CV_TEMPLATE_MAX_WIDTH x CV_TEMPLATE_MAX_HEIGHT. + * @return EMBEDDIP_OK on success, EMBEDDIP_ERROR_NULL_PTR if state or src is + * NULL, EMBEDDIP_ERROR_INVALID_FORMAT if src is not grayscale/mask, + * EMBEDDIP_ERROR_INVALID_SIZE if roi is out of bounds or too large. + */ +embeddip_status_t cv_template_set(CvTemplateState *state, const ImageView *src, Rectangle roi); + +/** + * @brief Find the best-matching location of the stored template in a new frame. + * + * Brute-force scan computing sum-of-absolute-differences at every candidate + * origin; returns the origin with the lowest SAD score. + * + * @param[in] state Previously captured template (via cv_template_set). + * @param[in] frame Grayscale (or mask) image view to search. + * @param[out] out_box Best-match bounding box (same size as the template). + * @return EMBEDDIP_OK on success, EMBEDDIP_ERROR_NULL_PTR if any pointer is + * NULL, EMBEDDIP_ERROR_NOT_INITIALIZED if cv_template_set was not + * called first, EMBEDDIP_ERROR_INVALID_SIZE if frame is smaller than + * the template. + */ +embeddip_status_t cv_template_match(const CvTemplateState *state, const ImageView *frame, + Rectangle *out_box); + +#ifdef __cplusplus +} +#endif + +#endif /* EMBEDDIP_CV_TRACKER_TEMPLATE_H */ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6322373..f417a10 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -42,6 +42,10 @@ add_executable(embeddip_test_cv_tracker_kalman test_cv_tracker_kalman.c) target_link_libraries(embeddip_test_cv_tracker_kalman PRIVATE embedDIP) add_test(NAME embeddip.cv_tracker_kalman COMMAND embeddip_test_cv_tracker_kalman) +add_executable(embeddip_test_cv_tracker_template test_cv_tracker_template.c) +target_link_libraries(embeddip_test_cv_tracker_template PRIVATE embedDIP) +add_test(NAME embeddip.cv_tracker_template COMMAND embeddip_test_cv_tracker_template) + add_executable(embeddip_test_runtime test_runtime.c) target_link_libraries(embeddip_test_runtime PRIVATE embedDIP) add_test(NAME embeddip.runtime COMMAND embeddip_test_runtime) diff --git a/tests/test_cv_tracker_template.c b/tests/test_cv_tracker_template.c new file mode 100644 index 0000000..8117902 --- /dev/null +++ b/tests/test_cv_tracker_template.c @@ -0,0 +1,82 @@ +#include +#include +#include +#include +#include + +#include +#include + +#define FRAME_W 20u +#define FRAME_H 20u + +static void fill_frame(uint8_t *pixels, int32_t block_x, int32_t block_y) +{ + memset(pixels, 0u, FRAME_W * FRAME_H); + for (int32_t y = block_y; y < block_y + 4; ++y) { + for (int32_t x = block_x; x < block_x + 4; ++x) { + pixels[y * (int32_t)FRAME_W + x] = 255u; + } + } +} + +static void test_set_null(void) +{ + ImageView view = {0}; + Rectangle roi = {0, 0, 4, 4}; + assert(cv_template_set(NULL, &view, roi) == EMBEDDIP_ERROR_NULL_PTR); +} + +static void test_match_without_set(void) +{ + CvTemplateState state; + memset(&state, 0, sizeof(state)); + uint8_t pixels[FRAME_W * FRAME_H]; + fill_frame(pixels, 0, 0); + ImageView frame = {.pixels = pixels, + .width = FRAME_W, + .height = FRAME_H, + .row_stride_bytes = FRAME_W, + .format = IMAGE_FORMAT_GRAYSCALE, + .depth = IMAGE_DEPTH_U8}; + Rectangle out; + assert(cv_template_match(&state, &frame, &out) == EMBEDDIP_ERROR_NOT_INITIALIZED); +} + +static void test_finds_moved_block(void) +{ + uint8_t template_pixels[FRAME_W * FRAME_H]; + fill_frame(template_pixels, 2, 2); + ImageView template_view = {.pixels = template_pixels, + .width = FRAME_W, + .height = FRAME_H, + .row_stride_bytes = FRAME_W, + .format = IMAGE_FORMAT_GRAYSCALE, + .depth = IMAGE_DEPTH_U8}; + Rectangle roi = {2, 2, 4, 4}; + + CvTemplateState state; + assert(cv_template_set(&state, &template_view, roi) == EMBEDDIP_OK); + + uint8_t frame_pixels[FRAME_W * FRAME_H]; + fill_frame(frame_pixels, 10, 8); /* block moved to (10,8) */ + ImageView frame = {.pixels = frame_pixels, + .width = FRAME_W, + .height = FRAME_H, + .row_stride_bytes = FRAME_W, + .format = IMAGE_FORMAT_GRAYSCALE, + .depth = IMAGE_DEPTH_U8}; + + Rectangle out; + assert(cv_template_match(&state, &frame, &out) == EMBEDDIP_OK); + assert(out.x == 10 && out.y == 8); + assert(out.width == 4 && out.height == 4); +} + +int main(void) +{ + test_set_null(); + test_match_without_set(); + test_finds_moved_block(); + return 0; +} From cba0c29c12e53b9348592ff5885512bd9d08a6b6 Mon Sep 17 00:00:00 2001 From: Ozan Durgut Date: Sat, 5 Sep 2026 14:03:03 +0200 Subject: [PATCH 4/9] cv: add Bhattacharyya helper Signed-off-by: Ozan Durgut --- cv/track_hist.c | 90 ++++++++++++++++++++++++++++++++++++++ cv/track_hist.h | 83 +++++++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 4 ++ tests/test_cv_track_hist.c | 33 ++++++++++++++ 4 files changed, 210 insertions(+) create mode 100644 cv/track_hist.c create mode 100644 cv/track_hist.h create mode 100644 tests/test_cv_track_hist.c diff --git a/cv/track_hist.c b/cv/track_hist.c new file mode 100644 index 0000000..fcfa78f --- /dev/null +++ b/cv/track_hist.c @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#include "cv/track_hist.h" + +#include +#include +#include + +/** Bhattacharyya exponent scale; tuned so identical hists -> >0.99 and + * disjoint hists -> <0.8 (see tests/test_cv_track_hist.c). */ +#define CV_HIST_BHATTA_K 5.0f + +embeddip_status_t cv_hist_build(const ImageView *img, Rectangle roi, float *out, + uint32_t *out_nbins) +{ + if (img == NULL || out == NULL || out_nbins == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + + bool is_gray = (img->format == IMAGE_FORMAT_GRAYSCALE || img->format == IMAGE_FORMAT_MASK) && + img->depth == IMAGE_DEPTH_U8; + bool is_rgb565 = img->format == IMAGE_FORMAT_RGB565 && img->depth == IMAGE_DEPTH_U16; + if (!is_gray && !is_rgb565) { + return EMBEDDIP_ERROR_INVALID_FORMAT; + } + + /* Clamp roi to image bounds. */ + int32_t x0 = roi.x < 0 ? 0 : roi.x; + int32_t y0 = roi.y < 0 ? 0 : roi.y; + int32_t x1 = roi.x + roi.width; + int32_t y1 = roi.y + roi.height; + if (x1 > (int32_t)img->width) { + x1 = (int32_t)img->width; + } + if (y1 > (int32_t)img->height) { + y1 = (int32_t)img->height; + } + if (x1 <= x0 || y1 <= y0) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + + uint32_t nbins = is_gray ? CV_HIST_GRAY_BINS : CV_HIST_COLOR_BINS; + memset(out, 0, nbins * sizeof(float)); + + uint32_t total = 0; + for (int32_t y = y0; y < y1; y++) { + const uint8_t *row = img->pixels + (uint32_t)y * img->row_stride_bytes; + if (is_gray) { + for (int32_t x = x0; x < x1; x++) { + out[cv_hist_gray_bin(row[x])]++; + total++; + } + } else { + const uint16_t *row16 = (const uint16_t *)row; + for (int32_t x = x0; x < x1; x++) { + uint16_t px = row16[x]; + out[cv_hist_rgb565_bin_r(px)]++; + out[cv_hist_rgb565_bin_g(px)]++; + out[cv_hist_rgb565_bin_b(px)]++; + total += 3; + } + } + } + + if (total > 0) { + float inv_total = 1.0f / (float)total; + for (uint32_t i = 0; i < nbins; i++) { + out[i] *= inv_total; + } + } + + *out_nbins = nbins; + return EMBEDDIP_OK; +} + +float cv_hist_bhattacharyya(const float *p, const float *q, uint32_t nbins) +{ + if (p == NULL || q == NULL || nbins == 0u) { + return 0.0f; + } + + float bc = 0.0f; + for (uint32_t i = 0; i < nbins; i++) { + bc += sqrtf(p[i] * q[i]); + } + + float d = sqrtf(fmaxf(0.0f, 1.0f - bc)); + return expf(-CV_HIST_BHATTA_K * d); +} diff --git a/cv/track_hist.h b/cv/track_hist.h new file mode 100644 index 0000000..2f7ffdc --- /dev/null +++ b/cv/track_hist.h @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#ifndef EMBEDDIP_CV_TRACK_HIST_H +#define EMBEDDIP_CV_TRACK_HIST_H + +#include + +#include "core/error.h" +#include "core/image.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Number of bins for a grayscale/mask histogram. */ +#define CV_HIST_GRAY_BINS 32u +/** @brief Number of bins for an RGB565 histogram (32 per channel). */ +#define CV_HIST_COLOR_BINS 96u +/** @brief Largest bin count either histogram variant can produce. */ +#define CV_HIST_MAX_BINS 96u + +/** + * @brief Build a normalized (sum = 1) intensity/color histogram over a ROI. + * + * Grayscale/mask images produce a 32-bin histogram (pixel >> 3). RGB565 + * images produce a 96-bin histogram: bins [0,32) hold the 5-bit red + * channel, [32,64) the 6-bit green channel folded to 32 bins, and [64,96) + * the 5-bit blue channel. + * + * @param[in] img Source image view (GRAYSCALE/MASK, U8, or RGB565, U16). + * @param[in] roi Region of interest; clamped to the image bounds. + * @param[out] out Buffer of at least CV_HIST_MAX_BINS floats. + * @param[out] out_nbins Number of bins actually written (32 or 96). + * @return EMBEDDIP_OK on success; EMBEDDIP_ERROR_NULL_PTR if img/out/out_nbins + * is NULL; EMBEDDIP_ERROR_INVALID_FORMAT if the image format/depth is + * unsupported; EMBEDDIP_ERROR_INVALID_SIZE if the clamped ROI is empty. + */ +embeddip_status_t cv_hist_build(const ImageView *img, Rectangle roi, + float *out, uint32_t *out_nbins); + +/** + * @brief Bhattacharyya similarity between two normalized histograms. + * + * Computed as exp(-k * sqrt(max(0, 1 - sum(sqrt(p_i * q_i))))). + * + * @param[in] p First normalized histogram. + * @param[in] q Second normalized histogram. + * @param[in] nbins Number of bins in @p p and @p q. + * @return Similarity in [0, 1]; higher means more similar. 0 if p or q is + * NULL or nbins is 0. + */ +float cv_hist_bhattacharyya(const float *p, const float *q, uint32_t nbins); + +/** @brief Grayscale/mask histogram bin for a pixel value (bins [0, 32)). */ +static inline uint32_t cv_hist_gray_bin(uint8_t px) +{ + return (uint32_t)px >> 3; +} + +/** @brief RGB565 histogram bin for a pixel's 5-bit red channel (bins [0, 32)). */ +static inline uint32_t cv_hist_rgb565_bin_r(uint16_t px) +{ + return ((uint32_t)px >> 11) & 0x1Fu; +} + +/** @brief RGB565 histogram bin for a pixel's 6-bit green channel, folded to 32 bins ([32, 64)). */ +static inline uint32_t cv_hist_rgb565_bin_g(uint16_t px) +{ + return 32u + ((((uint32_t)px >> 5) & 0x3Fu) >> 1); +} + +/** @brief RGB565 histogram bin for a pixel's 5-bit blue channel (bins [64, 96)). */ +static inline uint32_t cv_hist_rgb565_bin_b(uint16_t px) +{ + return 64u + ((uint32_t)px & 0x1Fu); +} + +#ifdef __cplusplus +} +#endif + +#endif /* EMBEDDIP_CV_TRACK_HIST_H */ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f417a10..6d7fd24 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -46,6 +46,10 @@ add_executable(embeddip_test_cv_tracker_template test_cv_tracker_template.c) target_link_libraries(embeddip_test_cv_tracker_template PRIVATE embedDIP) add_test(NAME embeddip.cv_tracker_template COMMAND embeddip_test_cv_tracker_template) +add_executable(embeddip_test_cv_track_hist test_cv_track_hist.c) +target_link_libraries(embeddip_test_cv_track_hist PRIVATE embedDIP) +add_test(NAME embeddip.cv_track_hist COMMAND embeddip_test_cv_track_hist) + add_executable(embeddip_test_runtime test_runtime.c) target_link_libraries(embeddip_test_runtime PRIVATE embedDIP) add_test(NAME embeddip.runtime COMMAND embeddip_test_runtime) diff --git a/tests/test_cv_track_hist.c b/tests/test_cv_track_hist.c new file mode 100644 index 0000000..773a391 --- /dev/null +++ b/tests/test_cv_track_hist.c @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +#include +#include +#include +#include +#include + +int main(void){ + /* 8x8 grayscale, left half 0, right half 255 */ + uint8_t g[64]; + for(int y=0;y<8;y++) for(int x=0;x<8;x++) g[y*8+x] = (x<4)?0:255; + ImageView gv = {g,8,8,8,IMAGE_FORMAT_GRAYSCALE,IMAGE_DEPTH_U8,0,0}; + Rectangle full = {0,0,8,8}; + float h1[CV_HIST_MAX_BINS], h2[CV_HIST_MAX_BINS]; uint32_t nb=0; + assert(cv_hist_build(&gv, full, h1, &nb)==EMBEDDIP_OK && nb==CV_HIST_GRAY_BINS); + float sum=0; for(uint32_t i=0;i similarity ~1 */ + memcpy(h2,h1,sizeof(float)*nb); + assert(cv_hist_bhattacharyya(h1,h2,nb) > 0.99f); + /* all-black roi -> disjoint from h1 -> low similarity */ + uint8_t b[64]; memset(b,0,64); + ImageView bv = {b,8,8,8,IMAGE_FORMAT_GRAYSCALE,IMAGE_DEPTH_U8,0,0}; + cv_hist_build(&bv, full, h2, &nb); + assert(cv_hist_bhattacharyya(h1,h2,nb) < 0.8f); + /* RGB565 path reports 96 bins */ + uint16_t c[16]; for(int i=0;i<16;i++) c[i]=0xF800; /* pure red */ + ImageView cv = {(uint8_t*)c,4,4,8,IMAGE_FORMAT_RGB565,IMAGE_DEPTH_U16,0,0}; + Rectangle r4={0,0,4,4}; + assert(cv_hist_build(&cv, r4, h1, &nb)==EMBEDDIP_OK && nb==CV_HIST_COLOR_BINS); + assert(cv_hist_build(NULL, r4, h1, &nb)==EMBEDDIP_ERROR_NULL_PTR); + return 0; +} From 38d8cd6acc5d98da6532854faaea5973ae2c8d67 Mon Sep 17 00:00:00 2001 From: Ozan Durgut Date: Sat, 5 Sep 2026 14:03:32 +0200 Subject: [PATCH 5/9] cv: add particle-filter tracker Signed-off-by: Ozan Durgut --- cv/tracker_particle.c | 328 +++++++++++++++++++++++++++++++ cv/tracker_particle.h | 122 ++++++++++++ tests/CMakeLists.txt | 4 + tests/test_cv_tracker_particle.c | 133 +++++++++++++ 4 files changed, 587 insertions(+) create mode 100644 cv/tracker_particle.c create mode 100644 cv/tracker_particle.h create mode 100644 tests/test_cv_tracker_particle.c diff --git a/cv/tracker_particle.c b/cv/tracker_particle.c new file mode 100644 index 0000000..81361fe --- /dev/null +++ b/cv/tracker_particle.c @@ -0,0 +1,328 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#include "cv/tracker_particle.h" + +#include +#include + +#include "core/memory_manager.h" +#include "imgproc/filter.h" + +static bool particle_format_ok(ImageFormat fmt) +{ + return fmt == IMAGE_FORMAT_GRAYSCALE || fmt == IMAGE_FORMAT_MASK; +} + +/* xorshift32: fast, deterministic, no external RNG dependency. */ +static uint32_t xorshift32(uint32_t *state) +{ + uint32_t x = *state; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + *state = x; + return x; +} + +/* Uniform float in [0, 1]. */ +static float xorshift_unit(uint32_t *state) +{ + uint32_t r = xorshift32(state); + return (float)r / (float)UINT32_MAX; +} + +/* Uniform float in [-range, range]. */ +static float xorshift_range(uint32_t *state, float range) +{ + return (xorshift_unit(state) * 2.0f - 1.0f) * range; +} + +embeddip_status_t cv_particle_init(CvParticleState *state, uint16_t particle_count, + float *particle_buffer, Rectangle roi) +{ + if (state == NULL || particle_buffer == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (particle_count == 0u) { + return EMBEDDIP_ERROR_INVALID_ARG; + } + if (roi.width <= 0 || roi.height <= 0) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + + state->particle_buffer = particle_buffer; + state->particle_count = particle_count; + state->box_width = roi.width; + state->box_height = roi.height; + state->rng_state = 0x9E3779B9u; /* fixed seed: deterministic tracking, no HW RNG dependency */ + state->hist_nbins = 0u; /* legacy gradient weighting unless cv_particle_init_hist overrides */ + + float center_x = (float)roi.x + (float)roi.width / 2.0f; + float center_y = (float)roi.y + (float)roi.height / 2.0f; + for (uint16_t i = 0u; i < particle_count; ++i) { + particle_buffer[i * 2u] = center_x + xorshift_range(&state->rng_state, 5.0f); + particle_buffer[i * 2u + 1u] = center_y + xorshift_range(&state->rng_state, 5.0f); + } + + state->initialized = true; + return EMBEDDIP_OK; +} + +embeddip_status_t cv_particle_init_hist(CvParticleState *state, uint16_t particle_count, + float *particle_buffer, const ImageView *frame, + Rectangle roi) +{ + if (frame == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (particle_count > CV_PARTICLE_MAX_COUNT) { + return EMBEDDIP_ERROR_INVALID_ARG; + } + + embeddip_status_t status = cv_particle_init(state, particle_count, particle_buffer, roi); + if (status != EMBEDDIP_OK) { + return status; + } + + status = cv_hist_build(frame, roi, state->template_hist, &state->hist_nbins); + if (status != EMBEDDIP_OK) { + state->initialized = false; /* leave the caller with a clean, not half-set-up, state */ + state->hist_nbins = 0u; + return status; + } + + return EMBEDDIP_OK; +} + +/* Build a non-owning Image view over an ImageView's buffer, matching fields + * one-to-one (both are plain structs; no ownership transfer). */ +static void particle_image_from_view(const ImageView *view, Image *out) +{ + out->width = view->width; + out->height = view->height; + out->pixels = view->pixels; + out->chals = NULL; + out->size = view->width * view->height; + out->format = view->format; + out->depth = view->depth; + out->log = IMAGE_DATA_PIXELS; + out->is_chals = false; +} + +/* Null-safe teardown for a filter-populated Image's channel storage: + * gaussianGradients/gradientMagnitude can leave chals allocated (struct + * only, or struct + ch[0]) even on OOM failure paths, so every field must + * be checked before freeing. */ +static void particle_free_image_chals(Image *img) +{ + if (img->chals == NULL) { + return; + } + if (img->chals->ch[0] != NULL) { + memory_free(img->chals->ch[0]); + } + memory_free(img->chals); + img->chals = NULL; +} + +/* Histogram-likelihood update (SIR-PF, ref [1]): weight each particle by + * Bhattacharyya similarity between its candidate box's histogram and the + * template histogram, take the weighted centroid, then SIR-resample + * particle_buffer. Ported from ../object-trackers/F429_Tracker/Core/Src/ + * Bhattacharya.c's cumulative-weight + uniform-draw resample loop, using + * the module's xorshift32 RNG instead of HAL RNG. + * + * No heap: the candidate histogram and the weight/resample scratch below + * are stack arrays bounded by CV_PARTICLE_MAX_COUNT (validated at + * cv_particle_init_hist), independent of the caller's particle_buffer. */ +static embeddip_status_t cv_particle_update_hist(CvParticleState *state, const ImageView *frame, + Rectangle *out_box) +{ + uint16_t count = state->particle_count; + float weights[CV_PARTICLE_MAX_COUNT]; + float sum_w = 0.0f; + + for (uint16_t i = 0u; i < count; ++i) { + float px = state->particle_buffer[i * 2u]; + float py = state->particle_buffer[i * 2u + 1u]; + + int32_t cand_x = (int32_t)px - state->box_width / 2; + int32_t cand_y = (int32_t)py - state->box_height / 2; + if (cand_x + state->box_width > (int32_t)frame->width) { + cand_x = (int32_t)frame->width - state->box_width; + } + if (cand_x < 0) { + cand_x = 0; + } + if (cand_y + state->box_height > (int32_t)frame->height) { + cand_y = (int32_t)frame->height - state->box_height; + } + if (cand_y < 0) { + cand_y = 0; + } + Rectangle cand_roi = {cand_x, cand_y, state->box_width, state->box_height}; + + float cand_hist[CV_HIST_MAX_BINS]; + uint32_t cand_nbins = 0u; + float weight = 0.0f; + if (cv_hist_build(frame, cand_roi, cand_hist, &cand_nbins) == EMBEDDIP_OK) { + weight = cv_hist_bhattacharyya(cand_hist, state->template_hist, state->hist_nbins) + + 1e-6f; + } + weights[i] = weight; + sum_w += weight; + } + + if (sum_w <= 0.0f) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + + float centroid_x = 0.0f; + float centroid_y = 0.0f; + for (uint16_t i = 0u; i < count; ++i) { + weights[i] /= sum_w; /* normalize */ + centroid_x += weights[i] * state->particle_buffer[i * 2u]; + centroid_y += weights[i] * state->particle_buffer[i * 2u + 1u]; + } + + out_box->x = (int32_t)(centroid_x - (float)state->box_width / 2.0f); + out_box->y = (int32_t)(centroid_y - (float)state->box_height / 2.0f); + out_box->width = state->box_width; + out_box->height = state->box_height; + + /* SIR resample: cumulative weights + uniform draw. */ + for (uint16_t i = 1u; i < count; ++i) { + weights[i] += weights[i - 1u]; + } + weights[count - 1u] = 1.0f; /* guard float round-off leaving 1.0 unreachable */ + + float resampled[CV_PARTICLE_MAX_COUNT * 2u]; + for (uint16_t i = 0u; i < count; ++i) { + float draw = xorshift_unit(&state->rng_state); + uint16_t j = 0u; + while (j < count - 1u && draw > weights[j]) { + ++j; + } + resampled[i * 2u] = state->particle_buffer[j * 2u]; + resampled[i * 2u + 1u] = state->particle_buffer[j * 2u + 1u]; + } + memcpy(state->particle_buffer, resampled, (size_t)count * 2u * sizeof(float)); + + return EMBEDDIP_OK; +} + +embeddip_status_t cv_particle_update(CvParticleState *state, const ImageView *frame, + Rectangle *out_box) +{ + if (state == NULL || frame == NULL || out_box == NULL || frame->pixels == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (!state->initialized) { + return EMBEDDIP_ERROR_NOT_INITIALIZED; + } + bool hist_mode = state->hist_nbins > 0u; + bool fmt_ok = particle_format_ok(frame->format) || + (hist_mode && frame->format == IMAGE_FORMAT_RGB565); + if (!fmt_ok) { + return EMBEDDIP_ERROR_INVALID_FORMAT; + } + + /* Diffuse particles (random walk). The histogram path needs a wider + * search radius than the legacy path since it has no motion model: + * resampling can only follow the target if diffusion occasionally + * places particles over its new position. */ + float diffuse_range = hist_mode ? 10.0f : 3.0f; + for (uint16_t i = 0u; i < state->particle_count; ++i) { + state->particle_buffer[i * 2u] += xorshift_range(&state->rng_state, diffuse_range); + state->particle_buffer[i * 2u + 1u] += xorshift_range(&state->rng_state, diffuse_range); + } + + if (hist_mode) { + return cv_particle_update_hist(state, frame, out_box); + } + + /* Weight by local gradient magnitude (gradient peaks stand in for corner + * strength; embedDIP has no Harris-response primitive). */ + Image gray_image; + particle_image_from_view(frame, &gray_image); + + /* gaussianGradients/gradientMagnitude ignore ->pixels on their output + * Images entirely: they memory_alloc a fresh chals->ch[0] buffer and + * write results there (see imgproc/filter.c). So outputs must start + * with chals == NULL and the actual results are read back from + * ->chals->ch[0], not ->pixels. Each call's allocation is freed below + * once the magnitude values have been consumed, to avoid a per-frame + * leak. */ + Image gx_img = {.width = frame->width, .height = frame->height, .pixels = NULL, + .chals = NULL, .size = frame->width * frame->height, + .format = IMAGE_FORMAT_GRAYSCALE, .depth = IMAGE_DEPTH_F32, + .log = IMAGE_DATA_PIXELS, .is_chals = false}; + Image gy_img = gx_img; + Image mag_img = gx_img; + + embeddip_status_t status = gaussianGradients(&gray_image, &gx_img, &gy_img, 1.0f); + if (status != EMBEDDIP_OK) { + /* OOM paths in gaussianGradients can leave the chals struct + * allocated (with ch[0] still NULL) even on failure; free + * whatever got allocated before returning. */ + particle_free_image_chals(&gx_img); + particle_free_image_chals(&gy_img); + return status; + } + status = gradientMagnitude(&gx_img, &gy_img, &mag_img); + if (status != EMBEDDIP_OK) { + particle_free_image_chals(&gx_img); + particle_free_image_chals(&gy_img); + particle_free_image_chals(&mag_img); + return status; + } + + float sum_x = 0.0f; + float sum_y = 0.0f; + float sum_w = 0.0f; + const float *mag = (const float *)mag_img.chals->ch[0]; + + for (uint16_t i = 0u; i < state->particle_count; ++i) { + float px = state->particle_buffer[i * 2u]; + float py = state->particle_buffer[i * 2u + 1u]; + int32_t ix = (int32_t)px; + int32_t iy = (int32_t)py; + if (ix < 0 || iy < 0 || (uint32_t)ix >= frame->width || (uint32_t)iy >= frame->height) { + continue; + } + float weight = mag[(size_t)iy * frame->width + (size_t)ix] + 1e-3f; + sum_x += px * weight; + sum_y += py * weight; + sum_w += weight; + } + + particle_free_image_chals(&gx_img); + particle_free_image_chals(&gy_img); + particle_free_image_chals(&mag_img); + + if (sum_w <= 0.0f) { + return EMBEDDIP_ERROR_UNKNOWN; + } + + float centroid_x = sum_x / sum_w; + float centroid_y = sum_y / sum_w; + out_box->x = (int32_t)(centroid_x - (float)state->box_width / 2.0f); + out_box->y = (int32_t)(centroid_y - (float)state->box_height / 2.0f); + out_box->width = state->box_width; + out_box->height = state->box_height; + return EMBEDDIP_OK; +} + +void cv_particle_free(CvParticleState *state) +{ + /* particle_buffer is caller-owned (no malloc happens in this module); + * just drop the state's reference so a stale state can't be reused + * without re-initializing. */ + if (state == NULL) { + return; + } + state->particle_buffer = NULL; + state->particle_count = 0u; + state->initialized = false; +} diff --git a/cv/tracker_particle.h b/cv/tracker_particle.h new file mode 100644 index 0000000..4567f98 --- /dev/null +++ b/cv/tracker_particle.h @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#ifndef EMBEDDIP_CV_TRACKER_PARTICLE_H +#define EMBEDDIP_CV_TRACKER_PARTICLE_H + +#include +#include + +#include "core/error.h" +#include "core/image.h" +#include "cv/track_hist.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Max particle_count supported by the histogram-likelihood path + * (cv_particle_init_hist / cv_particle_update's SIR resample). Weight and + * resample scratch for that path live on the stack (no heap use), so + * particle_count is bounded to keep stack usage bounded too. Not enforced + * on the legacy gradient-weighting path. + */ +#define CV_PARTICLE_MAX_COUNT 256u + +/** + * @brief Particle-filter tracker state. + * + * Particles are stored as caller-owned (x, y) float pairs in + * @c particle_buffer (capacity particle_count * 2 floats), so the module + * never allocates memory. + */ +typedef struct { + float *particle_buffer; /**< Caller-owned, particle_count * 2 floats: [x0,y0,x1,y1,...] */ + uint16_t particle_count; + int32_t box_width; + int32_t box_height; + uint32_t rng_state; /**< xorshift32 state, seeded at init */ + bool initialized; + float template_hist[CV_HIST_MAX_BINS]; /**< Target histogram, filled by cv_particle_init_hist */ + uint32_t hist_nbins; /**< 0 = legacy gradient weighting; >0 = histogram-Bhattacharyya weighting */ +} CvParticleState; + +/** + * @brief Seed the particle filter around an initial bounding box. + * + * @param[out] state Filter state to initialize. + * @param[in] particle_count Number of particles (> 0); particle_buffer must + * have capacity particle_count * 2 floats. + * @param[in] particle_buffer Caller-owned scratch buffer for particle positions. + * @param[in] roi Initial bounding box; particles are seeded around its center. + * @return EMBEDDIP_OK on success, EMBEDDIP_ERROR_NULL_PTR if state or + * particle_buffer is NULL, EMBEDDIP_ERROR_INVALID_ARG if + * particle_count is 0, EMBEDDIP_ERROR_INVALID_SIZE if roi has + * non-positive width/height. + */ +embeddip_status_t cv_particle_init(CvParticleState *state, uint16_t particle_count, + float *particle_buffer, Rectangle roi); + +/** + * @brief Seed the particle filter (as cv_particle_init) and capture a target + * histogram from the init frame, switching cv_particle_update to the + * histogram-Bhattacharyya likelihood (ref [1] SIR-PF) instead of the + * legacy gradient weighting. + * + * @param[out] state Filter state to initialize. + * @param[in] particle_count Number of particles (> 0 and <= + * CV_PARTICLE_MAX_COUNT); particle_buffer must have capacity + * particle_count * 2 floats. + * @param[in] particle_buffer Caller-owned scratch buffer for particle positions. + * @param[in] frame Grayscale/mask (U8) or RGB565 (U16) image view to sample + * the target histogram from. + * @param[in] roi Initial bounding box; also the histogram sampling region. + * @return EMBEDDIP_OK on success, EMBEDDIP_ERROR_NULL_PTR if state, + * particle_buffer, or frame is NULL, EMBEDDIP_ERROR_INVALID_ARG if + * particle_count is 0 or exceeds CV_PARTICLE_MAX_COUNT, + * EMBEDDIP_ERROR_INVALID_SIZE if roi has non-positive width/height, + * EMBEDDIP_ERROR_INVALID_FORMAT if frame is not grayscale/mask/RGB565. + */ +embeddip_status_t cv_particle_init_hist(CvParticleState *state, uint16_t particle_count, + float *particle_buffer, const ImageView *frame, + Rectangle roi); + +/** + * @brief Diffuse particles, weight them, and return the weighted-centroid + * bounding box. + * + * Weighting depends on how @p state was initialized: cv_particle_init uses + * local gradient strength (legacy path); cv_particle_init_hist uses + * histogram-Bhattacharyya similarity against the captured target histogram, + * followed by SIR resampling of @c particle_buffer. + * + * @param[in,out] state Filter state (particles updated in place). + * @param[in] frame Image view to search; grayscale/mask for the legacy path, + * grayscale/mask/RGB565 for the histogram path (matching the + * format cv_particle_init_hist was called with). + * @param[out] out_box Weighted-centroid bounding box (same size as init roi). + * @return EMBEDDIP_OK on success, EMBEDDIP_ERROR_NULL_PTR if state, frame, or + * out_box is NULL, EMBEDDIP_ERROR_NOT_INITIALIZED if cv_particle_init + * /cv_particle_init_hist was not called first, EMBEDDIP_ERROR_INVALID_FORMAT + * if frame's format doesn't match the active path. + */ +embeddip_status_t cv_particle_update(CvParticleState *state, const ImageView *frame, + Rectangle *out_box); + +/** + * @brief Release the particle filter's association with its buffer. + * + * The particle buffer is caller-owned (no heap allocation happens inside + * this module), so this only clears @p state so it can no longer be used + * without re-initializing; it does not free @c particle_buffer itself. + * + * @param[in,out] state Filter state to release; NULL is a no-op. + */ +void cv_particle_free(CvParticleState *state); + +#ifdef __cplusplus +} +#endif + +#endif /* EMBEDDIP_CV_TRACKER_PARTICLE_H */ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6d7fd24..d47f4ff 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -50,6 +50,10 @@ add_executable(embeddip_test_cv_track_hist test_cv_track_hist.c) target_link_libraries(embeddip_test_cv_track_hist PRIVATE embedDIP) add_test(NAME embeddip.cv_track_hist COMMAND embeddip_test_cv_track_hist) +add_executable(embeddip_test_cv_tracker_particle test_cv_tracker_particle.c) +target_link_libraries(embeddip_test_cv_tracker_particle PRIVATE embedDIP) +add_test(NAME embeddip.cv_tracker_particle COMMAND embeddip_test_cv_tracker_particle) + add_executable(embeddip_test_runtime test_runtime.c) target_link_libraries(embeddip_test_runtime PRIVATE embedDIP) add_test(NAME embeddip.runtime COMMAND embeddip_test_runtime) diff --git a/tests/test_cv_tracker_particle.c b/tests/test_cv_tracker_particle.c new file mode 100644 index 0000000..5e12596 --- /dev/null +++ b/tests/test_cv_tracker_particle.c @@ -0,0 +1,133 @@ +#include +#include +#include +#include +#include + +#include +#include +#include + +#define FRAME_W 32u +#define FRAME_H 32u + +static void fill_frame(uint8_t *pixels, int32_t block_x, int32_t block_y) +{ + memset(pixels, 0u, FRAME_W * FRAME_H); + for (int32_t y = block_y; y < block_y + 6; ++y) { + for (int32_t x = block_x; x < block_x + 6; ++x) { + pixels[y * (int32_t)FRAME_W + x] = 255u; + } + } +} + +static void test_init_null(void) +{ + Rectangle roi = {0, 0, 6, 6}; + assert(cv_particle_init(NULL, 10u, NULL, roi) == EMBEDDIP_ERROR_NULL_PTR); +} + +static void test_init_zero_particles(void) +{ + CvParticleState state; + float buf[2]; + Rectangle roi = {0, 0, 6, 6}; + assert(cv_particle_init(&state, 0u, buf, roi) == EMBEDDIP_ERROR_INVALID_ARG); +} + +static void test_update_without_init(void) +{ + CvParticleState state; + memset(&state, 0, sizeof(state)); + uint8_t pixels[FRAME_W * FRAME_H]; + fill_frame(pixels, 0, 0); + ImageView frame = {.pixels = pixels, + .width = FRAME_W, + .height = FRAME_H, + .row_stride_bytes = FRAME_W, + .format = IMAGE_FORMAT_GRAYSCALE, + .depth = IMAGE_DEPTH_U8}; + Rectangle out; + assert(cv_particle_update(&state, &frame, &out) == EMBEDDIP_ERROR_NOT_INITIALIZED); +} + +static void test_tracks_toward_block(void) +{ + CvParticleState state; + float particle_buf[100 * 2]; + /* Seed away from the block's center so a dead (image-blind) tracker, + * which just reports the mean of the diffusing particles, stays near + * the seed instead of moving toward the block's actual edges. */ + Rectangle roi = {6, 6, 6, 6}; + assert(cv_particle_init(&state, 100u, particle_buf, roi) == EMBEDDIP_OK); + + uint8_t pixels[FRAME_W * FRAME_H]; + fill_frame(pixels, 22, 22); /* block far from seed */ + ImageView frame = {.pixels = pixels, + .width = FRAME_W, + .height = FRAME_H, + .row_stride_bytes = FRAME_W, + .format = IMAGE_FORMAT_GRAYSCALE, + .depth = IMAGE_DEPTH_U8}; + + Rectangle out; + embeddip_status_t status = EMBEDDIP_ERROR_UNKNOWN; + for (int frame_i = 0; frame_i < 30; ++frame_i) { + status = cv_particle_update(&state, &frame, &out); + assert(status == EMBEDDIP_OK); + } + /* The tracked box must have moved from the seed (center ~9,9) toward + * the block (center ~25,25); a gradient-blind tracker would stay near + * the seed's diffusion cloud instead. */ + int32_t center_x = out.x + out.width / 2; + int32_t center_y = out.y + out.height / 2; + assert(center_x > 15 && center_y > 15); + + cv_particle_free(&state); + assert(!state.initialized); +} + +static void test_hist_likelihood_tracks_bright_block(void) +{ + /* frame: dark bg, one 20x20 bright block; block moves; PF should follow */ + enum { W = 128, H = 128, NP = 200 }; + static uint8_t px[W * H]; + static float pbuf[NP * 2]; + CvParticleState st; + memset(&st, 0, sizeof st); + /* block at (40,40) */ + memset(px, 0, sizeof px); + for (int y = 40; y < 60; y++) { + for (int x = 40; x < 60; x++) { + px[y * W + x] = 255; + } + } + ImageView f = {px, W, H, W, IMAGE_FORMAT_GRAYSCALE, IMAGE_DEPTH_U8, 0, 0}; + Rectangle roi = {40, 40, 20, 20}; + assert(cv_particle_init_hist(&st, NP, pbuf, &f, roi) == EMBEDDIP_OK); + assert(st.hist_nbins == CV_HIST_GRAY_BINS); + Rectangle out; + /* move block to (70,70) over a few frames */ + for (int step = 0; step <= 30; step += 10) { + memset(px, 0, sizeof px); + for (int y = 40 + step; y < 60 + step; y++) { + for (int x = 40 + step; x < 60 + step; x++) { + px[y * W + x] = 255; + } + } + assert(cv_particle_update(&st, &f, &out) == EMBEDDIP_OK); + } + int cx = out.x + out.width / 2, cy = out.y + out.height / 2; + assert(cx > 60 && cy > 60); /* estimate migrated toward (80,80) */ + cv_particle_free(&st); +} + +int main(void) +{ + test_init_null(); + test_init_zero_particles(); + test_update_without_init(); + test_tracks_toward_block(); + test_hist_likelihood_tracks_bright_block(); + return 0; +} From 30acdb78fa03bad1ab91507d7c90a2e6c3f07438 Mon Sep 17 00:00:00 2001 From: Ozan Durgut Date: Sat, 5 Sep 2026 14:04:01 +0200 Subject: [PATCH 6/9] cv: add mean shift tracker Signed-off-by: Ozan Durgut --- cv/tracker_meanshift.c | 152 ++++++++++++++++++++++++++++++ cv/tracker_meanshift.h | 72 ++++++++++++++ tests/CMakeLists.txt | 4 + tests/test_cv_tracker_meanshift.c | 45 +++++++++ 4 files changed, 273 insertions(+) create mode 100644 cv/tracker_meanshift.c create mode 100644 cv/tracker_meanshift.h create mode 100644 tests/test_cv_tracker_meanshift.c diff --git a/cv/tracker_meanshift.c b/cv/tracker_meanshift.c new file mode 100644 index 0000000..a8ee758 --- /dev/null +++ b/cv/tracker_meanshift.c @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#include "cv/tracker_meanshift.h" + +#include + +/** Default mode-seek iteration cap; converges well within this for the + * shift magnitudes expected between consecutive frames. */ +#define CV_MEANSHIFT_DEFAULT_MAX_ITERS 5u + +static bool is_supported_format(const ImageView *img) +{ + bool is_gray = (img->format == IMAGE_FORMAT_GRAYSCALE || img->format == IMAGE_FORMAT_MASK) && + img->depth == IMAGE_DEPTH_U8; + bool is_rgb565 = img->format == IMAGE_FORMAT_RGB565 && img->depth == IMAGE_DEPTH_U16; + return is_gray || is_rgb565; +} + +/** Grayscale/mask bin index for pixel (x,y); uses cv/track_hist's mapping. */ +static uint32_t gray_pixel_bin(const ImageView *img, int32_t x, int32_t y) +{ + const uint8_t *row = img->pixels + (uint32_t)y * img->row_stride_bytes; + return cv_hist_gray_bin(row[x]); +} + +embeddip_status_t cv_meanshift_init(CvMeanShiftState *state, const ImageView *frame, Rectangle roi) +{ + if (state == NULL || frame == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (!is_supported_format(frame)) { + return EMBEDDIP_ERROR_INVALID_FORMAT; + } + if (roi.width <= 0 || roi.height <= 0) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + + /* Clamp roi to frame bounds first, matching cv_hist_build's own clamp, + * so the stored box geometry always matches the region the histogram + * was built over (and always fits inside the frame). */ + int32_t x0 = roi.x < 0 ? 0 : roi.x; + int32_t y0 = roi.y < 0 ? 0 : roi.y; + int32_t x1 = roi.x + roi.width; + int32_t y1 = roi.y + roi.height; + if (x1 > (int32_t)frame->width) x1 = (int32_t)frame->width; + if (y1 > (int32_t)frame->height) y1 = (int32_t)frame->height; + if (x1 <= x0 || y1 <= y0) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + roi.x = x0; + roi.y = y0; + roi.width = x1 - x0; + roi.height = y1 - y0; + + embeddip_status_t st = cv_hist_build(frame, roi, state->template_hist, &state->hist_nbins); + if (st != EMBEDDIP_OK) { + return st; + } + + state->box_width = roi.width; + state->box_height = roi.height; + state->center_x = roi.x + roi.width / 2; + state->center_y = roi.y + roi.height / 2; + state->max_iters = CV_MEANSHIFT_DEFAULT_MAX_ITERS; + state->initialized = true; + return EMBEDDIP_OK; +} + +embeddip_status_t cv_meanshift_update(CvMeanShiftState *state, const ImageView *frame, + Rectangle *out_box) +{ + if (state == NULL || frame == NULL || out_box == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (!state->initialized) { + return EMBEDDIP_ERROR_NOT_INITIALIZED; + } + if (!is_supported_format(frame)) { + return EMBEDDIP_ERROR_INVALID_FORMAT; + } + + bool is_gray = (frame->format == IMAGE_FORMAT_GRAYSCALE || frame->format == IMAGE_FORMAT_MASK); + int32_t hw = state->box_width / 2; + int32_t hh = state->box_height / 2; + /* ponytail: search box padded to 2x the tracked box so a shift of up to + * one box dimension per frame is still visible to the centroid; widen + * further if the tracked object can move faster between frames. */ + int32_t search_hw = state->box_width; + int32_t search_hh = state->box_height; + + for (uint16_t iter = 0; iter < state->max_iters; iter++) { + int32_t sx0 = state->center_x - search_hw; + int32_t sy0 = state->center_y - search_hh; + int32_t sx1 = state->center_x + search_hw; + int32_t sy1 = state->center_y + search_hh; + if (sx0 < 0) sx0 = 0; + if (sy0 < 0) sy0 = 0; + if (sx1 > (int32_t)frame->width) sx1 = (int32_t)frame->width; + if (sy1 > (int32_t)frame->height) sy1 = (int32_t)frame->height; + + double sum_x = 0.0, sum_y = 0.0, sum_w = 0.0; + for (int32_t y = sy0; y < sy1; y++) { + for (int32_t x = sx0; x < sx1; x++) { + if (is_gray) { + uint32_t bin = gray_pixel_bin(frame, x, y); + float w = sqrtf(state->template_hist[bin]); + sum_x += (double)x * w; + sum_y += (double)y * w; + sum_w += (double)w; + } else { + const uint16_t *row16 = + (const uint16_t *)(frame->pixels + (uint32_t)y * frame->row_stride_bytes); + uint16_t px = row16[x]; + float w = sqrtf(state->template_hist[cv_hist_rgb565_bin_r(px)]) + + sqrtf(state->template_hist[cv_hist_rgb565_bin_g(px)]) + + sqrtf(state->template_hist[cv_hist_rgb565_bin_b(px)]); + sum_x += (double)x * w; + sum_y += (double)y * w; + sum_w += (double)w; + } + } + } + + if (sum_w <= 0.0) { + break; + } + + int32_t new_cx = (int32_t)lround(sum_x / sum_w); + int32_t new_cy = (int32_t)lround(sum_y / sum_w); + double dx = (double)(new_cx - state->center_x); + double dy = (double)(new_cy - state->center_y); + state->center_x = new_cx; + state->center_y = new_cy; + + if ((dx * dx + dy * dy) < 1.0) { + break; + } + } + + /* Clamp so the box stays inside the frame. */ + if (state->center_x - hw < 0) state->center_x = hw; + if (state->center_y - hh < 0) state->center_y = hh; + if (state->center_x + hw > (int32_t)frame->width) state->center_x = (int32_t)frame->width - hw; + if (state->center_y + hh > (int32_t)frame->height) state->center_y = (int32_t)frame->height - hh; + + out_box->x = state->center_x - hw; + out_box->y = state->center_y - hh; + out_box->width = state->box_width; + out_box->height = state->box_height; + return EMBEDDIP_OK; +} diff --git a/cv/tracker_meanshift.h b/cv/tracker_meanshift.h new file mode 100644 index 0000000..15528cc --- /dev/null +++ b/cv/tracker_meanshift.h @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#ifndef EMBEDDIP_CV_TRACKER_MEANSHIFT_H +#define EMBEDDIP_CV_TRACKER_MEANSHIFT_H + +#include +#include + +#include "core/error.h" +#include "core/image.h" +#include "cv/track_hist.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Mean-shift tracker state: target histogram + current box. + */ +typedef struct { + float template_hist[CV_HIST_MAX_BINS]; /**< Normalized target histogram. */ + uint32_t hist_nbins; /**< Bins in use (32 gray / 96 RGB565). */ + int32_t box_width; /**< Tracked box width in pixels. */ + int32_t box_height; /**< Tracked box height in pixels. */ + int32_t center_x; /**< Current box center X. */ + int32_t center_y; /**< Current box center Y. */ + uint16_t max_iters; /**< Mode-seek iteration cap (default 5). */ + bool initialized; /**< True once cv_meanshift_init succeeded. */ +} CvMeanShiftState; + +/** + * @brief Initialize a mean-shift tracker from a target ROI in @p frame. + * + * Builds the target's normalized histogram (via cv_hist_build) and stores + * the initial box center/size. @p max_iters defaults to 5. + * + * @param[out] state Tracker state to initialize. + * @param[in] frame Source frame (GRAYSCALE/MASK U8, or RGB565 U16). + * @param[in] roi Initial target box; must have positive width/height. + * @return EMBEDDIP_OK on success; EMBEDDIP_ERROR_NULL_PTR if state/frame is + * NULL; EMBEDDIP_ERROR_INVALID_FORMAT if the frame format/depth is + * unsupported; EMBEDDIP_ERROR_INVALID_SIZE if roi is empty/out of + * bounds. + */ +embeddip_status_t cv_meanshift_init(CvMeanShiftState *state, const ImageView *frame, Rectangle roi); + +/** + * @brief Advance the tracker one frame via classic mean-shift mode-seeking. + * + * Iterates up to state->max_iters times: backprojects the target histogram + * over a search box centered on the current position (padded to twice the + * box size, clamped to the frame, so a shift up to one box dimension can be + * recovered), weights each pixel by sqrtf(template_hist[bin]), and moves the + * center to the weighted centroid. Stops early once the shift is < 1 px. + * The box is re-clamped so it stays inside the frame after each move. + * + * @param[in,out] state Tracker state (center is updated in place). + * @param[in] frame Current frame; same format as used in cv_meanshift_init. + * @param[out] out_box Converged box (center ± size/2). + * @return EMBEDDIP_OK on success; EMBEDDIP_ERROR_NULL_PTR if state/frame/ + * out_box is NULL; EMBEDDIP_ERROR_NOT_INITIALIZED if state was not + * initialized. + */ +embeddip_status_t cv_meanshift_update(CvMeanShiftState *state, const ImageView *frame, + Rectangle *out_box); + +#ifdef __cplusplus +} +#endif + +#endif /* EMBEDDIP_CV_TRACKER_MEANSHIFT_H */ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d47f4ff..6f70ec9 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -54,6 +54,10 @@ add_executable(embeddip_test_cv_tracker_particle test_cv_tracker_particle.c) target_link_libraries(embeddip_test_cv_tracker_particle PRIVATE embedDIP) add_test(NAME embeddip.cv_tracker_particle COMMAND embeddip_test_cv_tracker_particle) +add_executable(embeddip_test_cv_tracker_meanshift test_cv_tracker_meanshift.c) +target_link_libraries(embeddip_test_cv_tracker_meanshift PRIVATE embedDIP) +add_test(NAME embeddip.cv_tracker_meanshift COMMAND embeddip_test_cv_tracker_meanshift) + add_executable(embeddip_test_runtime test_runtime.c) target_link_libraries(embeddip_test_runtime PRIVATE embedDIP) add_test(NAME embeddip.runtime COMMAND embeddip_test_runtime) diff --git a/tests/test_cv_tracker_meanshift.c b/tests/test_cv_tracker_meanshift.c new file mode 100644 index 0000000..9389483 --- /dev/null +++ b/tests/test_cv_tracker_meanshift.c @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +#include +#include +#include +#include + +int main(void){ + enum { W=128, H=128 }; + static uint8_t px[W*H]; + CvMeanShiftState st; memset(&st,0,sizeof st); + memset(px,0,sizeof px); + for(int y=40;y<60;y++) for(int x=40;x<60;x++) px[y*W+x]=200; + ImageView f={px,W,H,W,IMAGE_FORMAT_GRAYSCALE,IMAGE_DEPTH_U8,0,0}; + Rectangle roi={40,40,20,20}; + assert(cv_meanshift_init(NULL,&f,roi)==EMBEDDIP_ERROR_NULL_PTR); + assert(cv_meanshift_init(&st,&f,roi)==EMBEDDIP_OK); + Rectangle out; + /* shift block by +8,+8 */ + memset(px,0,sizeof px); + for(int y=48;y<68;y++) for(int x=48;x<68;x++) px[y*W+x]=200; + assert(cv_meanshift_update(&st,&f,&out)==EMBEDDIP_OK); + int cx=out.x+out.width/2, cy=out.y+out.height/2; + assert(cx>=44 && cx<=64 && cy>=44 && cy<=64); /* tracked toward (58,58) */ + + /* ROI partially outside the frame: geometry must clamp to fit. */ + CvMeanShiftState st2; memset(&st2,0,sizeof st2); + Rectangle partial_roi={120,120,20,20}; + assert(cv_meanshift_init(&st2,&f,partial_roi)==EMBEDDIP_OK); + assert(st2.box_width<=W-120 && st2.box_height<=H-120); + assert(st2.center_x - st2.box_width/2 >= 0 && st2.center_x + st2.box_width/2 <= W); + assert(st2.center_y - st2.box_height/2 >= 0 && st2.center_y + st2.box_height/2 <= H); + + /* ROI fully outside the frame: init must fail. */ + CvMeanShiftState st3; memset(&st3,0,sizeof st3); + Rectangle outside_roi={200,200,20,20}; + assert(cv_meanshift_init(&st3,&f,outside_roi)==EMBEDDIP_ERROR_INVALID_SIZE); + + /* update() must reject an unsupported format even if init'd on a supported one. */ + static uint8_t px888[W*H*3]; + ImageView f_rgb888={px888,W,H,W*3,IMAGE_FORMAT_RGB888,IMAGE_DEPTH_U8,0,0}; + Rectangle out2; + assert(cv_meanshift_update(&st,&f_rgb888,&out2)==EMBEDDIP_ERROR_INVALID_FORMAT); + + return 0; +} From 0d777754e394b2dd007b9b823bc9b6f734b87f0b Mon Sep 17 00:00:00 2001 From: Ozan Durgut Date: Sat, 5 Sep 2026 14:04:32 +0200 Subject: [PATCH 7/9] add: KCF correlation tracker with online adaptation Signed-off-by: Ozan Durgut --- cv/tracker_kcf.c | 311 ++++++++++++++++++++++++++++++++++++ cv/tracker_kcf.h | 121 ++++++++++++++ tests/CMakeLists.txt | 4 + tests/test_cv_tracker_kcf.c | 141 ++++++++++++++++ 4 files changed, 577 insertions(+) create mode 100644 cv/tracker_kcf.c create mode 100644 cv/tracker_kcf.h create mode 100644 tests/test_cv_tracker_kcf.c diff --git a/cv/tracker_kcf.c b/cv/tracker_kcf.c new file mode 100644 index 0000000..84b1686 --- /dev/null +++ b/cv/tracker_kcf.c @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#include "cv/tracker_kcf.h" + +#include +#include + +#include "board/common.h" +#include "imgproc/fft.h" + +static bool kcf_format_ok(ImageFormat fmt) +{ + return fmt == IMAGE_FORMAT_GRAYSCALE || fmt == IMAGE_FORMAT_MASK; +} + +/* Crop and nearest-neighbor resample a src region to + * CV_KCF_PATCH_SIZE x CV_KCF_PATCH_SIZE, writing into a heap Image's + * pixels buffer (fft() reads src->pixels as uint8_t regardless of + * ImageFormat). */ +static void kcf_extract_patch(const ImageView *src, Rectangle roi, uint8_t *out_pixels) +{ + for (uint32_t py = 0u; py < CV_KCF_PATCH_SIZE; ++py) { + int32_t sy = roi.y + (int32_t)((int64_t)py * roi.height / CV_KCF_PATCH_SIZE); + const uint8_t *row = src->pixels + (size_t)sy * src->row_stride_bytes; + for (uint32_t px = 0u; px < CV_KCF_PATCH_SIZE; ++px) { + int32_t sx = roi.x + (int32_t)((int64_t)px * roi.width / CV_KCF_PATCH_SIZE); + out_pixels[py * CV_KCF_PATCH_SIZE + px] = row[sx]; + } + } +} + +/* Build a heap Image holding a CV_KCF_PATCH_SIZE^2 grayscale patch sampled + * from src's roi. Caller must deleteImage() the result. */ +static embeddip_status_t kcf_make_patch_image(const ImageView *src, Rectangle roi, + Image **out_patch) +{ + Image *patch = NULL; + embeddip_status_t status = + createImageWH((int)CV_KCF_PATCH_SIZE, (int)CV_KCF_PATCH_SIZE, IMAGE_FORMAT_GRAYSCALE, &patch); + if (status != EMBEDDIP_OK) { + return status; + } + kcf_extract_patch(src, roi, (uint8_t *)patch->pixels); + *out_patch = patch; + return EMBEDDIP_OK; +} + +/* Multiply search-patch spectrum by the conjugate of the template spectrum, + * writing an already-chals-allocated complex spectrum Image (corr->chals + * must be pre-allocated via createChalsComplex(corr, 2) by the caller). + * + * fft() writes interleaved complex data (re,im,re,im,...) into ch[0] and + * then copies the whole interleaved buffer into ch[1] too, so ch[0] and + * ch[1] are identical 2*num_bins-float interleaved buffers on both inputs + * (imgproc/fft.c:50-77). ifft() (when src->log == IMAGE_DATA_COMPLEX) reads + * its input as an interleaved buffer from ch[1] (imgproc/fft.c:99), so the + * output here must be written as interleaved complex into both ch[0] and + * ch[1] to match that shape. */ +static void kcf_conj_multiply(const Image *search_spec, const Image *template_spec, Image *corr) +{ + uint32_t num_bins = search_spec->width * search_spec->height; + const float *a = search_spec->chals->ch[0]; + const float *b = template_spec->chals->ch[0]; + float *out0 = corr->chals->ch[0]; + float *out1 = corr->chals->ch[1]; + + for (uint32_t i = 0u; i < num_bins; ++i) { + float a_re = a[2u * i]; + float a_im = a[2u * i + 1u]; + float b_re = b[2u * i]; + float b_im = b[2u * i + 1u]; + /* (a) * conj(b) = (a_re*b_re + a_im*b_im) + j(a_im*b_re - a_re*b_im) */ + float re = a_re * b_re + a_im * b_im; + float im = a_im * b_re - a_re * b_im; + out0[2u * i] = re; + out0[2u * i + 1u] = im; + out1[2u * i] = re; + out1[2u * i + 1u] = im; + } + /* Zero the DC bin (i=0): both patches carry a large constant background + * level (mostly-zero pixels with a small foreground block), so the DC + * term's conjugate product is orders of magnitude larger than every AC + * bin. At float32 precision this swamps the AC terms entirely, so the + * inverse transform collapses to a near-constant image and the peak + * search becomes meaningless. Dropping DC (a standard trick for + * correlation-based trackers, equivalent to correlating mean-subtracted + * patches) lets the actual shape/position information dominate. */ + out0[0] = 0.0f; + out0[1] = 0.0f; + out1[0] = 0.0f; + out1[1] = 0.0f; + corr->log = IMAGE_DATA_COMPLEX; +} + +/* Find the index of the largest real correlation value, then convert its + * circular position into a signed (dx,dy) pixel offset in patch space + * (values in [0, N/2) map to positive offsets, [N/2, N) wrap to negative). */ +/* Build a box_width x box_height roi centered at (cx,cy), clamped to stay + * inside frame bounds (same clamp policy cv_kcf_update already applies to + * its search roi). */ +static Rectangle kcf_clamped_roi(int32_t cx, int32_t cy, int32_t box_width, int32_t box_height, + const ImageView *frame) +{ + Rectangle roi = {cx - box_width / 2, cy - box_height / 2, box_width, box_height}; + if (roi.x < 0) { + roi.x = 0; + } + if (roi.y < 0) { + roi.y = 0; + } + if ((uint32_t)(roi.x + roi.width) > frame->width) { + roi.x = (int32_t)frame->width - roi.width; + } + if ((uint32_t)(roi.y + roi.height) > frame->height) { + roi.y = (int32_t)frame->height - roi.height; + } + return roi; +} + +static void kcf_find_peak_offset(const Image *corr_time, int32_t *out_dx, int32_t *out_dy) +{ + uint32_t n = corr_time->width; + const float *data = corr_time->chals->ch[0]; + uint32_t best_idx = 0u; + float best_val = data[0]; + for (uint32_t i = 1u; i < n * n; ++i) { + if (data[i] > best_val) { + best_val = data[i]; + best_idx = i; + } + } + int32_t x = (int32_t)(best_idx % n); + int32_t y = (int32_t)(best_idx / n); + int32_t half = (int32_t)n / 2; + *out_dx = (x >= half) ? (x - (int32_t)n) : x; + *out_dy = (y >= half) ? (y - (int32_t)n) : y; +} + +embeddip_status_t cv_kcf_init(CvKcfState *state, const ImageView *src, Rectangle roi) +{ + if (state == NULL || src == NULL || src->pixels == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (!kcf_format_ok(src->format)) { + return EMBEDDIP_ERROR_INVALID_FORMAT; + } + if (roi.width <= 0 || roi.height <= 0 || roi.x < 0 || roi.y < 0 || + (uint32_t)(roi.x + roi.width) > src->width || + (uint32_t)(roi.y + roi.height) > src->height) { + return EMBEDDIP_ERROR_INVALID_SIZE; + } + + memset(state, 0, sizeof(*state)); + + Image *patch = NULL; + embeddip_status_t status = kcf_make_patch_image(src, roi, &patch); + if (status != EMBEDDIP_OK) { + return status; + } + + Image *spectrum = NULL; + status = createImageWH((int)CV_KCF_PATCH_SIZE, (int)CV_KCF_PATCH_SIZE, IMAGE_FORMAT_GRAYSCALE, + &spectrum); + if (status != EMBEDDIP_OK) { + deleteImage(patch); + return status; + } + + status = fft(patch, spectrum); + deleteImage(patch); + if (status != EMBEDDIP_OK) { + deleteImage(spectrum); + return status; + } + state->template_spectrum = spectrum; + + /* Preallocate every per-frame scratch buffer cv_kcf_update will reuse + * (see CvKcfState doc comment). On any failure below, release whatever + * was already allocated (including template_spectrum above) and bail. */ + status = createImageWH((int)CV_KCF_PATCH_SIZE, (int)CV_KCF_PATCH_SIZE, IMAGE_FORMAT_GRAYSCALE, + &state->search_patch); + if (status == EMBEDDIP_OK) { + status = createImageWH((int)CV_KCF_PATCH_SIZE, (int)CV_KCF_PATCH_SIZE, + IMAGE_FORMAT_GRAYSCALE, &state->search_spectrum); + } + if (status == EMBEDDIP_OK) { + status = createImageWH((int)CV_KCF_PATCH_SIZE, (int)CV_KCF_PATCH_SIZE, + IMAGE_FORMAT_GRAYSCALE, &state->corr_spectrum); + } + if (status == EMBEDDIP_OK) { + status = createChalsComplex(state->corr_spectrum, 2u); + } + if (status == EMBEDDIP_OK) { + status = createImageWH((int)CV_KCF_PATCH_SIZE, (int)CV_KCF_PATCH_SIZE, + IMAGE_FORMAT_GRAYSCALE, &state->corr_time); + } + if (status == EMBEDDIP_OK) { + status = createImageWH((int)CV_KCF_PATCH_SIZE, (int)CV_KCF_PATCH_SIZE, + IMAGE_FORMAT_GRAYSCALE, &state->adapt_patch); + } + if (status == EMBEDDIP_OK) { + status = createImageWH((int)CV_KCF_PATCH_SIZE, (int)CV_KCF_PATCH_SIZE, + IMAGE_FORMAT_GRAYSCALE, &state->adapt_spectrum); + } + if (status != EMBEDDIP_OK) { + cv_kcf_free(state); + return status; + } + + state->box_width = roi.width; + state->box_height = roi.height; + state->center_x = roi.x + roi.width / 2; + state->center_y = roi.y + roi.height / 2; + state->initialized = true; + state->learn_rate = 0.075f; + return EMBEDDIP_OK; +} + +embeddip_status_t cv_kcf_update(CvKcfState *state, const ImageView *frame, Rectangle *out_box) +{ + if (state == NULL || frame == NULL || out_box == NULL || frame->pixels == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (!state->initialized) { + return EMBEDDIP_ERROR_NOT_INITIALIZED; + } + if (!kcf_format_ok(frame->format)) { + return EMBEDDIP_ERROR_INVALID_FORMAT; + } + + Rectangle search_roi = + kcf_clamped_roi(state->center_x, state->center_y, state->box_width, state->box_height, frame); + + /* All buffers below are preallocated once by cv_kcf_init and reused + * every frame (see CvKcfState doc comment) — no create/delete here, so + * this function performs zero heap traffic per call. Each producer + * (kcf_extract_patch, fft, ifft, kcf_conj_multiply) fully overwrites + * every element of its output on every call, so no per-frame clear is + * needed before reuse. */ + kcf_extract_patch(frame, search_roi, (uint8_t *)state->search_patch->pixels); + + embeddip_status_t status = fft(state->search_patch, state->search_spectrum); + if (status != EMBEDDIP_OK) { + return status; + } + + kcf_conj_multiply(state->search_spectrum, state->template_spectrum, state->corr_spectrum); + + status = ifft(state->corr_spectrum, state->corr_time); + if (status != EMBEDDIP_OK) { + return status; + } + + int32_t peak_dx = 0; + int32_t peak_dy = 0; + kcf_find_peak_offset(state->corr_time, &peak_dx, &peak_dy); + + int32_t offset_x = peak_dx * search_roi.width / (int32_t)CV_KCF_PATCH_SIZE; + int32_t offset_y = peak_dy * search_roi.height / (int32_t)CV_KCF_PATCH_SIZE; + + state->center_x = search_roi.x + search_roi.width / 2 + offset_x; + state->center_y = search_roi.y + search_roi.height / 2 + offset_y; + + if (state->learn_rate > 0.0f) { + Rectangle new_roi = kcf_clamped_roi(state->center_x, state->center_y, state->box_width, + state->box_height, frame); + + kcf_extract_patch(frame, new_roi, (uint8_t *)state->adapt_patch->pixels); + status = fft(state->adapt_patch, state->adapt_spectrum); + if (status == EMBEDDIP_OK) { + float eta = state->learn_rate; + uint32_t num_floats = 2u * state->adapt_spectrum->width * state->adapt_spectrum->height; + /* Only ch[0] is ever read (kcf_conj_multiply above); ch[1] is a + * redundant copy fft() makes (see its comment), so blending it + * too would just be wasted work. */ + float *tmpl = state->template_spectrum->chals->ch[0]; + const float *fresh = state->adapt_spectrum->chals->ch[0]; + for (uint32_t i = 0u; i < num_floats; ++i) { + tmpl[i] = (1.0f - eta) * tmpl[i] + eta * fresh[i]; + } + } + } + + out_box->x = state->center_x - state->box_width / 2; + out_box->y = state->center_y - state->box_height / 2; + out_box->width = state->box_width; + out_box->height = state->box_height; + return EMBEDDIP_OK; +} + +embeddip_status_t cv_kcf_free(CvKcfState *state) +{ + if (state == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + + Image **owned[] = { + &state->template_spectrum, &state->search_patch, &state->search_spectrum, + &state->corr_spectrum, &state->corr_time, &state->adapt_patch, + &state->adapt_spectrum, + }; + for (size_t i = 0u; i < sizeof(owned) / sizeof(owned[0]); ++i) { + if (*owned[i] != NULL) { + deleteImage(*owned[i]); + *owned[i] = NULL; + } + } + state->initialized = false; + return EMBEDDIP_OK; +} diff --git a/cv/tracker_kcf.h b/cv/tracker_kcf.h new file mode 100644 index 0000000..ca994cc --- /dev/null +++ b/cv/tracker_kcf.h @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#ifndef EMBEDDIP_CV_TRACKER_KCF_H +#define EMBEDDIP_CV_TRACKER_KCF_H + +#include +#include + +#include "core/error.h" +#include "core/image.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** Fixed patch edge length in pixels. Must be a power of 2 to satisfy + * imgproc/fft.c's isValidFFTSize check. */ +#define CV_KCF_PATCH_SIZE 64u + +/** + * @brief KCF (Kernel Correlation Filter) tracker state. + * + * `template_spectrum` is a heap-allocated Image owned by this state, holding + * the forward FFT of the learned template patch (real/imag interleaved via + * imgproc/fft.c's Image.chals convention: ch[0]=real, ch[1]=imag). + * + * The remaining Image* fields are per-frame scratch buffers used by + * cv_kcf_update. They are all fixed CV_KCF_PATCH_SIZE x CV_KCF_PATCH_SIZE and + * are allocated exactly once (in cv_kcf_init) and reused every call to + * cv_kcf_update, instead of being alloc/freed per frame — some board + * allocators (e.g. a bump allocator with a no-op free) never reclaim freed + * memory, so per-frame alloc/free would exhaust the heap after one frame. + * Every producer of these buffers (kcf_extract_patch, fft(), ifft(), + * kcf_conj_multiply) fully overwrites every element on each call, so no + * per-frame clear is needed between reuses. + * + * - search_patch: grayscale patch sampled from the current search roi. + * - search_spectrum: forward FFT of search_patch. + * - corr_spectrum: search_spectrum * conj(template_spectrum). + * - corr_time: inverse FFT of corr_spectrum (peak is searched here). + * - adapt_patch: grayscale patch sampled at the newly tracked roi, used + * only for online template adaptation (learn_rate > 0). + * - adapt_spectrum: forward FFT of adapt_patch, blended into + * template_spectrum. + * + * All of the above are allocated in cv_kcf_init and released by + * cv_kcf_free. + */ +typedef struct { + Image *template_spectrum; + Image *search_patch; + Image *search_spectrum; + Image *corr_spectrum; + Image *corr_time; + Image *adapt_patch; + Image *adapt_spectrum; + int32_t box_width; + int32_t box_height; + int32_t center_x; + int32_t center_y; + bool initialized; + /** Online template adaptation rate in [0,1]; 0 disables adaptation + * (template stays fixed at the init spectrum, matching the original + * behavior). Set to 0.075f by cv_kcf_init. */ + float learn_rate; +} CvKcfState; + +/** + * @brief Initialize the tracker: extract and store a template patch's spectrum. + * + * @param[out] state Tracker state to initialize. + * @param[in] src Grayscale (or mask) image view to sample the template from. + * @param[in] roi Initial bounding box; the patch is resampled/cropped to + * CV_KCF_PATCH_SIZE x CV_KCF_PATCH_SIZE around its center. + * + * Also preallocates every scratch Image cv_kcf_update will reuse per frame + * (see CvKcfState); on any allocation failure, everything allocated so far + * is released before returning. + * + * @return EMBEDDIP_OK on success, EMBEDDIP_ERROR_NULL_PTR if state or src is + * NULL, EMBEDDIP_ERROR_INVALID_FORMAT if src is not grayscale/mask, + * EMBEDDIP_ERROR_INVALID_SIZE if roi is out of bounds, + * EMBEDDIP_ERROR_OUT_OF_MEMORY if the spectrum Image or any scratch + * Image cannot be allocated. + */ +embeddip_status_t cv_kcf_init(CvKcfState *state, const ImageView *src, Rectangle roi); + +/** + * @brief Locate the tracked object in a new frame via correlation-filter response. + * + * @param[in,out] state Tracker state (template updated in place after match). + * @param[in] frame Grayscale (or mask) image view to search. + * @param[out] out_box New bounding box (same size as init roi). + * @return EMBEDDIP_OK on success, EMBEDDIP_ERROR_NULL_PTR if state, frame, or + * out_box is NULL, EMBEDDIP_ERROR_NOT_INITIALIZED if cv_kcf_init was + * not called first, EMBEDDIP_ERROR_INVALID_FORMAT if frame is not + * grayscale/mask. Allocates no memory: all scratch Images were + * preallocated by cv_kcf_init and are reused every call. + */ +embeddip_status_t cv_kcf_update(CvKcfState *state, const ImageView *frame, Rectangle *out_box); + +/** + * @brief Release all heap Images owned by state (template_spectrum plus the + * per-frame scratch buffers preallocated by cv_kcf_init). + * + * Safe to call on a zero-initialized, partially-initialized, or + * already-freed state (each Image* is only deleted, and set back to NULL, + * if non-NULL). Must be called before the state goes out of scope to avoid + * leaking the owned Images' chals/pixels buffers. + * + * @param[in,out] state Tracker state to release. + * @return EMBEDDIP_OK always (EMBEDDIP_ERROR_NULL_PTR if state is NULL). + */ +embeddip_status_t cv_kcf_free(CvKcfState *state); + +#ifdef __cplusplus +} +#endif + +#endif /* EMBEDDIP_CV_TRACKER_KCF_H */ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6f70ec9..554bcb3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -58,6 +58,10 @@ add_executable(embeddip_test_cv_tracker_meanshift test_cv_tracker_meanshift.c) target_link_libraries(embeddip_test_cv_tracker_meanshift PRIVATE embedDIP) add_test(NAME embeddip.cv_tracker_meanshift COMMAND embeddip_test_cv_tracker_meanshift) +add_executable(embeddip_test_cv_tracker_kcf test_cv_tracker_kcf.c) +target_link_libraries(embeddip_test_cv_tracker_kcf PRIVATE embedDIP) +add_test(NAME embeddip.cv_tracker_kcf COMMAND embeddip_test_cv_tracker_kcf) + add_executable(embeddip_test_runtime test_runtime.c) target_link_libraries(embeddip_test_runtime PRIVATE embedDIP) add_test(NAME embeddip.runtime COMMAND embeddip_test_runtime) diff --git a/tests/test_cv_tracker_kcf.c b/tests/test_cv_tracker_kcf.c new file mode 100644 index 0000000..d03367f --- /dev/null +++ b/tests/test_cv_tracker_kcf.c @@ -0,0 +1,141 @@ +#include +#include +#include +#include +#include + +#include +#include + +#define FRAME_W 128u +#define FRAME_H 128u + +static void fill_frame(uint8_t *pixels, int32_t block_x, int32_t block_y) +{ + memset(pixels, 0u, FRAME_W * FRAME_H); + for (int32_t y = block_y; y < block_y + 20; ++y) { + for (int32_t x = block_x; x < block_x + 20; ++x) { + pixels[y * (int32_t)FRAME_W + x] = 255u; + } + } +} + +static void test_init_null(void) +{ + Rectangle roi = {40, 40, 20, 20}; + assert(cv_kcf_init(NULL, NULL, roi) == EMBEDDIP_ERROR_NULL_PTR); +} + +static void test_update_without_init(void) +{ + CvKcfState state; + memset(&state, 0, sizeof(state)); + uint8_t pixels[FRAME_W * FRAME_H]; + fill_frame(pixels, 40, 40); + ImageView frame = {.pixels = pixels, + .width = FRAME_W, + .height = FRAME_H, + .row_stride_bytes = FRAME_W, + .format = IMAGE_FORMAT_GRAYSCALE, + .depth = IMAGE_DEPTH_U8}; + Rectangle out; + assert(cv_kcf_update(&state, &frame, &out) == EMBEDDIP_ERROR_NOT_INITIALIZED); +} + +static void test_init_and_update_stationary(void) +{ + uint8_t pixels[FRAME_W * FRAME_H]; + fill_frame(pixels, 40, 40); + ImageView view = {.pixels = pixels, + .width = FRAME_W, + .height = FRAME_H, + .row_stride_bytes = FRAME_W, + .format = IMAGE_FORMAT_GRAYSCALE, + .depth = IMAGE_DEPTH_U8}; + Rectangle roi = {40, 40, 20, 20}; + + CvKcfState state; + assert(cv_kcf_init(&state, &view, roi) == EMBEDDIP_OK); + + Rectangle out; + assert(cv_kcf_update(&state, &view, &out) == EMBEDDIP_OK); + /* Stationary target: the recovered box should stay near the original. */ + assert(out.width == 20 && out.height == 20); + assert(out.x > 20 && out.x < 60); + assert(out.y > 20 && out.y < 60); + + assert(cv_kcf_free(&state) == EMBEDDIP_OK); +} + +/* Target moves between init and update: verify the tracker actually follows + * the motion, not just reports a stationary/no-op box. */ +static void test_init_and_update_moving(void) +{ + uint8_t pixels[FRAME_W * FRAME_H]; + fill_frame(pixels, 40, 40); + ImageView view = {.pixels = pixels, + .width = FRAME_W, + .height = FRAME_H, + .row_stride_bytes = FRAME_W, + .format = IMAGE_FORMAT_GRAYSCALE, + .depth = IMAGE_DEPTH_U8}; + /* ROI is larger than the 20x20 block and includes surrounding + * background, so the template patch has real edge structure to + * correlate on (a ROI exactly matching the block would sample a + * uniform white square with nothing to distinguish position). */ + Rectangle roi = {20, 20, 60, 60}; + + CvKcfState state; + assert(cv_kcf_init(&state, &view, roi) == EMBEDDIP_OK); + + /* Move the block 10px right and down. */ + fill_frame(pixels, 50, 50); + + Rectangle out; + assert(cv_kcf_update(&state, &view, &out) == EMBEDDIP_OK); + assert(out.width == 60 && out.height == 60); + /* Tracker must move toward the new block location, not stay at the old + * box (x=20..80,y=20..80) or report a no-op. */ + assert(out.x > 24 && out.x < 36); + assert(out.y > 24 && out.y < 36); + + assert(cv_kcf_free(&state) == EMBEDDIP_OK); +} + +static void test_online_update_survives_appearance_drift(void) +{ + /* target block whose intensity ramps down over frames while translating; + * with adaptation the peak stays locked, tracked center keeps up. */ + static uint8_t px[FRAME_W * FRAME_H]; + CvKcfState st; + memset(&st, 0, sizeof st); + Rectangle roi = {40, 40, 20, 20}; + /* init frame: bright block at 40,40 */ + fill_frame(px, 40, 40); + ImageView f = {px, FRAME_W, FRAME_H, FRAME_W, IMAGE_FORMAT_GRAYSCALE, IMAGE_DEPTH_U8, 0, 0}; + assert(cv_kcf_init(&st, &f, roi) == EMBEDDIP_OK); + assert(st.learn_rate > 0.0f); /* adaptation on by default */ + Rectangle out; + int bx = 40; + for (int k = 0; k < 5; k++) { + bx += 6; + memset(px, 0, sizeof px); + int val = 255 - k * 30; /* appearance drifts */ + for (int y = 40; y < 60; y++) + for (int x = bx; x < bx + 20; x++) px[y * FRAME_W + x] = (uint8_t)val; + assert(cv_kcf_update(&st, &f, &out) == EMBEDDIP_OK); + } + int cx = out.x + out.width / 2; + assert(cx > 55); /* followed the moving block */ + cv_kcf_free(&st); +} + +int main(void) +{ + test_init_null(); + test_update_without_init(); + test_init_and_update_stationary(); + test_init_and_update_moving(); + test_online_update_survives_appearance_drift(); + return 0; +} From 57f3ee4876429ed4885882d4eaca7cde561563aa Mon Sep 17 00:00:00 2001 From: Ozan Durgut Date: Sat, 5 Sep 2026 14:05:02 +0200 Subject: [PATCH 8/9] cv: SORT-style IoU/Kalman track association Signed-off-by: Ozan Durgut --- cv/track_assoc.c | 152 ++++++++++++++++++++++++++++++++++++ cv/track_assoc.h | 84 ++++++++++++++++++++ tests/CMakeLists.txt | 4 + tests/test_cv_track_assoc.c | 47 +++++++++++ 4 files changed, 287 insertions(+) create mode 100644 cv/track_assoc.c create mode 100644 cv/track_assoc.h create mode 100644 tests/test_cv_track_assoc.c diff --git a/cv/track_assoc.c b/cv/track_assoc.c new file mode 100644 index 0000000..9e207e0 --- /dev/null +++ b/cv/track_assoc.c @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#include "cv/track_assoc.h" + +#include + +float cv_track_assoc_iou(Rectangle a, Rectangle b) +{ + int32_t ix0 = a.x > b.x ? a.x : b.x; + int32_t iy0 = a.y > b.y ? a.y : b.y; + int32_t ix1 = (a.x + a.width) < (b.x + b.width) ? (a.x + a.width) : (b.x + b.width); + int32_t iy1 = (a.y + a.height) < (b.y + b.height) ? (a.y + a.height) : (b.y + b.height); + + int32_t iw = ix1 - ix0; + int32_t ih = iy1 - iy0; + if (iw <= 0 || ih <= 0) { + return 0.0f; + } + + float inter = (float)iw * (float)ih; + float area_a = (float)a.width * (float)a.height; + float area_b = (float)b.width * (float)b.height; + float uni = area_a + area_b - inter; + if (uni <= 0.0f) { + return 0.0f; + } + + return inter / uni; +} + +embeddip_status_t cv_track_assoc_init(CvTrackAssoc *t, float iou_threshold, uint16_t max_misses) +{ + if (t == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + + memset(t, 0, sizeof(*t)); + t->next_id = 0; + t->iou_threshold = iou_threshold; + t->max_misses = max_misses; + + return EMBEDDIP_OK; +} + +embeddip_status_t cv_track_assoc_step(CvTrackAssoc *t, const CvDetection *dets, size_t det_count) +{ + if (t == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + if (det_count > 0 && dets == NULL) { + return EMBEDDIP_ERROR_NULL_PTR; + } + + /* 1. Predict all active tracks. */ + for (size_t i = 0; i < CV_TRACK_ASSOC_MAX; i++) { + CvTrack *trk = &t->tracks[i]; + if (!trk->active) { + continue; + } + embeddip_status_t st = cv_kalman_predict(&trk->kf, &trk->box); + if (embeddip_failed(st)) { + return st; + } + } + + /* 2. Greedy IoU matching: repeatedly pick the highest-IoU unused pair. */ + bool track_matched[CV_TRACK_ASSOC_MAX] = { false }; + /* ponytail: fixed local buffer sized to CV_TRACK_ASSOC_MAX detections per frame; + * detections beyond that limit are ignored this frame (neither matched nor + * spawned) since the spawn loop below is bounded by the same match_det_limit. + * Bump if a use case needs more per-frame dets. */ + bool det_matched[CV_TRACK_ASSOC_MAX] = { false }; + size_t match_det_limit = det_count < CV_TRACK_ASSOC_MAX ? det_count : CV_TRACK_ASSOC_MAX; + + for (;;) { + float best_iou = 0.0f; + size_t best_trk = CV_TRACK_ASSOC_MAX; + size_t best_det = CV_TRACK_ASSOC_MAX; + + for (size_t i = 0; i < CV_TRACK_ASSOC_MAX; i++) { + if (!t->tracks[i].active || track_matched[i]) { + continue; + } + for (size_t j = 0; j < match_det_limit; j++) { + if (det_matched[j]) { + continue; + } + float iou = cv_track_assoc_iou(t->tracks[i].box, dets[j].box); + if (iou >= t->iou_threshold && iou > best_iou) { + best_iou = iou; + best_trk = i; + best_det = j; + } + } + } + + if (best_trk == CV_TRACK_ASSOC_MAX) { + break; /* no more eligible pairs */ + } + + track_matched[best_trk] = true; + det_matched[best_det] = true; + + CvTrack *trk = &t->tracks[best_trk]; + embeddip_status_t st = cv_kalman_update(&trk->kf, dets[best_det].box); + if (embeddip_failed(st)) { + return st; + } + trk->box = dets[best_det].box; + trk->misses = 0; + trk->age++; + } + + /* 3. Unmatched detections: spawn new tracks into free slots. */ + for (size_t j = 0; j < match_det_limit; j++) { + if (det_matched[j]) { + continue; + } + for (size_t i = 0; i < CV_TRACK_ASSOC_MAX; i++) { + CvTrack *trk = &t->tracks[i]; + if (trk->active) { + continue; + } + embeddip_status_t st = cv_kalman_init(&trk->kf, dets[j].box); + if (embeddip_failed(st)) { + break; + } + trk->box = dets[j].box; + trk->id = t->next_id++; + trk->active = true; + trk->age = 0; + trk->misses = 0; + break; + } + /* No free slot: detection is silently dropped. */ + } + + /* 4. Unmatched tracks: age misses, drop if past the threshold. */ + for (size_t i = 0; i < CV_TRACK_ASSOC_MAX; i++) { + CvTrack *trk = &t->tracks[i]; + if (!trk->active || track_matched[i]) { + continue; + } + trk->misses++; + if (trk->misses > t->max_misses) { + trk->active = false; + } + } + + return EMBEDDIP_OK; +} diff --git a/cv/track_assoc.h b/cv/track_assoc.h new file mode 100644 index 0000000..097069f --- /dev/null +++ b/cv/track_assoc.h @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 EmbedDIP + +#ifndef EMBEDDIP_CV_TRACK_ASSOC_H +#define EMBEDDIP_CV_TRACK_ASSOC_H + +#include +#include +#include + +#include "core/error.h" +#include "core/image.h" +#include "cv/detect.h" +#include "cv/tracker_kalman.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Maximum number of simultaneously tracked objects. */ +#define CV_TRACK_ASSOC_MAX 16u + +/** + * @brief One SORT-style track: a Kalman filter plus bookkeeping. + */ +typedef struct { + CvKalmanState kf; /**< Backing Kalman filter for this track. */ + Rectangle box; /**< Last estimate (predicted or corrected). */ + int32_t id; /**< Stable track id, >= 0. */ + uint16_t age; /**< Frames since spawn. */ + uint16_t misses; /**< Consecutive frames without a match. */ + bool active; /**< Whether this slot holds a live track. */ +} CvTrack; + +/** + * @brief Fixed-capacity set of tracks with greedy IoU association state. + */ +typedef struct { + CvTrack tracks[CV_TRACK_ASSOC_MAX]; /**< Track slots (active/inactive). */ + int32_t next_id; /**< Next id to assign to a spawned track. */ + float iou_threshold; /**< Match if IoU >= this (e.g. 0.3). */ + uint16_t max_misses; /**< Drop track after this many misses (e.g. 5). */ +} CvTrackAssoc; + +/** + * @brief Initialize a tracker with empty (inactive) track slots. + * + * @param[out] t Tracker to initialize. + * @param[in] iou_threshold Minimum IoU to consider a track/detection matched. + * @param[in] max_misses Consecutive missed frames after which a track is dropped. + * @return EMBEDDIP_OK on success, EMBEDDIP_ERROR_NULL_PTR if t is NULL. + */ +embeddip_status_t cv_track_assoc_init(CvTrackAssoc *t, float iou_threshold, uint16_t max_misses); + +/** + * @brief Advance the tracker by one frame. + * + * Predicts all active tracks, greedily matches them to detections by IoU, + * corrects matched tracks, spawns new tracks for unmatched detections, and + * ages/drops tracks that went unmatched. + * + * @param[in,out] t Tracker to advance. + * @param[in] dets Detections for this frame (may be NULL if det_count is 0). + * @param[in] det_count Number of detections in @p dets. + * @return EMBEDDIP_OK on success, EMBEDDIP_ERROR_NULL_PTR if t is NULL, or + * an error code propagated from the underlying Kalman filter calls. + */ +embeddip_status_t cv_track_assoc_step(CvTrackAssoc *t, const CvDetection *dets, size_t det_count); + +/** + * @brief Intersection-over-union of two rectangles. + * + * @param a First rectangle. + * @param b Second rectangle. + * @return IoU in [0, 1]; 0 if the rectangles do not overlap or the union + * area is non-positive. Exposed for testing. + */ +float cv_track_assoc_iou(Rectangle a, Rectangle b); + +#ifdef __cplusplus +} +#endif + +#endif /* EMBEDDIP_CV_TRACK_ASSOC_H */ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 554bcb3..00d5258 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -62,6 +62,10 @@ add_executable(embeddip_test_cv_tracker_kcf test_cv_tracker_kcf.c) target_link_libraries(embeddip_test_cv_tracker_kcf PRIVATE embedDIP) add_test(NAME embeddip.cv_tracker_kcf COMMAND embeddip_test_cv_tracker_kcf) +add_executable(embeddip_test_cv_track_assoc test_cv_track_assoc.c) +target_link_libraries(embeddip_test_cv_track_assoc PRIVATE embedDIP) +add_test(NAME embeddip.cv_track_assoc COMMAND embeddip_test_cv_track_assoc) + add_executable(embeddip_test_runtime test_runtime.c) target_link_libraries(embeddip_test_runtime PRIVATE embedDIP) add_test(NAME embeddip.runtime COMMAND embeddip_test_runtime) diff --git a/tests/test_cv_track_assoc.c b/tests/test_cv_track_assoc.c new file mode 100644 index 0000000..a94e12e --- /dev/null +++ b/tests/test_cv_track_assoc.c @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +#include +#include +#include +#include +#include + +static Rectangle R(int x,int y,int w,int h){ Rectangle r={x,y,w,h}; return r; } + +static void test_iou(void){ + assert(cv_track_assoc_iou(R(0,0,10,10), R(0,0,10,10)) > 0.99f); + assert(cv_track_assoc_iou(R(0,0,10,10), R(100,100,10,10)) == 0.0f); + float half = cv_track_assoc_iou(R(0,0,10,10), R(5,0,10,10)); /* overlap 50/150 */ + assert(half > 0.32f && half < 0.34f); +} + +static void test_null(void){ + assert(cv_track_assoc_init(NULL, 0.3f, 5) == EMBEDDIP_ERROR_NULL_PTR); + CvTrackAssoc t; cv_track_assoc_init(&t,0.3f,5); + assert(cv_track_assoc_step(NULL, NULL, 0) == EMBEDDIP_ERROR_NULL_PTR); +} + +static void test_spawn_and_persist(void){ + CvTrackAssoc t; assert(cv_track_assoc_init(&t,0.3f,5)==EMBEDDIP_OK); + CvDetection d = { R(50,50,20,20), 100 }; + assert(cv_track_assoc_step(&t,&d,1)==EMBEDDIP_OK); + /* one track spawned with id 0 */ + int active=0, id=-1; + for(size_t i=0;i track dropped */ + for(int k=0;k<3;k++) cv_track_assoc_step(&t,NULL,0); + for(size_t i=0;i Date: Sat, 5 Sep 2026 14:28:00 +0200 Subject: [PATCH 9/9] cv: refactor the structure Signed-off-by: Ozan Durgut --- cv/hog.c | 42 ++++++++-------- cv/image_gray.h | 100 +++++++++++++++++++++++++++++++++++-- cv/nn.c | 24 +++++---- cv/track_hist.c | 32 ++++++------ cv/tracker_kcf.c | 109 ++++++++++++++++++++--------------------- cv/tracker_meanshift.c | 53 ++++++++++---------- cv/tracker_particle.c | 73 +++++++++++++-------------- cv/tracker_template.c | 22 ++++----- 8 files changed, 269 insertions(+), 186 deletions(-) diff --git a/cv/hog.c b/cv/hog.c index 08c0c2f..6d621f8 100644 --- a/cv/hog.c +++ b/cv/hog.c @@ -29,8 +29,11 @@ static uint8_t hog_pixel_clamped(const ImageView *src, int32_t px, int32_t py) } /* Accumulate a single cell's 9-bin orientation histogram. */ -static void hog_cell_histogram(const ImageView *src, int32_t cell_x0, int32_t cell_y0, - uint16_t cell_size, float hist[CV_HOG_BINS]) +static void hog_cell_histogram(const ImageView *src, + int32_t cell_x0, + int32_t cell_y0, + uint16_t cell_size, + float hist[CV_HOG_BINS]) { const float bin_width = CV_HOG_PI / (float)CV_HOG_BINS; uint16_t cy; @@ -71,8 +74,10 @@ static void hog_cell_histogram(const ImageView *src, int32_t cell_x0, int32_t ce } } -static embeddip_status_t hog_geometry(Rectangle roi, const CvHogConfig *config, - size_t *cells_x, size_t *cells_y, +static embeddip_status_t hog_geometry(Rectangle roi, + const CvHogConfig *config, + size_t *cells_x, + size_t *cells_y, size_t *out_length) { size_t cx; @@ -114,8 +119,8 @@ static embeddip_status_t hog_geometry(Rectangle roi, const CvHogConfig *config, return EMBEDDIP_OK; } -embeddip_status_t cv_hog_descriptor_size(Rectangle roi, const CvHogConfig *config, - size_t *out_length) +embeddip_status_t +cv_hog_descriptor_size(Rectangle roi, const CvHogConfig *config, size_t *out_length) { size_t cells_x; size_t cells_y; @@ -126,15 +131,17 @@ embeddip_status_t cv_hog_descriptor_size(Rectangle roi, const CvHogConfig *confi return hog_geometry(roi, config, &cells_x, &cells_y, out_length); } -embeddip_status_t cv_hog_extract(const ImageView *src, Rectangle roi, - const CvHogConfig *config, float *descriptor, - size_t descriptor_capacity, size_t *out_length) +embeddip_status_t cv_hog_extract(const ImageView *src, + Rectangle roi, + const CvHogConfig *config, + float *descriptor, + size_t descriptor_capacity, + size_t *out_length) { embeddip_status_t status; size_t cells_x; size_t cells_y; size_t length; - size_t blocks_x; size_t out = 0u; size_t by; @@ -150,8 +157,7 @@ embeddip_status_t cv_hog_extract(const ImageView *src, Rectangle roi, return status; } /* ROI must lie inside the image. */ - if (roi.x < 0 || roi.y < 0 || - (int64_t)roi.x + (int64_t)roi.width > (int64_t)src->width || + if (roi.x < 0 || roi.y < 0 || (int64_t)roi.x + (int64_t)roi.width > (int64_t)src->width || (int64_t)roi.y + (int64_t)roi.height > (int64_t)src->height) { return EMBEDDIP_ERROR_OUT_OF_RANGE; } @@ -159,9 +165,6 @@ embeddip_status_t cv_hog_extract(const ImageView *src, Rectangle roi, return EMBEDDIP_ERROR_INVALID_SIZE; } - blocks_x = cells_x - (CV_HOG_BLOCK_CELLS - 1u); - (void)blocks_x; - for (by = 0u; by + CV_HOG_BLOCK_CELLS <= cells_y; ++by) { size_t bx; for (bx = 0u; bx + CV_HOG_BLOCK_CELLS <= cells_x; ++bx) { @@ -176,12 +179,9 @@ embeddip_status_t cv_hog_extract(const ImageView *src, Rectangle roi, /* Gather the 2x2 cell histograms of this block, row-major. */ for (r = 0u; r < CV_HOG_BLOCK_CELLS; ++r) { for (c = 0u; c < CV_HOG_BLOCK_CELLS; ++c) { - int32_t cell_x0 = - roi.x + (int32_t)((bx + c) * config->cell_size); - int32_t cell_y0 = - roi.y + (int32_t)((by + r) * config->cell_size); - hog_cell_histogram(src, cell_x0, cell_y0, config->cell_size, - &block[idx]); + int32_t cell_x0 = roi.x + (int32_t)((bx + c) * config->cell_size); + int32_t cell_y0 = roi.y + (int32_t)((by + r) * config->cell_size); + hog_cell_histogram(src, cell_x0, cell_y0, config->cell_size, &block[idx]); idx += CV_HOG_BINS; } } diff --git a/cv/image_gray.h b/cv/image_gray.h index ac65855..f24bb41 100644 --- a/cv/image_gray.h +++ b/cv/image_gray.h @@ -4,15 +4,107 @@ #ifndef EMBEDDIP_CV_IMAGE_GRAY_H #define EMBEDDIP_CV_IMAGE_GRAY_H -#include - #include "core/error.h" #include "core/image.h" +#include +#include + #ifdef __cplusplus extern "C" { #endif +/** + * @brief Check whether a format is grayscale or mask (8-bit single channel). + * + * @param[in] f Format to check. + * @return true if grayscale or mask. + */ +static inline bool cv_format_is_gray(ImageFormat f) +{ + return f == IMAGE_FORMAT_GRAYSCALE || f == IMAGE_FORMAT_MASK; +} + +/** + * @brief Check whether a format is grayscale, mask, or packed RGB565. + * + * @param[in] f Format to check. + * @return true if grayscale, mask, or RGB565. + */ +static inline bool cv_format_is_gray_or_rgb565(ImageFormat f) +{ + return cv_format_is_gray(f) || f == IMAGE_FORMAT_RGB565; +} + +/** + * @brief Clamp a rectangle to [0, width) x [0, height). + * + * @param[in] roi Rectangle to clamp. + * @param[in] w Bound width. + * @param[in] h Bound height. + * @param[out] out Clamped rectangle; only written when the clamp is non-empty. + * @return false if the clamped rectangle is empty (roi lies fully outside + * the [0, w) x [0, h) bounds, or was empty to begin with). + */ +static inline bool cv_clamp_roi(Rectangle roi, uint32_t w, uint32_t h, Rectangle *out) +{ + int32_t x0 = roi.x < 0 ? 0 : roi.x; + int32_t y0 = roi.y < 0 ? 0 : roi.y; + int32_t x1 = roi.x + roi.width; + int32_t y1 = roi.y + roi.height; + if (x1 > (int32_t)w) { + x1 = (int32_t)w; + } + if (y1 > (int32_t)h) { + y1 = (int32_t)h; + } + if (x1 <= x0 || y1 <= y0) { + return false; + } + out->x = x0; + out->y = y0; + out->width = x1 - x0; + out->height = y1 - y0; + return true; +} + +/** + * @brief Clamp a box_width x box_height box centered at (cx,cy) into + * [0, frame_width) x [0, frame_height), preferring to shift the box + * (not shrink it) when it would otherwise cross a low edge, then a + * high edge. + * + * @param[in] cx Box center x. + * @param[in] cy Box center y. + * @param[in] box_width Box width. + * @param[in] box_height Box height. + * @param[in] frame_width Bound width. + * @param[in] frame_height Bound height. + * @return The clamped box (same width/height, shifted origin). + */ +static inline Rectangle cv_clamp_centered_box(int32_t cx, + int32_t cy, + int32_t box_width, + int32_t box_height, + uint32_t frame_width, + uint32_t frame_height) +{ + Rectangle roi = {cx - box_width / 2, cy - box_height / 2, box_width, box_height}; + if (roi.x < 0) { + roi.x = 0; + } + if (roi.y < 0) { + roi.y = 0; + } + if ((uint32_t)(roi.x + roi.width) > frame_width) { + roi.x = (int32_t)frame_width - roi.width; + } + if ((uint32_t)(roi.y + roi.height) > frame_height) { + roi.y = (int32_t)frame_height - roi.height; + } + return roi; +} + /** * @brief Validate a non-owning 8-bit grayscale or mask image view. * @@ -30,8 +122,8 @@ embeddip_status_t cv_gray_view_validate(const ImageView *view); * @param[out] out_pixel Destination for the pixel value. * @return EMBEDDIP_OK on success, error code otherwise. */ -embeddip_status_t cv_gray_pixel_u8(const ImageView *view, uint32_t x, uint32_t y, - uint8_t *out_pixel); +embeddip_status_t +cv_gray_pixel_u8(const ImageView *view, uint32_t x, uint32_t y, uint8_t *out_pixel); #ifdef __cplusplus } diff --git a/cv/nn.c b/cv/nn.c index 3c5fd2e..e400879 100644 --- a/cv/nn.c +++ b/cv/nn.c @@ -12,7 +12,6 @@ embeddip_status_t cv_nn_image_to_tensor(const ImageView *src, cv_tensor_t *dst) { embeddip_status_t status; - size_t count; size_t i; uint32_t y; @@ -29,13 +28,10 @@ embeddip_status_t cv_nn_image_to_tensor(const ImageView *src, cv_tensor_t *dst) if ((uint32_t)dst->width != src->width || (uint32_t)dst->height != src->height) { return EMBEDDIP_ERROR_INVALID_SIZE; } - if ((dst->type == CV_TENSOR_I8 || dst->type == CV_TENSOR_U8) && - !(dst->scale > 0.0f)) { + if ((dst->type == CV_TENSOR_I8 || dst->type == CV_TENSOR_U8) && !(dst->scale > 0.0f)) { return EMBEDDIP_ERROR_INVALID_ARG; } - count = (size_t)src->width * (size_t)src->height; - i = 0u; for (y = 0u; y < src->height; ++y) { const uint8_t *row = &src->pixels[(size_t)y * (size_t)src->row_stride_bytes]; @@ -83,12 +79,11 @@ embeddip_status_t cv_nn_image_to_tensor(const ImageView *src, cv_tensor_t *dst) } } - (void)count; return EMBEDDIP_OK; } -embeddip_status_t cv_nn_argmax(const float *scores, size_t count, size_t *out_index, - float *out_value) +embeddip_status_t +cv_nn_argmax(const float *scores, size_t count, size_t *out_index, float *out_value) { size_t best = 0u; size_t i; @@ -147,8 +142,8 @@ embeddip_status_t cv_nn_softmax(float *logits, size_t count) return EMBEDDIP_OK; } -embeddip_status_t cv_nn_segmentation_argmax(const cv_tensor_t *output, - uint8_t *class_map, size_t capacity) +embeddip_status_t +cv_nn_segmentation_argmax(const cv_tensor_t *output, uint8_t *class_map, size_t capacity) { size_t pixels; size_t channels; @@ -205,9 +200,12 @@ embeddip_status_t cv_nn_segmentation_argmax(const cv_tensor_t *output, return EMBEDDIP_OK; } -embeddip_status_t cv_nn_colorize(const uint8_t *class_map, uint32_t width, - uint32_t height, const uint8_t *palette, - size_t palette_count, uint8_t *rgb, +embeddip_status_t cv_nn_colorize(const uint8_t *class_map, + uint32_t width, + uint32_t height, + const uint8_t *palette, + size_t palette_count, + uint8_t *rgb, size_t rgb_capacity) { size_t pixels; diff --git a/cv/track_hist.c b/cv/track_hist.c index fcfa78f..ca3e023 100644 --- a/cv/track_hist.c +++ b/cv/track_hist.c @@ -7,38 +7,36 @@ #include #include +#include "cv/image_gray.h" + /** Bhattacharyya exponent scale; tuned so identical hists -> >0.99 and * disjoint hists -> <0.8 (see tests/test_cv_track_hist.c). */ #define CV_HIST_BHATTA_K 5.0f -embeddip_status_t cv_hist_build(const ImageView *img, Rectangle roi, float *out, - uint32_t *out_nbins) +embeddip_status_t +cv_hist_build(const ImageView *img, Rectangle roi, float *out, uint32_t *out_nbins) { if (img == NULL || out == NULL || out_nbins == NULL) { return EMBEDDIP_ERROR_NULL_PTR; } - bool is_gray = (img->format == IMAGE_FORMAT_GRAYSCALE || img->format == IMAGE_FORMAT_MASK) && - img->depth == IMAGE_DEPTH_U8; - bool is_rgb565 = img->format == IMAGE_FORMAT_RGB565 && img->depth == IMAGE_DEPTH_U16; - if (!is_gray && !is_rgb565) { + if (!cv_format_is_gray_or_rgb565(img->format)) { + return EMBEDDIP_ERROR_INVALID_FORMAT; + } + bool is_gray = cv_format_is_gray(img->format); + if (img->depth != (is_gray ? IMAGE_DEPTH_U8 : IMAGE_DEPTH_U16)) { return EMBEDDIP_ERROR_INVALID_FORMAT; } /* Clamp roi to image bounds. */ - int32_t x0 = roi.x < 0 ? 0 : roi.x; - int32_t y0 = roi.y < 0 ? 0 : roi.y; - int32_t x1 = roi.x + roi.width; - int32_t y1 = roi.y + roi.height; - if (x1 > (int32_t)img->width) { - x1 = (int32_t)img->width; - } - if (y1 > (int32_t)img->height) { - y1 = (int32_t)img->height; - } - if (x1 <= x0 || y1 <= y0) { + Rectangle clamped; + if (!cv_clamp_roi(roi, img->width, img->height, &clamped)) { return EMBEDDIP_ERROR_INVALID_SIZE; } + int32_t x0 = clamped.x; + int32_t y0 = clamped.y; + int32_t x1 = clamped.x + clamped.width; + int32_t y1 = clamped.y + clamped.height; uint32_t nbins = is_gray ? CV_HIST_GRAY_BINS : CV_HIST_COLOR_BINS; memset(out, 0, nbins * sizeof(float)); diff --git a/cv/tracker_kcf.c b/cv/tracker_kcf.c index 84b1686..828bedb 100644 --- a/cv/tracker_kcf.c +++ b/cv/tracker_kcf.c @@ -3,16 +3,13 @@ #include "cv/tracker_kcf.h" -#include -#include - #include "board/common.h" #include "imgproc/fft.h" -static bool kcf_format_ok(ImageFormat fmt) -{ - return fmt == IMAGE_FORMAT_GRAYSCALE || fmt == IMAGE_FORMAT_MASK; -} +#include +#include + +#include "cv/image_gray.h" /* Crop and nearest-neighbor resample a src region to * CV_KCF_PATCH_SIZE x CV_KCF_PATCH_SIZE, writing into a heap Image's @@ -32,12 +29,12 @@ static void kcf_extract_patch(const ImageView *src, Rectangle roi, uint8_t *out_ /* Build a heap Image holding a CV_KCF_PATCH_SIZE^2 grayscale patch sampled * from src's roi. Caller must deleteImage() the result. */ -static embeddip_status_t kcf_make_patch_image(const ImageView *src, Rectangle roi, - Image **out_patch) +static embeddip_status_t +kcf_make_patch_image(const ImageView *src, Rectangle roi, Image **out_patch) { Image *patch = NULL; - embeddip_status_t status = - createImageWH((int)CV_KCF_PATCH_SIZE, (int)CV_KCF_PATCH_SIZE, IMAGE_FORMAT_GRAYSCALE, &patch); + embeddip_status_t status = createImageWH( + (int)CV_KCF_PATCH_SIZE, (int)CV_KCF_PATCH_SIZE, IMAGE_FORMAT_GRAYSCALE, &patch); if (status != EMBEDDIP_OK) { return status; } @@ -96,28 +93,6 @@ static void kcf_conj_multiply(const Image *search_spec, const Image *template_sp /* Find the index of the largest real correlation value, then convert its * circular position into a signed (dx,dy) pixel offset in patch space * (values in [0, N/2) map to positive offsets, [N/2, N) wrap to negative). */ -/* Build a box_width x box_height roi centered at (cx,cy), clamped to stay - * inside frame bounds (same clamp policy cv_kcf_update already applies to - * its search roi). */ -static Rectangle kcf_clamped_roi(int32_t cx, int32_t cy, int32_t box_width, int32_t box_height, - const ImageView *frame) -{ - Rectangle roi = {cx - box_width / 2, cy - box_height / 2, box_width, box_height}; - if (roi.x < 0) { - roi.x = 0; - } - if (roi.y < 0) { - roi.y = 0; - } - if ((uint32_t)(roi.x + roi.width) > frame->width) { - roi.x = (int32_t)frame->width - roi.width; - } - if ((uint32_t)(roi.y + roi.height) > frame->height) { - roi.y = (int32_t)frame->height - roi.height; - } - return roi; -} - static void kcf_find_peak_offset(const Image *corr_time, int32_t *out_dx, int32_t *out_dy) { uint32_t n = corr_time->width; @@ -142,7 +117,7 @@ embeddip_status_t cv_kcf_init(CvKcfState *state, const ImageView *src, Rectangle if (state == NULL || src == NULL || src->pixels == NULL) { return EMBEDDIP_ERROR_NULL_PTR; } - if (!kcf_format_ok(src->format)) { + if (!cv_format_is_gray(src->format)) { return EMBEDDIP_ERROR_INVALID_FORMAT; } if (roi.width <= 0 || roi.height <= 0 || roi.x < 0 || roi.y < 0 || @@ -160,8 +135,8 @@ embeddip_status_t cv_kcf_init(CvKcfState *state, const ImageView *src, Rectangle } Image *spectrum = NULL; - status = createImageWH((int)CV_KCF_PATCH_SIZE, (int)CV_KCF_PATCH_SIZE, IMAGE_FORMAT_GRAYSCALE, - &spectrum); + status = createImageWH( + (int)CV_KCF_PATCH_SIZE, (int)CV_KCF_PATCH_SIZE, IMAGE_FORMAT_GRAYSCALE, &spectrum); if (status != EMBEDDIP_OK) { deleteImage(patch); return status; @@ -178,30 +153,42 @@ embeddip_status_t cv_kcf_init(CvKcfState *state, const ImageView *src, Rectangle /* Preallocate every per-frame scratch buffer cv_kcf_update will reuse * (see CvKcfState doc comment). On any failure below, release whatever * was already allocated (including template_spectrum above) and bail. */ - status = createImageWH((int)CV_KCF_PATCH_SIZE, (int)CV_KCF_PATCH_SIZE, IMAGE_FORMAT_GRAYSCALE, - &state->search_patch); + status = createImageWH((int)CV_KCF_PATCH_SIZE, + (int)CV_KCF_PATCH_SIZE, + IMAGE_FORMAT_GRAYSCALE, + &state->search_patch); if (status == EMBEDDIP_OK) { - status = createImageWH((int)CV_KCF_PATCH_SIZE, (int)CV_KCF_PATCH_SIZE, - IMAGE_FORMAT_GRAYSCALE, &state->search_spectrum); + status = createImageWH((int)CV_KCF_PATCH_SIZE, + (int)CV_KCF_PATCH_SIZE, + IMAGE_FORMAT_GRAYSCALE, + &state->search_spectrum); } if (status == EMBEDDIP_OK) { - status = createImageWH((int)CV_KCF_PATCH_SIZE, (int)CV_KCF_PATCH_SIZE, - IMAGE_FORMAT_GRAYSCALE, &state->corr_spectrum); + status = createImageWH((int)CV_KCF_PATCH_SIZE, + (int)CV_KCF_PATCH_SIZE, + IMAGE_FORMAT_GRAYSCALE, + &state->corr_spectrum); } if (status == EMBEDDIP_OK) { status = createChalsComplex(state->corr_spectrum, 2u); } if (status == EMBEDDIP_OK) { - status = createImageWH((int)CV_KCF_PATCH_SIZE, (int)CV_KCF_PATCH_SIZE, - IMAGE_FORMAT_GRAYSCALE, &state->corr_time); + status = createImageWH((int)CV_KCF_PATCH_SIZE, + (int)CV_KCF_PATCH_SIZE, + IMAGE_FORMAT_GRAYSCALE, + &state->corr_time); } if (status == EMBEDDIP_OK) { - status = createImageWH((int)CV_KCF_PATCH_SIZE, (int)CV_KCF_PATCH_SIZE, - IMAGE_FORMAT_GRAYSCALE, &state->adapt_patch); + status = createImageWH((int)CV_KCF_PATCH_SIZE, + (int)CV_KCF_PATCH_SIZE, + IMAGE_FORMAT_GRAYSCALE, + &state->adapt_patch); } if (status == EMBEDDIP_OK) { - status = createImageWH((int)CV_KCF_PATCH_SIZE, (int)CV_KCF_PATCH_SIZE, - IMAGE_FORMAT_GRAYSCALE, &state->adapt_spectrum); + status = createImageWH((int)CV_KCF_PATCH_SIZE, + (int)CV_KCF_PATCH_SIZE, + IMAGE_FORMAT_GRAYSCALE, + &state->adapt_spectrum); } if (status != EMBEDDIP_OK) { cv_kcf_free(state); @@ -225,12 +212,16 @@ embeddip_status_t cv_kcf_update(CvKcfState *state, const ImageView *frame, Recta if (!state->initialized) { return EMBEDDIP_ERROR_NOT_INITIALIZED; } - if (!kcf_format_ok(frame->format)) { + if (!cv_format_is_gray(frame->format)) { return EMBEDDIP_ERROR_INVALID_FORMAT; } - Rectangle search_roi = - kcf_clamped_roi(state->center_x, state->center_y, state->box_width, state->box_height, frame); + Rectangle search_roi = cv_clamp_centered_box(state->center_x, + state->center_y, + state->box_width, + state->box_height, + frame->width, + frame->height); /* All buffers below are preallocated once by cv_kcf_init and reused * every frame (see CvKcfState doc comment) — no create/delete here, so @@ -263,8 +254,12 @@ embeddip_status_t cv_kcf_update(CvKcfState *state, const ImageView *frame, Recta state->center_y = search_roi.y + search_roi.height / 2 + offset_y; if (state->learn_rate > 0.0f) { - Rectangle new_roi = kcf_clamped_roi(state->center_x, state->center_y, state->box_width, - state->box_height, frame); + Rectangle new_roi = cv_clamp_centered_box(state->center_x, + state->center_y, + state->box_width, + state->box_height, + frame->width, + frame->height); kcf_extract_patch(frame, new_roi, (uint8_t *)state->adapt_patch->pixels); status = fft(state->adapt_patch, state->adapt_spectrum); @@ -296,8 +291,12 @@ embeddip_status_t cv_kcf_free(CvKcfState *state) } Image **owned[] = { - &state->template_spectrum, &state->search_patch, &state->search_spectrum, - &state->corr_spectrum, &state->corr_time, &state->adapt_patch, + &state->template_spectrum, + &state->search_patch, + &state->search_spectrum, + &state->corr_spectrum, + &state->corr_time, + &state->adapt_patch, &state->adapt_spectrum, }; for (size_t i = 0u; i < sizeof(owned) / sizeof(owned[0]); ++i) { diff --git a/cv/tracker_meanshift.c b/cv/tracker_meanshift.c index a8ee758..a27df12 100644 --- a/cv/tracker_meanshift.c +++ b/cv/tracker_meanshift.c @@ -5,16 +5,19 @@ #include +#include "cv/image_gray.h" + /** Default mode-seek iteration cap; converges well within this for the * shift magnitudes expected between consecutive frames. */ #define CV_MEANSHIFT_DEFAULT_MAX_ITERS 5u static bool is_supported_format(const ImageView *img) { - bool is_gray = (img->format == IMAGE_FORMAT_GRAYSCALE || img->format == IMAGE_FORMAT_MASK) && - img->depth == IMAGE_DEPTH_U8; - bool is_rgb565 = img->format == IMAGE_FORMAT_RGB565 && img->depth == IMAGE_DEPTH_U16; - return is_gray || is_rgb565; + if (!cv_format_is_gray_or_rgb565(img->format)) { + return false; + } + return cv_format_is_gray(img->format) ? img->depth == IMAGE_DEPTH_U8 + : img->depth == IMAGE_DEPTH_U16; } /** Grayscale/mask bin index for pixel (x,y); uses cv/track_hist's mapping. */ @@ -39,19 +42,9 @@ embeddip_status_t cv_meanshift_init(CvMeanShiftState *state, const ImageView *fr /* Clamp roi to frame bounds first, matching cv_hist_build's own clamp, * so the stored box geometry always matches the region the histogram * was built over (and always fits inside the frame). */ - int32_t x0 = roi.x < 0 ? 0 : roi.x; - int32_t y0 = roi.y < 0 ? 0 : roi.y; - int32_t x1 = roi.x + roi.width; - int32_t y1 = roi.y + roi.height; - if (x1 > (int32_t)frame->width) x1 = (int32_t)frame->width; - if (y1 > (int32_t)frame->height) y1 = (int32_t)frame->height; - if (x1 <= x0 || y1 <= y0) { + if (!cv_clamp_roi(roi, frame->width, frame->height, &roi)) { return EMBEDDIP_ERROR_INVALID_SIZE; } - roi.x = x0; - roi.y = y0; - roi.width = x1 - x0; - roi.height = y1 - y0; embeddip_status_t st = cv_hist_build(frame, roi, state->template_hist, &state->hist_nbins); if (st != EMBEDDIP_OK) { @@ -67,8 +60,8 @@ embeddip_status_t cv_meanshift_init(CvMeanShiftState *state, const ImageView *fr return EMBEDDIP_OK; } -embeddip_status_t cv_meanshift_update(CvMeanShiftState *state, const ImageView *frame, - Rectangle *out_box) +embeddip_status_t +cv_meanshift_update(CvMeanShiftState *state, const ImageView *frame, Rectangle *out_box) { if (state == NULL || frame == NULL || out_box == NULL) { return EMBEDDIP_ERROR_NULL_PTR; @@ -80,7 +73,7 @@ embeddip_status_t cv_meanshift_update(CvMeanShiftState *state, const ImageView * return EMBEDDIP_ERROR_INVALID_FORMAT; } - bool is_gray = (frame->format == IMAGE_FORMAT_GRAYSCALE || frame->format == IMAGE_FORMAT_MASK); + bool is_gray = cv_format_is_gray(frame->format); int32_t hw = state->box_width / 2; int32_t hh = state->box_height / 2; /* ponytail: search box padded to 2x the tracked box so a shift of up to @@ -94,10 +87,14 @@ embeddip_status_t cv_meanshift_update(CvMeanShiftState *state, const ImageView * int32_t sy0 = state->center_y - search_hh; int32_t sx1 = state->center_x + search_hw; int32_t sy1 = state->center_y + search_hh; - if (sx0 < 0) sx0 = 0; - if (sy0 < 0) sy0 = 0; - if (sx1 > (int32_t)frame->width) sx1 = (int32_t)frame->width; - if (sy1 > (int32_t)frame->height) sy1 = (int32_t)frame->height; + if (sx0 < 0) + sx0 = 0; + if (sy0 < 0) + sy0 = 0; + if (sx1 > (int32_t)frame->width) + sx1 = (int32_t)frame->width; + if (sy1 > (int32_t)frame->height) + sy1 = (int32_t)frame->height; double sum_x = 0.0, sum_y = 0.0, sum_w = 0.0; for (int32_t y = sy0; y < sy1; y++) { @@ -139,10 +136,14 @@ embeddip_status_t cv_meanshift_update(CvMeanShiftState *state, const ImageView * } /* Clamp so the box stays inside the frame. */ - if (state->center_x - hw < 0) state->center_x = hw; - if (state->center_y - hh < 0) state->center_y = hh; - if (state->center_x + hw > (int32_t)frame->width) state->center_x = (int32_t)frame->width - hw; - if (state->center_y + hh > (int32_t)frame->height) state->center_y = (int32_t)frame->height - hh; + if (state->center_x - hw < 0) + state->center_x = hw; + if (state->center_y - hh < 0) + state->center_y = hh; + if (state->center_x + hw > (int32_t)frame->width) + state->center_x = (int32_t)frame->width - hw; + if (state->center_y + hh > (int32_t)frame->height) + state->center_y = (int32_t)frame->height - hh; out_box->x = state->center_x - hw; out_box->y = state->center_y - hh; diff --git a/cv/tracker_particle.c b/cv/tracker_particle.c index 81361fe..cde7d7b 100644 --- a/cv/tracker_particle.c +++ b/cv/tracker_particle.c @@ -3,16 +3,13 @@ #include "cv/tracker_particle.h" -#include -#include - #include "core/memory_manager.h" #include "imgproc/filter.h" -static bool particle_format_ok(ImageFormat fmt) -{ - return fmt == IMAGE_FORMAT_GRAYSCALE || fmt == IMAGE_FORMAT_MASK; -} +#include +#include + +#include "cv/image_gray.h" /* xorshift32: fast, deterministic, no external RNG dependency. */ static uint32_t xorshift32(uint32_t *state) @@ -38,8 +35,10 @@ static float xorshift_range(uint32_t *state, float range) return (xorshift_unit(state) * 2.0f - 1.0f) * range; } -embeddip_status_t cv_particle_init(CvParticleState *state, uint16_t particle_count, - float *particle_buffer, Rectangle roi) +embeddip_status_t cv_particle_init(CvParticleState *state, + uint16_t particle_count, + float *particle_buffer, + Rectangle roi) { if (state == NULL || particle_buffer == NULL) { return EMBEDDIP_ERROR_NULL_PTR; @@ -69,8 +68,10 @@ embeddip_status_t cv_particle_init(CvParticleState *state, uint16_t particle_cou return EMBEDDIP_OK; } -embeddip_status_t cv_particle_init_hist(CvParticleState *state, uint16_t particle_count, - float *particle_buffer, const ImageView *frame, +embeddip_status_t cv_particle_init_hist(CvParticleState *state, + uint16_t particle_count, + float *particle_buffer, + const ImageView *frame, Rectangle roi) { if (frame == NULL) { @@ -136,8 +137,8 @@ static void particle_free_image_chals(Image *img) * No heap: the candidate histogram and the weight/resample scratch below * are stack arrays bounded by CV_PARTICLE_MAX_COUNT (validated at * cv_particle_init_hist), independent of the caller's particle_buffer. */ -static embeddip_status_t cv_particle_update_hist(CvParticleState *state, const ImageView *frame, - Rectangle *out_box) +static embeddip_status_t +cv_particle_update_hist(CvParticleState *state, const ImageView *frame, Rectangle *out_box) { uint16_t count = state->particle_count; float weights[CV_PARTICLE_MAX_COUNT]; @@ -147,28 +148,19 @@ static embeddip_status_t cv_particle_update_hist(CvParticleState *state, const I float px = state->particle_buffer[i * 2u]; float py = state->particle_buffer[i * 2u + 1u]; - int32_t cand_x = (int32_t)px - state->box_width / 2; - int32_t cand_y = (int32_t)py - state->box_height / 2; - if (cand_x + state->box_width > (int32_t)frame->width) { - cand_x = (int32_t)frame->width - state->box_width; - } - if (cand_x < 0) { - cand_x = 0; - } - if (cand_y + state->box_height > (int32_t)frame->height) { - cand_y = (int32_t)frame->height - state->box_height; - } - if (cand_y < 0) { - cand_y = 0; - } - Rectangle cand_roi = {cand_x, cand_y, state->box_width, state->box_height}; + Rectangle cand_roi = cv_clamp_centered_box((int32_t)px, + (int32_t)py, + state->box_width, + state->box_height, + frame->width, + frame->height); float cand_hist[CV_HIST_MAX_BINS]; uint32_t cand_nbins = 0u; float weight = 0.0f; if (cv_hist_build(frame, cand_roi, cand_hist, &cand_nbins) == EMBEDDIP_OK) { - weight = cv_hist_bhattacharyya(cand_hist, state->template_hist, state->hist_nbins) + - 1e-6f; + weight = + cv_hist_bhattacharyya(cand_hist, state->template_hist, state->hist_nbins) + 1e-6f; } weights[i] = weight; sum_w += weight; @@ -212,8 +204,8 @@ static embeddip_status_t cv_particle_update_hist(CvParticleState *state, const I return EMBEDDIP_OK; } -embeddip_status_t cv_particle_update(CvParticleState *state, const ImageView *frame, - Rectangle *out_box) +embeddip_status_t +cv_particle_update(CvParticleState *state, const ImageView *frame, Rectangle *out_box) { if (state == NULL || frame == NULL || out_box == NULL || frame->pixels == NULL) { return EMBEDDIP_ERROR_NULL_PTR; @@ -222,8 +214,8 @@ embeddip_status_t cv_particle_update(CvParticleState *state, const ImageView *fr return EMBEDDIP_ERROR_NOT_INITIALIZED; } bool hist_mode = state->hist_nbins > 0u; - bool fmt_ok = particle_format_ok(frame->format) || - (hist_mode && frame->format == IMAGE_FORMAT_RGB565); + bool fmt_ok = + hist_mode ? cv_format_is_gray_or_rgb565(frame->format) : cv_format_is_gray(frame->format); if (!fmt_ok) { return EMBEDDIP_ERROR_INVALID_FORMAT; } @@ -254,10 +246,15 @@ embeddip_status_t cv_particle_update(CvParticleState *state, const ImageView *fr * ->chals->ch[0], not ->pixels. Each call's allocation is freed below * once the magnitude values have been consumed, to avoid a per-frame * leak. */ - Image gx_img = {.width = frame->width, .height = frame->height, .pixels = NULL, - .chals = NULL, .size = frame->width * frame->height, - .format = IMAGE_FORMAT_GRAYSCALE, .depth = IMAGE_DEPTH_F32, - .log = IMAGE_DATA_PIXELS, .is_chals = false}; + Image gx_img = {.width = frame->width, + .height = frame->height, + .pixels = NULL, + .chals = NULL, + .size = frame->width * frame->height, + .format = IMAGE_FORMAT_GRAYSCALE, + .depth = IMAGE_DEPTH_F32, + .log = IMAGE_DATA_PIXELS, + .is_chals = false}; Image gy_img = gx_img; Image mag_img = gx_img; diff --git a/cv/tracker_template.c b/cv/tracker_template.c index 2da13cb..782d603 100644 --- a/cv/tracker_template.c +++ b/cv/tracker_template.c @@ -4,18 +4,16 @@ #include "cv/tracker_template.h" #include +#include -static bool template_format_ok(ImageFormat fmt) -{ - return fmt == IMAGE_FORMAT_GRAYSCALE || fmt == IMAGE_FORMAT_MASK; -} +#include "cv/image_gray.h" embeddip_status_t cv_template_set(CvTemplateState *state, const ImageView *src, Rectangle roi) { if (state == NULL || src == NULL || src->pixels == NULL) { return EMBEDDIP_ERROR_NULL_PTR; } - if (!template_format_ok(src->format)) { + if (!cv_format_is_gray(src->format)) { return EMBEDDIP_ERROR_INVALID_FORMAT; } if (roi.width <= 0 || roi.height <= 0 || (uint32_t)roi.width > CV_TEMPLATE_MAX_WIDTH || @@ -29,9 +27,7 @@ embeddip_status_t cv_template_set(CvTemplateState *state, const ImageView *src, for (int32_t row = 0; row < roi.height; ++row) { const uint8_t *src_row = src->pixels + (size_t)(roi.y + row) * src->row_stride_bytes; - for (int32_t col = 0; col < roi.width; ++col) { - state->patch[row][col] = src_row[roi.x + col]; - } + memcpy(state->patch[row], src_row + roi.x, (size_t)roi.width); } state->width = (uint16_t)roi.width; @@ -41,8 +37,10 @@ embeddip_status_t cv_template_set(CvTemplateState *state, const ImageView *src, } /* Sum of absolute differences between the stored patch and a frame region. */ -static uint32_t template_sad_at(const CvTemplateState *state, const ImageView *frame, - int32_t origin_x, int32_t origin_y) +static uint32_t template_sad_at(const CvTemplateState *state, + const ImageView *frame, + int32_t origin_x, + int32_t origin_y) { uint32_t sad = 0u; for (uint16_t row = 0u; row < state->height; ++row) { @@ -56,8 +54,8 @@ static uint32_t template_sad_at(const CvTemplateState *state, const ImageView *f return sad; } -embeddip_status_t cv_template_match(const CvTemplateState *state, const ImageView *frame, - Rectangle *out_box) +embeddip_status_t +cv_template_match(const CvTemplateState *state, const ImageView *frame, Rectangle *out_box) { if (state == NULL || frame == NULL || out_box == NULL || frame->pixels == NULL) { return EMBEDDIP_ERROR_NULL_PTR;