Commit db20f322 for tesseract

commit db20f322d03664d1e878e2fbf6e904f5da755594
Author: Stefan Weil <sw@weilnetz.de>
Date:   Mon Sep 28 09:40:38 2026 +0200

    Avoid float overflow for unclassified-word certainty (#4630)

    A word that the recognizer could not classify is given a "bad" certainty
    of -FLT_MAX (WERD_CHOICE::make_bad, WERD_RES::FakeWordFromRatings).
    Textord::CleanupSingleRowResult sums the certainties of a row in a float,
    so a row holding two such words overflows to -inf; the tesseract CLI turns
    that into a SIGFPE because main1() enables the FE_OVERFLOW trap, and
    library users get a silent -inf confidence. The per-word confidence
    (100 + 5 * certainty) overflows the same way.

    Introduce WERD_CHOICE::kBadCertainty, a deliberately finite sentinel
    (-100000, mirroring kBadRating) that can be summed and scaled without
    overflowing, and use it for the unclassified-word certainty. Add a
    regression test asserting the sentinel is finite and that the row-sum and
    confidence arithmetic stay finite.

    Fixes #4627.

    Assisted-by: OpenCode / Qwen3.8-27B-Thinking (Alibaba Cloud)

    Signed-off-by: Stefan Weil <sw@weilnetz.de>

diff --git a/Makefile.am b/Makefile.am
index b1546119..51f26fb6 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -1207,6 +1207,7 @@ check_PROGRAMS += params_model_test
 endif # !DISABLED_LEGACY_ENGINE
 check_PROGRAMS += progress_test
 check_PROGRAMS += qrsequence_test
+check_PROGRAMS += ratngs_test
 check_PROGRAMS += recodebeam_test
 check_PROGRAMS += recoder_test
 check_PROGRAMS += rect_test
@@ -1456,6 +1457,10 @@ qrsequence_test_SOURCES = unittest/qrsequence_test.cc
 qrsequence_test_CPPFLAGS = $(unittest_CPPFLAGS)
 qrsequence_test_LDADD = $(TESS_LIBS)

+ratngs_test_SOURCES = unittest/ratngs_test.cc
+ratngs_test_CPPFLAGS = $(unittest_CPPFLAGS)
+ratngs_test_LDADD = $(TESS_LIBS)
+
 recodebeam_test_SOURCES = unittest/recodebeam_test.cc
 recodebeam_test_CPPFLAGS = $(unittest_CPPFLAGS)
 recodebeam_test_LDADD = $(TRAINING_LIBS) $(ICU_I18N_LIBS) $(ICU_UC_LIBS)
diff --git a/src/ccstruct/pageres.cpp b/src/ccstruct/pageres.cpp
index 60e110b2..1dcb536c 100644
--- a/src/ccstruct/pageres.cpp
+++ b/src/ccstruct/pageres.cpp
@@ -936,7 +936,7 @@ void WERD_RES::FakeWordFromRatings(PermuterType permuter) {
     UNICHAR_ID unichar_id = UNICHAR_SPACE;
     // Initialize rating and certainty like in WERD_CHOICE::make_bad().
     float rating = WERD_CHOICE::kBadRating;
-    float certainty = -FLT_MAX;
+    float certainty = WERD_CHOICE::kBadCertainty;
     BLOB_CHOICE_LIST *choices = ratings->get(b, b);
     if (choices != nullptr && !choices->empty()) {
       BLOB_CHOICE_IT bc_it(choices);
diff --git a/src/ccstruct/ratngs.cpp b/src/ccstruct/ratngs.cpp
index 9cd8093a..e28cfed8 100644
--- a/src/ccstruct/ratngs.cpp
+++ b/src/ccstruct/ratngs.cpp
@@ -35,6 +35,7 @@
 namespace tesseract {

 const float WERD_CHOICE::kBadRating = 100000.0;
+const float WERD_CHOICE::kBadCertainty = -100000.0f;
 // Min offset in baseline-normalized coords to make a character a subscript.
 const int kMinSubscriptOffset = 20;
 // Min offset in baseline-normalized coords to make a character a superscript.
diff --git a/src/ccstruct/ratngs.h b/src/ccstruct/ratngs.h
index 2d3c2740..90021054 100644
--- a/src/ccstruct/ratngs.h
+++ b/src/ccstruct/ratngs.h
@@ -261,6 +261,12 @@ const char *ScriptPosToString(ScriptPos script_pos);
 class TESS_API WERD_CHOICE : public ELIST<WERD_CHOICE>::LINK {
 public:
   static const float kBadRating;
+  // Worst-case (most negative) certainty, mirroring kBadRating (highest rating).
+  // A deliberately finite value so that it can be summed and scaled without
+  // overflowing to -inf (which the tesseract CLI turns into a SIGFPE, since
+  // main1() enables the FE_OVERFLOW trap). See WERD_CHOICE::make_bad() and
+  // WERD_RES::FakeWordFromRatings().
+  static const float kBadCertainty;
   static const char *permuter_name(uint8_t permuter);

   WERD_CHOICE(const UNICHARSET *unicharset) : unicharset_(unicharset) {
@@ -423,7 +429,7 @@ public:
   inline void make_bad() {
     length_ = 0;
     rating_ = kBadRating;
-    certainty_ = -FLT_MAX;
+    certainty_ = kBadCertainty;
   }

   /// This function assumes that there is enough space reserved
diff --git a/unittest/ratngs_test.cc b/unittest/ratngs_test.cc
new file mode 100644
index 00000000..9b6d9fd4
--- /dev/null
+++ b/unittest/ratngs_test.cc
@@ -0,0 +1,83 @@
+// (C) Copyright 2026, Stefan Weil
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "include_gunit.h"
+
+#include "ratngs.h"
+#include "unicharset.h"
+
+#include <cfloat>
+#include <cmath>
+#include <limits>
+
+namespace tesseract {
+
+// Regression test for tesseract-ocr/tesseract#4627.
+//
+// A word that the recognizer could not classify carries the "bad" certainty
+// WERD_CHOICE::kBadCertainty. Textord::CleanupSingleRowResult() sums the
+// certainties of the words in a row, and the per-word confidence is computed
+// as 100 + 5 * certainty (AllWordConfidences, ChoiceIterator::Confidence).
+// With the old sentinel of -FLT_MAX, a row holding two such words overflowed
+// the float sum to -inf (and 5 * -FLT_MAX overflowed as well), which the
+// tesseract CLI turns into a SIGFPE because main1() enables the FE_OVERFLOW
+// trap, and which silently yields a -inf confidence for library users.
+// kBadCertainty must therefore be finite *and* small enough in magnitude that
+// these float sums and scalings cannot overflow.
+
+class RatngsTest : public ::testing::Test {
+protected:
+  void SetUp() override {
+    unicharset_.clear();
+    unicharset_.unichar_insert(" ");
+  }
+
+  // The certainty a word is given when it could not be classified.
+  float BadCertainty() const {
+    return WERD_CHOICE::kBadCertainty;
+  }
+
+  UNICHARSET unicharset_;
+};
+
+// The sentinel itself must be a finite float; it is the value a missing
+// classification is given (WERD_CHOICE::make_bad,
+// WERD_RES::FakeWordFromRatings).
+TEST_F(RatngsTest, BadCertaintyIsFinite) {
+  const float bad = BadCertainty();
+  EXPECT_TRUE(std::isfinite(bad));
+  // It is a "bad" (very low) certainty, i.e. negative.
+  EXPECT_LT(bad, 0.0f);
+  EXPECT_GT(bad, std::numeric_limits<float>::lowest());
+}
+
+// make_bad() (and, by construction, FakeWordFromRatings which reuses the same
+// constant) must leave a finite certainty that can be summed/scaled without
+// overflowing the float arithmetic used downstream.
+TEST_F(RatngsTest, BadWordCertaintyIsSummable) {
+  WERD_CHOICE bad(&unicharset_);
+  bad.make_bad();
+  const float cert = bad.certainty();
+  EXPECT_TRUE(std::isfinite(cert));
+  EXPECT_FLOAT_EQ(WERD_CHOICE::kBadRating, bad.rating());
+
+  // Two unclassified words in one row (Textord::CleanupSingleRowResult sums a
+  // float per row). With the former -FLT_MAX sentinel this was -inf.
+  EXPECT_TRUE(std::isfinite(cert + cert));
+  EXPECT_FALSE(std::isinf(cert + cert));
+
+  // Per-word confidence (AllWordConfidences / ChoiceIterator::Confidence).
+  // With the former -FLT_MAX sentinel 5 * cert overflowed to -inf.
+  EXPECT_TRUE(std::isfinite(100.0f + 5.0f * cert));
+  EXPECT_FALSE(std::isinf(100.0f + 5.0f * cert));
+}
+
+} // namespace tesseract.