Commit 780f5d1a for tesseract

commit 780f5d1a387143a1fd46de8bb2447c2cada8289b
Author: Stefan Weil <sw@weilnetz.de>
Date:   Tue Nov 10 19:19:31 2026 +0100

    wordrec: let a real result win against a fake result of an exhausted budget

    In combined mode (OEM_TESSERACT_LSTM_COMBINED) a language is still tried
    in pass 1 when its classification budget is used up, because only some
    words (odd-sized words, and words which the LSTM recognizer could not
    read) fall back to the legacy engine. For such a word classify_word_pass1()
    sets up a fake result of spaces. The fake has certainty -1 and a rating of
    10 per blob, so in SelectBestWords() it won against a real result of
    another language which still had budget unless that result was a
    dictionary word with a rating within classify_max_rating_ratio: a real
    non-dictionary reading with certainty -3 lost against blanks.

    Mark such fakes with a new WERD_RES flag, budget_skipped, and let a real
    result always win against a flagged fake in SelectBestWords(). Two
    flagged fakes, or two real results, are compared as before, so a fake is
    still kept when no language can recognize the word. The flag is copied
    with the other simple fields and cleared by ClearResults(), so it does
    not survive a new recognition attempt of the word.

    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 6d5b84d5..c11f1650 100644
--- a/src/ccmain/control.cpp
+++ b/src/ccmain/control.cpp
@@ -801,9 +801,11 @@ static void WordGap(const PointerVector<WERD_RES> &words, unsigned index, int *r
 }

 // Factored helper computes the rating, certainty, badness and validity of
-// the permuter of the words in [first_index, end_index).
+// the permuter of the words in [first_index, end_index), and whether any of
+// them is a fake because the classification budget was used up.
 static void EvaluateWordSpan(const PointerVector<WERD_RES> &words, unsigned first_index, unsigned end_index,
-                             float *rating, float *certainty, bool *bad, bool *valid_permuter) {
+                             float *rating, float *certainty, bool *bad, bool *valid_permuter,
+                             bool *budget_skipped) {
   if (end_index <= first_index) {
     *bad = true;
     *valid_permuter = false;
@@ -819,13 +821,19 @@ static void EvaluateWordSpan(const PointerVector<WERD_RES> &words, unsigned firs
         *valid_permuter = false;
       }
     }
+    if (words[index]->budget_skipped) {
+      *budget_skipped = true;
+    }
   }
 }

 // Helper chooses the best combination of words, transferring good ones from
 // new_words to best_words. To win, a new word must have (better rating and
 // certainty) or (better permuter status and rating within rating ratio and
-// certainty within certainty margin) than current best.
+// certainty within certainty margin) than current best. A real result always
+// wins against a fake result of a language which has used up its
+// classification budget, so such a fake is only kept if no language could
+// recognize the word.
 // All the new_words are consumed (moved to best_words or deleted.)
 // The return value is the number of new_words used minus the number of
 // best_words that remain in the output.
@@ -867,16 +875,20 @@ static int SelectBestWords(double rating_ratio, double certainty_margin, bool de
     bool b_bad = false, n_bad = false;
     // True if all words have a valid permuter.
     bool b_valid_permuter = true, n_valid_permuter = true;
+    // True if any word is a fake because the classification budget was used up.
+    bool b_budget_skipped = false, n_budget_skipped = false;
     const int end_b = b < best_words->size() ? b + 1 : b;
     const int end_n = n < new_words->size() ? n + 1 : n;
     EvaluateWordSpan(*best_words, start_b, end_b, &b_rating, &b_certainty, &b_bad,
-                     &b_valid_permuter);
+                     &b_valid_permuter, &b_budget_skipped);
     EvaluateWordSpan(*new_words, start_n, end_n, &n_rating, &n_certainty, &n_bad,
-                     &n_valid_permuter);
+                     &n_valid_permuter, &n_budget_skipped);
     bool new_better = false;
-    if (!n_bad && (b_bad || (n_certainty > b_certainty && n_rating < b_rating) ||
-                   (!b_valid_permuter && n_valid_permuter && n_rating < b_rating * rating_ratio &&
-                    n_certainty > b_certainty - certainty_margin))) {
+    if (!n_bad && (b_bad || (b_budget_skipped && !n_budget_skipped) ||
+                   (b_budget_skipped == n_budget_skipped &&
+                    ((n_certainty > b_certainty && n_rating < b_rating) ||
+                     (!b_valid_permuter && n_valid_permuter && n_rating < b_rating * rating_ratio &&
+                      n_certainty > b_certainty - certainty_margin))))) {
       // New is better.
       for (int i = start_n; i < end_n; ++i) {
         out_words.push_back((*new_words)[i]);
@@ -1425,6 +1437,7 @@ void Tesseract::classify_word_and_language(int pass_n, PAGE_RES_IT *pr_it, WordD
       // result from an earlier pass, so fake it as for a timeout. Later
       // passes expect that the word knows its language.
       word_data->word->SetupFake(unicharset);
+      word_data->word->budget_skipped = true;
       word_data->word->tesseract = this;
     }
     return;
@@ -1496,8 +1509,10 @@ void Tesseract::classify_word_pass1(const WordData &word_data, WERD_RES **in_wor

 #ifndef DISABLED_LEGACY_ENGINE
     if (ClassificationBudgetExhausted()) {
-      // No budget left for the fallback, so use a fake.
+      // No budget left for the fallback, so use a fake. The flag lets a real
+      // result of another language win against it in SelectBestWords().
       (*in_word)->SetupFake(lstm_recognizer_->GetUnicharset());
+      (*in_word)->budget_skipped = true;
       return;
     }
     // Fall back to tesseract for failed words or odd words.
diff --git a/src/ccstruct/pageres.cpp b/src/ccstruct/pageres.cpp
index afe7552a..5f0c09d2 100644
--- a/src/ccstruct/pageres.cpp
+++ b/src/ccstruct/pageres.cpp
@@ -259,6 +259,7 @@ void WERD_RES::CopySimpleFields(const WERD_RES &source) {
   unlv_crunch_mode = source.unlv_crunch_mode;
   small_caps = source.small_caps;
   odd_size = source.odd_size;
+  budget_skipped = source.budget_skipped;
   fontinfo = source.fontinfo;
   fontinfo2 = source.fontinfo2;
   fontinfo_id_count = source.fontinfo_id_count;
@@ -1142,6 +1143,7 @@ void WERD_RES::Clear() {

 void WERD_RES::ClearResults() {
   done = false;
+  budget_skipped = false;
   fontinfo = nullptr;
   fontinfo2 = nullptr;
   fontinfo_id_count = 0;
diff --git a/src/ccstruct/pageres.h b/src/ccstruct/pageres.h
index f37b4df0..0d19ef67 100644
--- a/src/ccstruct/pageres.h
+++ b/src/ccstruct/pageres.h
@@ -303,6 +303,9 @@ public:
   bool done = false;             // ready for output?
   bool small_caps = false;       // word appears to be small caps
   bool odd_size = false;         // word is bigger than line or leader dots.
+  // The result is a fake because the blob classification budget of the
+  // language was used up (see Wordrec::ClassificationBudgetExhausted()).
+  bool budget_skipped = false;
   // The fontinfos are pointers to data owned by the classifier.
   const FontInfo *fontinfo = nullptr;
   const FontInfo *fontinfo2 = nullptr;