diff --git a/modules/objdetect/include/opencv2/objdetect/aruco_dictionary.hpp b/modules/objdetect/include/opencv2/objdetect/aruco_dictionary.hpp index d918e08129..faa79f0207 100644 --- a/modules/objdetect/include/opencv2/objdetect/aruco_dictionary.hpp +++ b/modules/objdetect/include/opencv2/objdetect/aruco_dictionary.hpp @@ -65,7 +65,7 @@ class CV_EXPORTS_W_SIMPLE Dictionary { */ CV_WRAP bool identify(const Mat &onlyBits, CV_OUT int &idx, CV_OUT int &rotation, double maxCorrectionRate) const; - /** @brief Given a matrix of pixel ratio raging from 0 to 1. Returns whether if marker is identified or not. + /** @brief Given a matrix of pixel ratio ranging from 0 to 1. Returns whether the marker is identified or not. * * Returns reference to the marker id in the dictionary (if any) and its rotation. */ @@ -77,6 +77,22 @@ class CV_EXPORTS_W_SIMPLE Dictionary { */ CV_WRAP int getDistanceToId(InputArray bits, int id, bool allRotations = true) const; + /** @brief Returns number of cells that differ from the specific id. + * + * For each cell, the distance is increased when the difference between the detected + * cell pixel ratio and the dictionary bit value is greater than `validBitIdThreshold`. + * If `allRotations` is set, the four possible marker rotations are considered. + * + * @param onlyCellPixelRatio markerSize x markerSize matrix (CV_32FC1) holding, for each cell, + * the ratio of white pixels ranging from 0 to 1 + * @param id marker id in the dictionary to compute the distance to + * @param allRotations if set, the four possible marker rotations are considered and the + * smallest distance is returned + * @param validBitIdThreshold maximum allowed difference between a cell pixel ratio and the + * dictionary bit value; cells exceeding it are counted as differing + */ + CV_WRAP int getDistanceToId(InputArray onlyCellPixelRatio, int id, bool allRotations, float validBitIdThreshold) const; + /** @brief Generate a canonical marker image */ CV_WRAP void generateImageMarker(int id, int sidePixels, OutputArray _img, int borderBits = 1) const; diff --git a/modules/objdetect/misc/python/test/test_objdetect_aruco.py b/modules/objdetect/misc/python/test/test_objdetect_aruco.py index 10dffad514..991d9bc3c9 100644 --- a/modules/objdetect/misc/python/test/test_objdetect_aruco.py +++ b/modules/objdetect/misc/python/test/test_objdetect_aruco.py @@ -144,6 +144,32 @@ class aruco_objdetect_test(NewOpenCVTests): self.assertEqual(dist, 0) + def test_getDistanceToId_cell_pixel_ratio(self): + aruco_dict = cv.aruco.getPredefinedDictionary(cv.aruco.DICT_4X4_50) + idx = 7 + valid_bit_id_threshold = 0.49 + bit_marker = np.array([[0, 1, 0, 1], [0, 1, 1, 1], [1, 1, 0, 0], [0, 1, 0, 0]], dtype=np.uint8) + ratio_marker = bit_marker.astype(np.float32) + + # Same marker as test_getDistanceToId, but passed as float cell ratios. + dist = aruco_dict.getDistanceToId(ratio_marker, idx, True, valid_bit_id_threshold) + self.assertEqual(dist, 0) + + # A small drift stays within the threshold. + accepted_ratio = ratio_marker.copy() + accepted_ratio[0, 0] = 0.4 + dist = aruco_dict.getDistanceToId(accepted_ratio, idx, True, valid_bit_id_threshold) + self.assertEqual(dist, 0) + + # A full flip crosses the threshold and counts as one bad cell. + erroneous_ratio = ratio_marker.copy() + erroneous_ratio[0, 0] = 1.0 - erroneous_ratio[0, 0] + dist = aruco_dict.getDistanceToId(onlyCellPixelRatio=erroneous_ratio, + id=idx, + allRotations=True, + validBitIdThreshold=valid_bit_id_threshold) + self.assertEqual(dist, 1) + def test_aruco_detector(self): aruco_params = cv.aruco.DetectorParameters() aruco_dict = cv.aruco.getPredefinedDictionary(cv.aruco.DICT_4X4_250) diff --git a/modules/objdetect/src/aruco/aruco_detector.cpp b/modules/objdetect/src/aruco/aruco_detector.cpp index 819d78db10..1870e53b53 100644 --- a/modules/objdetect/src/aruco/aruco_detector.cpp +++ b/modules/objdetect/src/aruco/aruco_detector.cpp @@ -466,7 +466,6 @@ static float _getMarkerConfidence(const Mat& groundTruthbits, const Mat &cellPix return std::max(0.f, std::min(1.f, normalizedMarkerConfidence)); } - /** * @brief Tries to identify one candidate given the dictionary * @return candidate typ. zero if the candidate is not valid, @@ -510,10 +509,10 @@ static uint8_t _identifyOneCandidate(const Dictionary& dictionary, const Mat& _i if(borderErrors > maximumErrorsInBorder) return 0; // border is wrong // take only inner bits - Mat onlyCellPixelRatio = - cellPixelRatio.rowRange(params.markerBorderBits, - cellPixelRatio.rows - params.markerBorderBits) - .colRange(params.markerBorderBits, cellPixelRatio.cols - params.markerBorderBits); + Mat onlyCellPixelRatio = cellPixelRatio( + Rect(params.markerBorderBits, params.markerBorderBits, + cellPixelRatio.cols - 2 * params.markerBorderBits, + cellPixelRatio.rows - 2 * params.markerBorderBits)); // try to identify the marker if(!dictionary.identify(onlyCellPixelRatio, idx, rotation, params.errorCorrectionRate, params.validBitIdThreshold)) @@ -1405,15 +1404,13 @@ void ArucoDetector::refineDetectedMarkers(InputArray _image, const Board& _board detectorParams.perspectiveRemovePixelPerCell, detectorParams.perspectiveRemoveIgnoredMarginPerCell, detectorParams.minOtsuStdDev); - Mat bits; - cellPixelRatio.convertTo(bits, CV_8UC1); + Mat onlyCellPixelRatio = cellPixelRatio( + Rect(detectorParams.markerBorderBits, detectorParams.markerBorderBits, + cellPixelRatio.cols - 2 * detectorParams.markerBorderBits, + cellPixelRatio.rows - 2 * detectorParams.markerBorderBits)); - Mat onlyBits = - bits.rowRange(detectorParams.markerBorderBits, bits.rows - detectorParams.markerBorderBits) - .colRange(detectorParams.markerBorderBits, bits.rows - detectorParams.markerBorderBits); - - codeDistance = - dictionary.getDistanceToId(onlyBits, undetectedMarkersIds[i], false); + codeDistance = dictionary.getDistanceToId(onlyCellPixelRatio, undetectedMarkersIds[i], + false, detectorParams.validBitIdThreshold); } // if everythin is ok, assign values to current best match diff --git a/modules/objdetect/src/aruco/aruco_dictionary.cpp b/modules/objdetect/src/aruco/aruco_dictionary.cpp index bd2ec20442..3e975c00bf 100644 --- a/modules/objdetect/src/aruco/aruco_dictionary.cpp +++ b/modules/objdetect/src/aruco/aruco_dictionary.cpp @@ -15,6 +15,86 @@ namespace aruco { using namespace std; +struct CellBitMasks { + CellBitMasks(const Mat &onlyCellPixelRatio, int markerSize, float validBitIdThreshold) + : s((markerSize * markerSize + 8 - 1) / 8), + totalCells(markerSize * markerSize), + temp(4 * s), + not0(temp.data()), not1(not0 + s), notXor(not1 + s), temp0(temp.data() + 3 * s) { + uint8_t* not0Writable = temp.data(); + uint8_t* not1Writable = not0Writable + s; + uint8_t* notXorWritable = not1Writable + s; + + // Fill bit masks of cells that are not black (not0) and not white (not1). + unsigned char not0Byte = 0, not1Byte = 0; + int currentByte = 0, currentBit = 0; + for(int j = 0; j < markerSize; j++) { + const float* cellPixelRatioRow = onlyCellPixelRatio.ptr(j); + for(int i = 0; i < markerSize; i++) { + not0Byte <<= 1; not1Byte <<= 1; + if(cellPixelRatioRow[i] > validBitIdThreshold) not0Byte |= 1; + if(cellPixelRatioRow[i] < 1 - validBitIdThreshold) not1Byte |= 1; + ++currentBit; + if(currentBit == 8) { + not0Writable[currentByte] = not0Byte; + not1Writable[currentByte] = not1Byte; + not0Byte = not1Byte = 0; + ++currentByte; + currentBit = 0; + } + } + } + if(currentBit != 0) { + not0Writable[currentByte] = not0Byte; + not1Writable[currentByte] = not1Byte; + } + + // Computing: notXor = not0 ^ not1 + hal::xor8u(not0, s, not1, s, notXorWritable, s, s, 1, nullptr); + } + + CellBitMasks(const CellBitMasks&) = delete; + CellBitMasks& operator=(const CellBitMasks&) = delete; + + // Smallest Hamming distance between these cell masks and dictionary marker `id`, + // searching the tested rotations; `rotation` returns the best one. + // Mutates the internal buffer (temp0). + int hammingDistanceToId(const Mat& bytesList, int id, bool allRotations, int& rotation) { + CV_Assert(id >= 0 && id < bytesList.rows); + + const unsigned int nRotations = allRotations ? 4u : 1u; + int currentMinDistance = totalCells + 1; + rotation = -1; + + const uchar* bytesRot = bytesList.ptr(id); + for(unsigned int r = 0; r < nRotations; r++, bytesRot += s) { + // Error if (marker is 0 and input is not 0) or (marker is 1 and input is not 1) + // i.e.: (!bytesRot && not0) || (bytesRot && not1) + // This is equivalent to: not0 ^ ((not0 ^ not1) & bytesRot) + // Computing: temp0 = (not0 ^ not1) & bytesRot + hal::and8u(notXor, s, bytesRot, s, temp0, s, s, 1, nullptr); + // Computing the final result (xor is performed internally). + int currentHamming = cv::hal::normHamming(not0, temp0, s); + + if(currentHamming < currentMinDistance) { + currentMinDistance = currentHamming; + rotation = static_cast(r); + // Break for perfect distance. + if(currentMinDistance == 0) break; + } + } + + return currentMinDistance; + } + + const int s; // bytes per rotation + const int totalCells; + std::vector temp; + const uint8_t *not0, *not1, *notXor; + uint8_t *temp0; // internal scratch workspace +}; + + Dictionary::Dictionary(): markerSize(0), maxCorrectionBits(0) {} @@ -46,6 +126,7 @@ bool Dictionary::readDictionary(const cv::FileNode& fn) { return true; } + void Dictionary::writeDictionary(FileStorage& fs, const String &name) { CV_Assert(fs.isOpened()); @@ -75,36 +156,9 @@ void Dictionary::writeDictionary(FileStorage& fs, const String &name) bool Dictionary::identify(const Mat &onlyCellPixelRatio, CV_OUT int &idx, CV_OUT int &rotation, double maxCorrectionRate, float validBitIdThreshold) const { CV_Assert(onlyCellPixelRatio.rows == markerSize && onlyCellPixelRatio.cols == markerSize); + CV_Assert(onlyCellPixelRatio.type() == CV_32FC1); - // Fill bit masks of cells that are not black (not0) and not white (not1). - const int s = (markerSize * markerSize + 8 - 1) / 8; - AutoBuffer temp(4 * s); - uint8_t* not0 = temp.data(), * not1 = not0 + s; - uint8_t not0Byte = 0, not1Byte = 0; - int currentByte = 0, currentBit = 0; - for(int j = 0; j < markerSize; j++) { - const float* cellPixelRatioRow = onlyCellPixelRatio.ptr(j); - for(int i = 0; i < markerSize; i++) { - not0Byte <<= 1; not1Byte <<= 1; - if(cellPixelRatioRow[i] > validBitIdThreshold) not0Byte |= 1; - if(cellPixelRatioRow[i] < 1 - validBitIdThreshold) not1Byte |= 1; - ++currentBit; - if(currentBit == 8) { - not0[currentByte] = not0Byte; - not1[currentByte] = not1Byte; - not0Byte = not1Byte = 0; - ++currentByte; - currentBit = 0; - } - } - } - if (currentBit != 0) { - not0[currentByte] = not0Byte; - not1[currentByte] = not1Byte; - } - uint8_t* notXor = not1 + s, * temp0 = notXor + s; - // Computing: notXor = not0 ^ not1 - hal::xor8u(not0, s, not1, s, notXor, s, s, 1, nullptr); + CellBitMasks cellBitMasks(onlyCellPixelRatio, markerSize, validBitIdThreshold); int maxCorrectionRecalculed = int(double(maxCorrectionBits) * maxCorrectionRate); @@ -112,25 +166,8 @@ bool Dictionary::identify(const Mat &onlyCellPixelRatio, CV_OUT int &idx, CV_OUT // search closest marker in dict for(int m = 0; m < bytesList.rows; m++) { - int currentMinDistance = markerSize * markerSize + 1; int currentRotation = -1; - const uchar* bytesRot = bytesList.ptr(m); - for(int r = 0; r < 4; r++, bytesRot += s) { - // Error if: (marker is 0 and input is not 0) or (marker is 1 and input is not 1) - // i.e. if: (!bytesRot && not0) || (bytesRot && not1) - // This is actually: not0 ^ ((not0 ^ not1) & bytesRot) - // Computing: temp0 = (not0 ^ not1) & bytesRot - hal::and8u(notXor, s, bytesRot, s, temp0, s, s, 1, nullptr); - // Computing the final result (xor is performed internally). - int currentHamming = cv::hal::normHamming(not0, temp0, s); - - if(currentHamming < currentMinDistance) { - currentMinDistance = currentHamming; - currentRotation = r; - // Break for perfect distance. - if (currentMinDistance == 0) break; - } - } + int currentMinDistance = cellBitMasks.hammingDistanceToId(bytesList, m, true, currentRotation); // if maxCorrection is fulfilled, return this one if(currentMinDistance <= maxCorrectionRecalculed) { @@ -148,9 +185,8 @@ bool Dictionary::identify(const Mat &onlyBits, CV_OUT int &idx, CV_OUT int &rota CV_Assert(onlyBits.rows == markerSize && onlyBits.cols == markerSize); Mat candidateBitRatio; - onlyBits.convertTo(candidateBitRatio, CV_32F); - const float validBitIdThreshold = DEFAULT_VALID_BIT_ID_THRESHOLD; - return identify(candidateBitRatio, idx, rotation, maxCorrectionRate, validBitIdThreshold); + Mat(onlyBits > 0).convertTo(candidateBitRatio, CV_32F, 1.0 / 255.0); + return identify(candidateBitRatio, idx, rotation, maxCorrectionRate, DEFAULT_VALID_BIT_ID_THRESHOLD); } @@ -177,6 +213,19 @@ int Dictionary::getDistanceToId(InputArray bits, int id, bool allRotations) cons } +int Dictionary::getDistanceToId(InputArray onlyCellPixelRatio, int id, bool allRotations, float validBitIdThreshold) const { + + Mat onlyCellPixelRatioMat = onlyCellPixelRatio.getMat(); + CV_Assert(onlyCellPixelRatioMat.rows == markerSize && onlyCellPixelRatioMat.cols == markerSize); + CV_Assert(onlyCellPixelRatioMat.type() == CV_32FC1); + CV_Assert(id >= 0 && id < bytesList.rows); + + int rotation = -1; + CellBitMasks cellBitMasks(onlyCellPixelRatioMat, markerSize, validBitIdThreshold); + return cellBitMasks.hammingDistanceToId(bytesList, id, allRotations, rotation); +} + + void Dictionary::generateImageMarker(int id, int sidePixels, OutputArray _img, int borderBits) const { CV_Assert(sidePixels >= (markerSize + 2*borderBits)); CV_Assert(id < bytesList.rows); @@ -405,6 +454,7 @@ static Mat _generateRandomMarker(int markerSize, RNG &rng) { return marker; } + /** * @brief Calculate selfDistance of the codification of a marker Mat. Self distance is the Hamming * distance of the marker to itself in the other rotations. diff --git a/modules/objdetect/test/test_boarddetection.cpp b/modules/objdetect/test/test_boarddetection.cpp index f95413ab32..c2e52782c6 100644 --- a/modules/objdetect/test/test_boarddetection.cpp +++ b/modules/objdetect/test/test_boarddetection.cpp @@ -206,6 +206,94 @@ void CV_ArucoRefine::run(int) { } } +// Find the position of a given marker id in the detection results, or -1 if absent. +static int findMarkerIndex(const vector& ids, int markerId) { + for(size_t i = 0; i < ids.size(); i++) { + if(ids[i] == markerId) + return (int)i; + } + return -1; +} + +// Warp a marker image onto an arbitrary quad in the scene and paint it over the +// background. A neutral grey (127) is used as the "background", the marker +// only contains black/white pixels, so everything that stays 127 after the warp +// is background and is left untouched. +static void drawMarkerAtCorners(Mat& image, const Mat& marker, const vector& corners) { + vector originalCorners = { + Point2f(0.f, 0.f), + Point2f((float)marker.cols - 1.f, 0.f), + Point2f((float)marker.cols - 1.f, (float)marker.rows - 1.f), + Point2f(0.f, (float)marker.rows - 1.f) + }; + Mat transformation = getPerspectiveTransform(originalCorners, corners); + + Mat warped(image.size(), image.type(), Scalar::all(127)); + warpPerspective(marker, warped, transformation, image.size(), INTER_NEAREST, BORDER_CONSTANT, Scalar::all(127)); + + Mat mask = warped != 127; + warped.copyTo(image, mask); +} + +// Degrade the marker image: find its first black inner cell and partially fill it with white so +// that the cell's white-pixel ratio becomes ~whiteRatio. This lets the test control how far a +// single cell drifts from its ground-truth bit, which is what validBitIdThreshold gates. +static bool setFirstBlackInnerCellWhiteRatio(Mat& marker, const aruco::Dictionary& dictionary, + int markerId, int markerBorderBits, float whiteRatio) { + const int markerSizeWithBorders = dictionary.markerSize + 2 * markerBorderBits; + const int cellSize = marker.rows / markerSizeWithBorders; + if(marker.cols != marker.rows || cellSize * markerSizeWithBorders != marker.rows) + return false; + + Mat markerBits = dictionary.getMarkerBits(markerId); + for(int y = 0; y < dictionary.markerSize; y++) { + for(int x = 0; x < dictionary.markerSize; x++) { + if(markerBits.ptr(y)[x] != 0.f) + continue; // skip white cells + + Rect cell((x + markerBorderBits) * cellSize, (y + markerBorderBits) * cellSize, + cellSize, cellSize); + marker(cell).setTo(Scalar::all(0)); + + // A centred white square of side sqrt(whiteRatio)*cellSize covers ~whiteRatio of the cell. + int whiteSide = cvRound(cellSize * std::sqrt(whiteRatio)); + whiteSide = std::max(1, std::min(cellSize, whiteSide)); + const int offset = (cellSize - whiteSide) / 2; + marker(Rect(cell.x + offset, cell.y + offset, whiteSide, whiteSide)).setTo(Scalar::all(255)); + return true; + } + } + + return false; +} + +// Drop a marker from the detection results and move its corners to the rejected list, so that +// refineDetectedMarkers() has a rejected candidate to try to recover. +static bool removeMarkerAndMakeRejected(int markerId, vector>& corners, + vector& ids, vector>& rejected) { + const int markerIndex = findMarkerIndex(ids, markerId); + if(markerIndex < 0) + return false; + + rejected.clear(); + rejected.push_back(corners[(size_t)markerIndex]); + corners.erase(corners.begin() + markerIndex); + ids.erase(ids.begin() + markerIndex); + return true; +} + +// Render a flat board image and detect its markers. +// Returns true only when every board marker was found. +static bool generateBoardForRefine(const aruco::GridBoard& board, int markerBorderBits, + Mat& image, const aruco::ArucoDetector& detector, + vector>& corners, vector& ids) { + board.generateImage(Size(760, 760), image, 50, markerBorderBits); + + vector> rejected; + detector.detectMarkers(image, corners, ids, rejected); + return board.getIds().size() == ids.size(); +} + TEST(CV_ArucoBoardPose, accuracy) { CV_ArucoBoardPose test(ArucoAlgParams::USE_DEFAULT); test.safe_run(); @@ -229,6 +317,75 @@ TEST(CV_Aruco3Refine, accuracy) { test.safe_run(); } +// refineDetectedMarkers() must use detectorParams.validBitIdThreshold when matching a rejected +// candidate's cell ratios against the expected marker code. Both cases below refine the very same +// image: a board whose dropped marker 0 is redrawn with one black cell brightened to a 0.6 white +// ratio and differ only in the threshold: the strict default (0.49) treats that cell as a bit +// error and leaves the marker rejected, while a relaxed 0.7 tolerates the deviation and recovers it. +class CV_ArucoRefineValidBitIdThreshold : public testing::Test { +protected: + void SetUp() override { + const int markerBorderBits = 1; + const int markerSidePixels = 300; + + dictionary = aruco::getPredefinedDictionary(aruco::DICT_4X4_50); + board = aruco::GridBoard(Size(2, 2), 1.f, 0.2f, dictionary); + + detectorParameters.markerBorderBits = markerBorderBits; + detectorParameters.perspectiveRemovePixelPerCell = 20; + detectorParameters.perspectiveRemoveIgnoredMarginPerCell = 0.; + + const aruco::ArucoDetector detector(dictionary, detectorParameters, refineParameters); + + // Start from a fully detected board (clean markers, so the threshold is irrelevant here). + ASSERT_TRUE(generateBoardForRefine(board, markerBorderBits, image, detector, corners, ids)); + + // Drop marker 0 so it becomes a rejected candidate for refinement. + ASSERT_TRUE(removeMarkerAndMakeRejected(markerId, corners, ids, rejected)); + + // Draw a degraded version of marker 0 (one black cell at 0.6 white ratio) at its location. + Mat marker; + dictionary.generateImageMarker(markerId, markerSidePixels, marker, markerBorderBits); + ASSERT_TRUE(setFirstBlackInnerCellWhiteRatio(marker, dictionary, markerId, markerBorderBits, 0.6f)); + drawMarkerAtCorners(image, marker, rejected[0]); + } + + // Refine the shared image with a given threshold and report whether marker 0 was recovered. + // refineDetectedMarkers() mutates its inputs, so each attempt runs on its own copy. + bool isMarkerRecovered(float validBitIdThreshold) const { + aruco::DetectorParameters attemptParameters = detectorParameters; + attemptParameters.validBitIdThreshold = validBitIdThreshold; + const aruco::ArucoDetector attemptDetector(dictionary, attemptParameters, refineParameters); + + vector> attemptCorners = corners; + vector attemptIds = ids; + vector> attemptRejected = rejected; + attemptDetector.refineDetectedMarkers(image, board, attemptCorners, attemptIds, attemptRejected); + return findMarkerIndex(attemptIds, markerId) >= 0; + } + + const int markerId = 0; + aruco::Dictionary dictionary; + aruco::GridBoard board; + aruco::DetectorParameters detectorParameters; + aruco::RefineParameters refineParameters{10.f, 1.f, true}; + + Mat image; + vector> corners; + vector ids; + vector> rejected; +}; + +// Strict threshold: the 0.6 white cell is treated as a bit error, so the marker is not recovered. +TEST_F(CV_ArucoRefineValidBitIdThreshold, strictThresholdKeepsMarkerRejected) { + EXPECT_FALSE(isMarkerRecovered(0.49f)); +} + +// Relaxed threshold: the deviation is tolerated, so the marker is recovered. +TEST_F(CV_ArucoRefineValidBitIdThreshold, relaxedThresholdRecoversMarker) { + EXPECT_TRUE(isMarkerRecovered(0.7f)); +} + TEST(CV_ArucoBoardPose, CheckNegativeZ) { double matrixData[9] = { -3.9062571886921410e+02, 0., 4.2350000000000000e+02, @@ -329,6 +486,85 @@ TEST(CV_ArucoDictionary, extendDictionary) { ASSERT_EQ(custom_dictionary.bytesList.rows, 150); ASSERT_EQ(cv::norm(custom_dictionary.bytesList, base_dictionary.bytesList.rowRange(0, 150)), 0.); } + +// Unit-test both getDistanceToId() overloads on a known marker: the existing bit-based overload +// must keep its exact Hamming behaviour, and the new ratio-based overload must count a cell as an +// error only when it deviates from the expected bit by more than validBitIdThreshold. +TEST(CV_ArucoDictionary, getDistanceToIdCellPixelRatio) { + const int markerId = 0; + const float validBitIdThreshold = 0.49f; + aruco::Dictionary dictionary = aruco::getPredefinedDictionary(aruco::DICT_4X4_50); + + // Bit overload: the exact marker bits are at distance 0 from their own id. + Mat bits = aruco::Dictionary::getBitsFromByteList(dictionary.bytesList.rowRange(markerId, markerId + 1), + dictionary.markerSize); + EXPECT_EQ(0, dictionary.getDistanceToId(bits, markerId, false)); + + // Bit overload: flipping a single bit yields a Hamming distance of exactly 1. + Mat erroneousBits = bits.clone(); + erroneousBits.ptr(0)[0] = (uchar)!erroneousBits.ptr(0)[0]; + EXPECT_EQ(1, dictionary.getDistanceToId(erroneousBits, markerId, false)); + + // Ground-truth bit values (0.f or 1.f) for the ratio overload checks below. + Mat markerRatio = dictionary.getMarkerBits(markerId); + const float expectedBit = markerRatio.ptr(0)[0]; + + // Ratio overload: a 0.4 drift toward the wrong value stays within the 0.49 tolerance -> no error. + Mat acceptedRatio = markerRatio.clone(); + acceptedRatio.ptr(0)[0] = expectedBit > 0.5f ? 0.6f : 0.4f; + EXPECT_EQ(0, dictionary.getDistanceToId(acceptedRatio, markerId, false, validBitIdThreshold)); + + // Ratio overload: a 0.6 drift exceeds the 0.49 tolerance -> the cell counts as one error. + Mat rejectedRatio = markerRatio.clone(); + rejectedRatio.ptr(0)[0] = expectedBit > 0.5f ? 0.4f : 0.6f; + EXPECT_EQ(1, dictionary.getDistanceToId(rejectedRatio, markerId, false, validBitIdThreshold)); +} + +// 5x5 markers leave one meaningful bit in the final packed byte. Flip only that cell +// far enough from its expected value and verify that the ratio distance counts it. +TEST(CV_ArucoDictionary, getDistanceToIdCellPixelRatioPartialByte) { + const int markerId = 15; + const float validBitIdThreshold = 0.49f; + aruco::Dictionary dictionary = aruco::getPredefinedDictionary(aruco::DICT_5X5_50); + + Mat markerRatio = dictionary.getMarkerBits(markerId); + EXPECT_EQ(0, dictionary.getDistanceToId(markerRatio, markerId, false, validBitIdThreshold)); + + Mat rotatedMarkerRatio = dictionary.getMarkerBits(markerId, 1); + EXPECT_EQ(0, dictionary.getDistanceToId(rotatedMarkerRatio, markerId, true, validBitIdThreshold)); + + Mat rejectedRatio = markerRatio.clone(); + float& lastCellRatio = rejectedRatio.ptr(dictionary.markerSize - 1)[dictionary.markerSize - 1]; + lastCellRatio = lastCellRatio > 0.5f ? 0.4f : 0.6f; + EXPECT_EQ(1, dictionary.getDistanceToId(rejectedRatio, markerId, false, validBitIdThreshold)); +} + +TEST(CV_ArucoDictionary, identifyBitMask) { + const int markerId = 7; + aruco::Dictionary dictionary = aruco::getPredefinedDictionary(aruco::DICT_4X4_50); + + // Start with a 0/1 bit matrix for the marker and confirm that the bit-based + // identify overload handles it without any ratio threshold input. + Mat bits = aruco::Dictionary::getBitsFromByteList(dictionary.bytesList.rowRange(markerId, markerId + 1), + dictionary.markerSize); + + int idx = -1; + int rotation = -1; + ASSERT_TRUE(dictionary.identify(bits, idx, rotation, 0.0)); + EXPECT_EQ(markerId, idx); + EXPECT_EQ(0, rotation); + + // OpenCV comparisons produce masks with values 0 and 255, not 0 and 1. The raw-bit + // identify overload must normalize those masks before delegating to the ratio path. + Mat bitMask; + bits.convertTo(bitMask, CV_8U, 255.0); + idx = -1; + rotation = -1; + ASSERT_TRUE(dictionary.identify(bitMask, idx, rotation, 0.0)); + EXPECT_EQ(markerId, idx); + EXPECT_EQ(0, rotation); +} + TEST(CV_ArucoBoardGenerateImage_RotationTest, HandlesRotatedMarkersWithoutBoundingBoxError) { using namespace cv;