Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion traincascade/test/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Unit tests for the color2gray algorithm
# Unit tests for the TrainCascadeLib

## Test framework: doctest
* https://github.com/onqtam/doctest
Expand Down
120 changes: 120 additions & 0 deletions traincascade/test/test_features.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include <doctest/doctest.h>

#include <opencv2/core.hpp>
#include <opencv2/imgproc.hpp>

#include "traincascade_features.h"
#include "haarfeatures.h"
Expand Down Expand Up @@ -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));
}
152 changes: 152 additions & 0 deletions traincascade/test/test_integration.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -279,3 +279,155 @@ 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);
}

// ---------------------------------------------------------------------------
// 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);
}