From fe64578343cf1879be4ccd4d88f0a925af4b82ea Mon Sep 17 00:00:00 2001 From: Vladislav Antonov Date: Thu, 30 Apr 2026 09:10:45 +0300 Subject: [PATCH 1/3] Add multi stage integration test --- traincascade/test/README.md | 2 +- traincascade/test/test_integration.cpp | 62 ++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/traincascade/test/README.md b/traincascade/test/README.md index 7c356a2..d7f1c71 100644 --- a/traincascade/test/README.md +++ b/traincascade/test/README.md @@ -1,4 +1,4 @@ -# Unit tests for the color2gray algorithm +# Unit tests for the TrainCascadeLib ## Test framework: doctest * https://github.com/onqtam/doctest diff --git a/traincascade/test/test_integration.cpp b/traincascade/test/test_integration.cpp index 23c4751..5953000 100644 --- a/traincascade/test/test_integration.cpp +++ b/traincascade/test/test_integration.cpp @@ -279,3 +279,65 @@ TEST_CASE("CvCascadeClassifier::train: throws when cascade dir name is empty") { std::error_code ec; fs::remove_all(workDir, ec); } + +// --------------------------------------------------------------------------- +// Multi-stage boost loop +// --------------------------------------------------------------------------- + +TEST_CASE( + "CvCascadeClassifier::train: completes multi-stage training " + "(numStages=2, maxWeakCount=3, maxDepth=2)") { + // Arrange: a 2-stage LBP cascade with depth-2 trees and up to 3 weak + // learners per stage. This exercises the boost outer loop for more than one + // stage as well as the recursive split path in o_cvboostree.cpp (depth>1) + // and the sample-weight update path in boost.cpp that runs between stages. + const auto workDir = makeUniqueOutputDir("multistage"); + const auto res = stageResources(workDir); + const auto dataDir = workDir / "data"; + fs::create_directories(dataDir); + + CvCascadeParams cascadeParams(CvCascadeParams::BOOST, + CvFeatureParams::LBP); + cascadeParams.winSize = cv::Size(75, 32); + CvLBPFeatureParams featureParams; + CvCascadeBoostParams stageParams(cv::ml::Boost::GENTLE, + /*minHitRate=*/0.995F, + /*maxFalseAlarm=*/0.5F, + /*weightTrimRate=*/0.95, + /*maxDepth=*/2, + /*maxWeakCount=*/3); + + CvCascadeClassifier classifier; + + // Act + const bool ok = classifier.train(dataDir.string(), + res.vec.string(), + res.bg.string(), + /*numPos=*/20, + /*numNeg=*/1, + /*precalcValBufSize=*/64, + /*precalcIdxBufSize=*/64, + /*numStages=*/2, + cascadeParams, + featureParams, + stageParams, + /*baseFormatSave=*/false, + /*acceptanceRatioBreakValue=*/-1.0); + + // Assert: train() returns true when at least one stage trains successfully. + // The second stage may early-exit if the negative reservoir is exhausted, + // but stage 0 must always be produced. We accept either a single-stage or a + // full two-stage cascade and verify the artefacts that are guaranteed. + CHECK(ok); + CHECK(fs::exists(dataDir / "cascade.xml")); + CHECK(fs::exists(dataDir / "params.xml")); + REQUIRE(fs::exists(dataDir / "stage0.xml")); + + // The produced cascade.xml must remain loadable by the public detector. + cv::CascadeClassifier loaded((dataDir / "cascade.xml").string()); + CHECK_FALSE(loaded.empty()); + + // Cleanup + std::error_code ec; + fs::remove_all(workDir, ec); +} From 0cea6ea24194ca6d2079559e99d288af64c29457 Mon Sep 17 00:00:00 2001 From: Vladislav Antonov Date: Thu, 30 Apr 2026 09:16:51 +0300 Subject: [PATCH 2/3] Add Core and All HAAR tests --- traincascade/test/test_integration.cpp | 90 ++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/traincascade/test/test_integration.cpp b/traincascade/test/test_integration.cpp index 5953000..a357229 100644 --- a/traincascade/test/test_integration.cpp +++ b/traincascade/test/test_integration.cpp @@ -341,3 +341,93 @@ TEST_CASE( std::error_code ec; fs::remove_all(workDir, ec); } + +// --------------------------------------------------------------------------- +// HAAR feature-set variants +// --------------------------------------------------------------------------- + +TEST_CASE("CvCascadeClassifier::train: HAAR CORE mode produces a usable cascade") { + // Arrange: CORE adds the diagonal/centred Haar features on top of BASIC, + // exercising additional code paths in haarfeatures.cpp (generateFeatures). + const auto workDir = makeUniqueOutputDir("haar_core"); + const auto res = stageResources(workDir); + const auto dataDir = workDir / "data"; + fs::create_directories(dataDir); + + CvCascadeParams cascadeParams(CvCascadeParams::BOOST, + CvFeatureParams::HAAR); + cascadeParams.winSize = cv::Size(75, 32); + CvHaarFeatureParams featureParams(CvHaarFeatureParams::CORE); + CvCascadeBoostParams stageParams(cv::ml::Boost::GENTLE, + 0.995F, 0.5F, 0.95, 1, 10); + + CvCascadeClassifier classifier; + + // Act + const bool ok = classifier.train(dataDir.string(), + res.vec.string(), + res.bg.string(), + /*numPos=*/20, + /*numNeg=*/1, + /*precalcValBufSize=*/64, + /*precalcIdxBufSize=*/64, + /*numStages=*/1, + cascadeParams, + featureParams, + stageParams, + /*baseFormatSave=*/false, + /*acceptanceRatioBreakValue=*/-1.0); + + // Assert + CHECK(ok); + CHECK(fs::exists(dataDir / "cascade.xml")); + cv::CascadeClassifier loaded((dataDir / "cascade.xml").string()); + CHECK_FALSE(loaded.empty()); + + // Cleanup + std::error_code ec; + fs::remove_all(workDir, ec); +} + +TEST_CASE("CvCascadeClassifier::train: HAAR ALL mode produces a usable cascade") { + // Arrange: ALL adds the 45-degree rotated Haar features, covering the + // remaining branch in CvHaarEvaluator::generateFeatures. + const auto workDir = makeUniqueOutputDir("haar_all"); + const auto res = stageResources(workDir); + const auto dataDir = workDir / "data"; + fs::create_directories(dataDir); + + CvCascadeParams cascadeParams(CvCascadeParams::BOOST, + CvFeatureParams::HAAR); + cascadeParams.winSize = cv::Size(75, 32); + CvHaarFeatureParams featureParams(CvHaarFeatureParams::ALL); + CvCascadeBoostParams stageParams(cv::ml::Boost::GENTLE, + 0.995F, 0.5F, 0.95, 1, 10); + + CvCascadeClassifier classifier; + + // Act + const bool ok = classifier.train(dataDir.string(), + res.vec.string(), + res.bg.string(), + /*numPos=*/20, + /*numNeg=*/1, + /*precalcValBufSize=*/64, + /*precalcIdxBufSize=*/64, + /*numStages=*/1, + cascadeParams, + featureParams, + stageParams, + /*baseFormatSave=*/false, + /*acceptanceRatioBreakValue=*/-1.0); + + // Assert + CHECK(ok); + CHECK(fs::exists(dataDir / "cascade.xml")); + cv::CascadeClassifier loaded((dataDir / "cascade.xml").string()); + CHECK_FALSE(loaded.empty()); + + // Cleanup + std::error_code ec; + fs::remove_all(workDir, ec); +} From 2faf95b434c6c9aab6406c9a89aa96fcb01dadc2 Mon Sep 17 00:00:00 2001 From: Vladislav Antonov Date: Thu, 30 Apr 2026 09:21:57 +0300 Subject: [PATCH 3/3] Add tilted feature branch test --- traincascade/test/test_features.cpp | 120 ++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/traincascade/test/test_features.cpp b/traincascade/test/test_features.cpp index 43b5d8b..a162503 100644 --- a/traincascade/test/test_features.cpp +++ b/traincascade/test/test_features.cpp @@ -1,6 +1,7 @@ #include #include +#include #include "traincascade_features.h" #include "haarfeatures.h" @@ -438,3 +439,122 @@ TEST_CASE("CvHOGEvaluator::operator(): produces at least one non-zero on a textu CHECK(foundNonZero); } + +// --------------------------------------------------------------------------- +// Direct CvHaarEvaluator::Feature::calc tests against known integral images +// +// Feature is a protected nested type, so we expose it via a thin probe +// subclass and construct/evaluate features by hand. The integral image is +// passed as a single flattened row (row-major) because calc() expects all +// fast-rect offsets to index into one cv::Mat row. +// --------------------------------------------------------------------------- + +namespace { + +class HaarFeatureProbe : public CvHaarEvaluator { + public: + using CvHaarEvaluator::Feature; +}; +using HaarFeature = HaarFeatureProbe::Feature; + +} // namespace + +TEST_CASE("CvHaarEvaluator::Feature::calc: upright two-rect feature on a vertical-step image") { + // Arrange: 8x8 image, left half = 0, right half = 100. Feature: +1 over the + // left half rectangle, -1 over the right half. Expected response = + // (left sum) - (right sum) = 0 - (100 * 4 * 8) = -3200. + cv::Mat img(8, 8, CV_8UC1, cv::Scalar(0)); + img.colRange(4, 8).setTo(100); + cv::Mat sum; + cv::integral(img, sum, CV_32S); // 9x9 CV_32S + const cv::Mat sumRow = sum.reshape(0, 1); // flatten to one row + cv::Mat unusedTilted; // not read for upright + const int offset = sum.cols; // = 9 + + HaarFeature feature(offset, /*tilted=*/false, + /*x0,y0,w0,h0,wt0=*/0, 0, 4, 8, +1.0F, + /*x1,y1,w1,h1,wt1=*/4, 0, 4, 8, -1.0F); + + // Act + const float response = feature.calc(sumRow, unusedTilted, 0); + + // Assert + CHECK(response == doctest::Approx(-3200.0F)); +} + +TEST_CASE("CvHaarEvaluator::Feature::calc: upright feature returns zero on a uniform image") { + // Arrange: uniform 8x8 image, balanced two-rect feature → response = 0. + cv::Mat img(8, 8, CV_8UC1, cv::Scalar(42)); + cv::Mat sum; + cv::integral(img, sum, CV_32S); + const cv::Mat sumRow = sum.reshape(0, 1); + cv::Mat unusedTilted; + + HaarFeature feature(sum.cols, /*tilted=*/false, + 0, 0, 4, 8, +1.0F, + 4, 0, 4, 8, -1.0F); + + // Act + const float response = feature.calc(sumRow, unusedTilted, 0); + + // Assert: any balanced two-rect filter is zero on a constant image. + CHECK(response == doctest::Approx(0.0F)); +} + +TEST_CASE("CvHaarEvaluator::Feature::calc: upright three-rect feature uses rect[2] when its weight is non-zero") { + // Arrange: 9x3 image with the centre column = 200, others = 0. Build a + // horizontal three-rect feature + // rect[0] = full 9x3 weight = +1 + // rect[1] = centre 3x3 weight = -3 + // (centred-band Haar feature). On this 3x9 image: + // rect[0] sum = 200 * 3 (cols) * 3 (rows) = 1800 + // rect[1] sum = 200 * 3 (cols) * 3 (rows) = 1800 + // response = 1800*1 + 1800*(-3) = -3600. + cv::Mat img(3, 9, CV_8UC1, cv::Scalar(0)); + img.colRange(3, 6).setTo(200); + cv::Mat sum; + cv::integral(img, sum, CV_32S); // 4x10 CV_32S + const cv::Mat sumRow = sum.reshape(0, 1); + cv::Mat unusedTilted; + + HaarFeature feature(sum.cols, /*tilted=*/false, + /*rect0=*/0, 0, 9, 3, +1.0F, + /*rect1=*/3, 0, 3, 3, -3.0F); + + // Act + const float response = feature.calc(sumRow, unusedTilted, 0); + + // Assert + CHECK(response == doctest::Approx(-3600.0F)); +} + +TEST_CASE("CvHaarEvaluator::Feature::calc: tilted-feature branch reads the tilted integral image") { + // Arrange: 16x16 uniform image of ones. The tilted integral image computed + // by cv::integral lets us evaluate a 45-degree rotated rectangle's area as + // tilted[p0] + tilted[p3] - tilted[p1] - tilted[p2] + // which on a unit-valued image equals w * h. We pick a rectangle that fits + // entirely inside the image and use a single weighted rect (rect[1] has + // zero weight, so its contribution drops out). + cv::Mat img(16, 16, CV_8UC1, cv::Scalar(1)); + cv::Mat sum; + cv::Mat sqsum; + cv::Mat tilted; + cv::integral(img, sum, sqsum, tilted, CV_32S); // tilted: 17x17 CV_32S + const cv::Mat tiltedRow = tilted.reshape(0, 1); + cv::Mat unusedSum; // not read for tilted + + // Tilted rectangle anchored so that all four corner offsets fall within the + // 17x17 tilted integral. With x=8,y=2,w=4,h=4 the corners land at + // (8,2) (12,6) (4,6) (8,10) — all inside. + HaarFeature feature(tilted.cols, /*tilted=*/true, + /*rect0=*/8, 2, 4, 4, +1.0F, + /*rect1 weight = 0 → ignored*/0, 0, 0, 0, 0.0F); + + // Act + const float response = feature.calc(unusedSum, tiltedRow, 0); + + // Assert: the cascade-trainer tilted-rect convention has sides of length + // w*sqrt(2) and h*sqrt(2) (w runs along (+1,+1), h along (-1,+1)), so the + // rotated rectangle's area on an all-ones image is 2 * w * h = 32. + CHECK(response == doctest::Approx(32.0F)); +}