Commit ceb526ea for tesseract
commit ceb526eab6998cd292139be90f0c696df8df50f4
Author: Stefan Weil <sw@weilnetz.de>
Date: Mon Nov 9 12:37:23 2026 +0100
wordrec: handle words skipped by the classification budget in more places
A word which is not recognized because the blob classification budget
is used up gets a fake result: it has boxes, but no blobs, no ratings
matrix and no text. Some code which runs later did not expect that.
- worst_noise_blob() read uninitialized noise scores for such a word
with a fixed pitch font and at least five boxes. Return early for
words which were not recognized, and skip the fuzzy space pass when
no language has budget left, because it needs the classifier.
- ambigs_classify_and_output() dereferenced the missing ratings
matrix. Return before the matrix paths are written.
- PrerecAllWordsPar() (tessedit_parallelize) classifies the blobs of
all words before the first word is recognized, so it could use up
the budget before the checks between words were reached, and the
page got no text. Do not prerecognize if the budget of any language
is limited.
- ClassificationBudgetTest did not skip when the traineddata has no
legacy model, because CHECK_EQ only logs in the unit tests. Check
the initialization of the legacy engine in the test body.
All four problems were reported by the Copilot review of the pull
request.
Assisted-by: Claude Fable 5.1 (Anthropic)
Signed-off-by: Stefan Weil <sw@weilnetz.de>
diff --git a/src/ccmain/control.cpp b/src/ccmain/control.cpp
index c02de8dc..6d5b84d5 100644
--- a/src/ccmain/control.cpp
+++ b/src/ccmain/control.cpp
@@ -433,8 +433,9 @@ bool Tesseract::recog_all_words(PAGE_RES *page_res, ETEXT_DESC *monitor,
// ****************** Pass 3 *******************
// Fix fuzzy spaces.
+ // This needs the classifier, so it is skipped without budget.
if (!tessedit_test_adaption && tessedit_fix_fuzzy_spaces && !tessedit_word_for_word &&
- !right_to_left()) {
+ !right_to_left() && !AllClassificationBudgetsExhausted()) {
fix_fuzzy_spaces(monitor, stats_.word_count, page_res);
}
diff --git a/src/ccmain/fixspace.cpp b/src/ccmain/fixspace.cpp
index 941d86c7..4e122609 100644
--- a/src/ccmain/fixspace.cpp
+++ b/src/ccmain/fixspace.cpp
@@ -695,6 +695,11 @@ int16_t Tesseract::worst_noise_blob(WERD_RES *word_res, float *worst_noise_score
if (word_res->rebuild_word == nullptr) {
return -1; // Can't handle cube words.
}
+ if (word_res->tess_failed) {
+ // A word which was not recognized (for example because the classification
+ // budget was used up) has boxes, but no blobs to get noise scores from.
+ return -1;
+ }
// Normalised.
auto blob_count = word_res->box_word->length();
diff --git a/src/ccmain/par_control.cpp b/src/ccmain/par_control.cpp
index efad3355..731bd19a 100644
--- a/src/ccmain/par_control.cpp
+++ b/src/ccmain/par_control.cpp
@@ -17,6 +17,9 @@
///////////////////////////////////////////////////////////////////////
#include "tesseractclass.h"
+
+#include <algorithm> // for std::any_of
+#include <cstdint> // for INT32_MAX
#ifdef _OPENMP
# include <omp.h>
#endif // _OPENMP
@@ -36,6 +39,18 @@ struct BlobData {
};
void Tesseract::PrerecAllWordsPar(const std::vector<WordData> &words) {
+ // The prerecognition classifies the blobs of all words before the first
+ // word is recognized. That does not fit to a blob-classification budget,
+ // which is enforced between words (see
+ // Wordrec::ClassificationBudgetExhausted()): the budget could be used up
+ // before any word is recognized. So there is no prerecognition if the
+ // budget of any language is limited.
+ if (chop_max_classifications_pass < INT32_MAX ||
+ std::any_of(sub_langs_.begin(), sub_langs_.end(), [](const Tesseract *lang) {
+ return lang->chop_max_classifications_pass < INT32_MAX;
+ })) {
+ return;
+ }
// Prepare all the blobs.
std::vector<BlobData> blobs;
for (const auto &w : words) {
diff --git a/src/ccmain/recogtraining.cpp b/src/ccmain/recogtraining.cpp
index 148f8820..4669cfe9 100644
--- a/src/ccmain/recogtraining.cpp
+++ b/src/ccmain/recogtraining.cpp
@@ -220,6 +220,12 @@ void Tesseract::ambigs_classify_and_output(const char *label, PAGE_RES_IT *pr_it
return;
}
+ if (werd_res->ratings == nullptr) {
+ // The word was not recognized (for example because the classification
+ // budget was used up), so there are no paths to output.
+ return;
+ }
+
// Dump all paths through the ratings matrix (which is normally small).
int dim = werd_res->ratings->dimension();
const auto **blob_choices = new const BLOB_CHOICE *[dim];
diff --git a/unittest/baseapi_test.cc b/unittest/baseapi_test.cc
index e148dd0f..f927d585 100644
--- a/unittest/baseapi_test.cc
+++ b/unittest/baseapi_test.cc
@@ -108,6 +108,15 @@ TEST_F(TesseractTest, ClassificationBudgetTest) {
GTEST_SKIP();
#else
REQUIRE_TRAINEDDATA(TESSDATA_DIR, "eng");
+ // Returns true if the legacy engine can be used with the given languages.
+ auto has_legacy_model = [](const char *languages) {
+ tesseract::TessBaseAPI api;
+ return api.Init(TessdataPath().c_str(), languages, tesseract::OEM_TESSERACT_ONLY) == 0;
+ };
+ if (!has_legacy_model("eng")) {
+ // eng.traineddata without legacy model.
+ GTEST_SKIP();
+ }
// Returns the text of phototest which the legacy engine gets with the given
// languages. A budget which is not nullptr is set for the first language.
auto recognize = [](const char *languages, const char *budget) {
@@ -150,6 +159,10 @@ TEST_F(TesseractTest, ClassificationBudgetTest) {
// The budget is per language, so a second language without limit takes
// over when the budget of the first language is used up.
REQUIRE_TRAINEDDATA(TESSDATA_DIR, "deu");
+ if (!has_legacy_model("eng+deu")) {
+ // deu.traineddata without legacy model.
+ GTEST_SKIP();
+ }
EXPECT_GE(count_words(recognize("eng+deu", "100")) * 10, full_words * 9);
#endif
}