From f5e49393c2840a1bd0013c8a2e81536d82183d4e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:19:01 +0000 Subject: [PATCH 1/5] Initial plan From 31bbcae4e7c7a1cfd5b654898ca086f37475d14c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:28:22 +0000 Subject: [PATCH 2/5] Fix diplopia: filter ghost duplicate characters from LSTM output When the LSTM network is uncertain between two similar-looking characters (e.g., O and 0, l and 1), it may output both at adjacent timesteps. This "diplopia" effect produces duplicate characters in the output. Fix by detecting pairs of adjacent characters with no null gap between them where at least one occupies only a single timestep, and removing the one with lower certainty (the ghost character). --- src/lstm/recodebeam.cpp | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/lstm/recodebeam.cpp b/src/lstm/recodebeam.cpp index 448d3d8bee..848a11692f 100644 --- a/src/lstm/recodebeam.cpp +++ b/src/lstm/recodebeam.cpp @@ -623,6 +623,33 @@ void RecodeBeamSearch::ExtractPathAsUnicharIds( } } starts.push_back(width); + // Filter "diplopia" ghost characters: when two adjacent characters have no + // null gap between them (starts[i+1] == ends[i]) and one occupies only a + // single timestep, remove the one with lower certainty, as it is likely a + // duplicate caused by the network being uncertain between two similar chars. + for (int i = static_cast(unichar_ids->size()) - 2; i >= 0; --i) { + // Skip pairs involving space characters. + if ((*unichar_ids)[i] == UNICHAR_SPACE || + (*unichar_ids)[i + 1] == UNICHAR_SPACE) { + continue; + } + // Check if there is no null gap between character i and character i+1. + if (starts[i + 1] == ends[i]) { + int width_i = ends[i] - static_cast((*xcoords)[i]); + int width_next = ends[i + 1] - static_cast((*xcoords)[i + 1]); + // Only filter if at least one of the pair occupies a single timestep. + if (width_i == 1 || width_next == 1) { + // Remove the character with lower certainty (more negative). + int to_remove = ((*certs)[i] < (*certs)[i + 1]) ? i : i + 1; + unichar_ids->erase(unichar_ids->begin() + to_remove); + certs->erase(certs->begin() + to_remove); + ratings->erase(ratings->begin() + to_remove); + xcoords->erase(xcoords->begin() + to_remove); + starts.erase(starts.begin() + to_remove); + ends.erase(ends.begin() + to_remove); + } + } + } if (character_boundaries != nullptr) { calculateCharBoundaries(&starts, &ends, character_boundaries, width); } From 06543e5824b8e534337e9bece3d481eeed7d6a7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:28:22 +0000 Subject: [PATCH 3/5] Fix diplopia: filter ghost duplicate characters from LSTM output Apply simpler diplopia resolution in ExtractBestPathAsLabels: when two different characters have a gap of 1 in xcoords, the earlier character likely expanded into the later position, so remove the later (spurious) character. This catches cases where the LSTM network is uncertain between visually similar alternatives (e.g., O/0, l/1). --- src/lstm/recodebeam.cpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/lstm/recodebeam.cpp b/src/lstm/recodebeam.cpp index 848a11692f..cdb86ad6ea 100644 --- a/src/lstm/recodebeam.cpp +++ b/src/lstm/recodebeam.cpp @@ -197,6 +197,9 @@ void RecodeBeamSearch::calculateCharBoundaries(std::vector *starts, } // Returns the best path as labels/scores/xcoords similar to simple CTC. +// Diplopia resolution removes spurious characters that appear at overlapping +// positions (gap of 1 in xcoords) with another character, keeping only the +// earlier emission. void RecodeBeamSearch::ExtractBestPathAsLabels( std::vector *labels, std::vector *xcoords) const { labels->clear(); @@ -216,6 +219,21 @@ void RecodeBeamSearch::ExtractBestPathAsLabels( } } xcoords->push_back(width); + + // Diplopia resolution: remove spurious characters at overlapping positions. + // If two different characters have a gap of 1 in xcoords, the earlier + // character likely expanded into the later position, so remove the later. + size_t i = 0; + while (i + 1 < labels->size()) { + int gap = (*xcoords)[i + 1] - (*xcoords)[i]; + if (gap == 1 && (*labels)[i] != (*labels)[i + 1]) { + // Likely diplopia: remove the later (spurious) character. + labels->erase(labels->begin() + i + 1); + xcoords->erase(xcoords->begin() + i + 1); + } else { + ++i; + } + } } // Returns the best path as unichar-ids/certs/ratings/xcoords skipping From 2c80dceac0b051ef6e31345961b4a1cc37e21ad0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:19:37 +0000 Subject: [PATCH 4/5] Revert diplopia fix: too aggressive, breaks recodebeam_test The gap==1 heuristic catches legitimate cases in CJK, Indic, and Latin scripts (with combining characters) where characters legitimately have single-timestep width or touch each other without a null gap. Revert the change until a more targeted approach can be developed. --- src/lstm/recodebeam.cpp | 45 ----------------------------------------- 1 file changed, 45 deletions(-) diff --git a/src/lstm/recodebeam.cpp b/src/lstm/recodebeam.cpp index cdb86ad6ea..448d3d8bee 100644 --- a/src/lstm/recodebeam.cpp +++ b/src/lstm/recodebeam.cpp @@ -197,9 +197,6 @@ void RecodeBeamSearch::calculateCharBoundaries(std::vector *starts, } // Returns the best path as labels/scores/xcoords similar to simple CTC. -// Diplopia resolution removes spurious characters that appear at overlapping -// positions (gap of 1 in xcoords) with another character, keeping only the -// earlier emission. void RecodeBeamSearch::ExtractBestPathAsLabels( std::vector *labels, std::vector *xcoords) const { labels->clear(); @@ -219,21 +216,6 @@ void RecodeBeamSearch::ExtractBestPathAsLabels( } } xcoords->push_back(width); - - // Diplopia resolution: remove spurious characters at overlapping positions. - // If two different characters have a gap of 1 in xcoords, the earlier - // character likely expanded into the later position, so remove the later. - size_t i = 0; - while (i + 1 < labels->size()) { - int gap = (*xcoords)[i + 1] - (*xcoords)[i]; - if (gap == 1 && (*labels)[i] != (*labels)[i + 1]) { - // Likely diplopia: remove the later (spurious) character. - labels->erase(labels->begin() + i + 1); - xcoords->erase(xcoords->begin() + i + 1); - } else { - ++i; - } - } } // Returns the best path as unichar-ids/certs/ratings/xcoords skipping @@ -641,33 +623,6 @@ void RecodeBeamSearch::ExtractPathAsUnicharIds( } } starts.push_back(width); - // Filter "diplopia" ghost characters: when two adjacent characters have no - // null gap between them (starts[i+1] == ends[i]) and one occupies only a - // single timestep, remove the one with lower certainty, as it is likely a - // duplicate caused by the network being uncertain between two similar chars. - for (int i = static_cast(unichar_ids->size()) - 2; i >= 0; --i) { - // Skip pairs involving space characters. - if ((*unichar_ids)[i] == UNICHAR_SPACE || - (*unichar_ids)[i + 1] == UNICHAR_SPACE) { - continue; - } - // Check if there is no null gap between character i and character i+1. - if (starts[i + 1] == ends[i]) { - int width_i = ends[i] - static_cast((*xcoords)[i]); - int width_next = ends[i + 1] - static_cast((*xcoords)[i + 1]); - // Only filter if at least one of the pair occupies a single timestep. - if (width_i == 1 || width_next == 1) { - // Remove the character with lower certainty (more negative). - int to_remove = ((*certs)[i] < (*certs)[i + 1]) ? i : i + 1; - unichar_ids->erase(unichar_ids->begin() + to_remove); - certs->erase(certs->begin() + to_remove); - ratings->erase(ratings->begin() + to_remove); - xcoords->erase(xcoords->begin() + to_remove); - starts.erase(starts.begin() + to_remove); - ends.erase(ends.begin() + to_remove); - } - } - } if (character_boundaries != nullptr) { calculateCharBoundaries(&starts, &ends, character_boundaries, width); } From 45b99ed41c03cbf6a4ad158148dad663eb3fb887 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:31:16 +0000 Subject: [PATCH 5/5] Fix diplopia with certainty-based filtering in ExtractPathAsUnicharIds Apply diplopia resolution at the unichar_id level where full characters are decoded, requiring a significant certainty difference (>2.5) between adjacent characters with no null gap and single-timestep width. This avoids false positives on legitimate CJK, Indic, and combining character sequences that have adjacent codes at the label level. --- src/lstm/recodebeam.cpp | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/lstm/recodebeam.cpp b/src/lstm/recodebeam.cpp index 448d3d8bee..4119accafe 100644 --- a/src/lstm/recodebeam.cpp +++ b/src/lstm/recodebeam.cpp @@ -623,6 +623,43 @@ void RecodeBeamSearch::ExtractPathAsUnicharIds( } } starts.push_back(width); + // Diplopia resolution: remove ghost characters caused by the network emitting + // two different characters at adjacent timesteps when uncertain between + // visually similar alternatives (e.g., O/0, l/1). A ghost is identified when: + // - Two adjacent characters have no null gap between them. + // - At least one occupies only a single timestep. + // - The certainty difference is large (one is clearly a ghost). + // - Neither is a space. + static constexpr float kDiplopiaCertDiff = 2.5f; + for (int i = static_cast(unichar_ids->size()) - 2; i >= 0; --i) { + if ((*unichar_ids)[i] == UNICHAR_SPACE || + (*unichar_ids)[i + 1] == UNICHAR_SPACE) { + continue; + } + if (starts[i + 1] == ends[i]) { + int width_i = ends[i] - starts[i]; + int width_next = ends[i + 1] - starts[i + 1]; + if (width_i == 1 || width_next == 1) { + float cert_diff = (*certs)[i] - (*certs)[i + 1]; + int to_remove = -1; + if (cert_diff < -kDiplopiaCertDiff) { + // Character i has much lower certainty: it's the ghost. + to_remove = i; + } else if (cert_diff > kDiplopiaCertDiff) { + // Character i+1 has much lower certainty: it's the ghost. + to_remove = i + 1; + } + if (to_remove >= 0) { + unichar_ids->erase(unichar_ids->begin() + to_remove); + certs->erase(certs->begin() + to_remove); + ratings->erase(ratings->begin() + to_remove); + xcoords->erase(xcoords->begin() + to_remove); + starts.erase(starts.begin() + to_remove); + ends.erase(ends.begin() + to_remove); + } + } + } + } if (character_boundaries != nullptr) { calculateCharBoundaries(&starts, &ends, character_boundaries, width); }