Skip to content

Fix diplopia: certainty-based ghost character filtering in LSTM beam search - #4578

Open
stweil with Copilot wants to merge 5 commits into
mainfrom
copilot/fix-duplicate-characters-output
Open

Fix diplopia: certainty-based ghost character filtering in LSTM beam search#4578
stweil with Copilot wants to merge 5 commits into
mainfrom
copilot/fix-duplicate-characters-output

Conversation

Copilot AI commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

LSTM beam search can emit two different characters at adjacent timesteps when uncertain between visually similar alternatives (e.g., O/0, l/1, O/Q). CTC only collapses repeated identical codes, so different codes representing similar glyphs both survive into output text.

Approach

Previous attempt operated at the labels level in ExtractBestPathAsLabels using a gap==1 heuristic. This was too aggressive — it caught legitimate multi-code sequences in CJK, Indic, and Latin combining characters where intermediate codes naturally have gap==1.

The fix now operates in ExtractPathAsUnicharIds where characters are fully decoded, with a certainty-based threshold to distinguish true ghosts from legitimate adjacent characters.

Detection criteria

A character is removed as a diplopia ghost only when all conditions hold:

  • Two adjacent decoded characters have no null gap between them (starts[i+1] == ends[i])
  • At least one occupies a single timestep
  • Certainty difference exceeds 2.5 (the ghost is clearly less confident)
  • Neither character is a space
static constexpr float kDiplopiaCertDiff = 2.5f;
for (int i = static_cast<int>(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) to_remove = i;
      else if (cert_diff > kDiplopiaCertDiff) to_remove = i + 1;
      if (to_remove >= 0) { /* erase from all parallel vectors */ }
    }
  }
}

Testing

All recodebeam_test cases (Chinese, Japanese, Korean, Kannada, Marathi, English) and lstm_recode_test cases pass.

Copilot AI linked an issue Jul 5, 2026 that may be closed by this pull request
@codacy-production

codacy-production Bot commented Jul 5, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 12 complexity · 0 duplication

Metric Results
Complexity 12
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

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).
Copilot AI changed the title [WIP] Fix duplicate characters in output stream Fix diplopia: filter ghost duplicate characters from LSTM beam search output Jul 5, 2026
Copilot finished work on behalf of stweil July 5, 2026 15:32
Copilot AI requested a review from stweil July 5, 2026 15:32

@stweil stweil left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot, would the following patch be also a possible solution?

diff --git a/src/lstm/recodebeam.cpp b/src/lstm/recodebeam.cpp
index 448d3d8be..b5daecf32 100644
--- a/src/lstm/recodebeam.cpp
+++ b/src/lstm/recodebeam.cpp
@@ -197,6 +197,9 @@ void RecodeBeamSearch::calculateCharBoundaries(std::vector<int> *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
+// higher certainty emission.
 void RecodeBeamSearch::ExtractBestPathAsLabels(
     std::vector<int> *labels, std::vector<int> *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.
+  int 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

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).

Copilot AI commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

@copilot, would the following patch be also a possible solution?

diff --git a/src/lstm/recodebeam.cpp b/src/lstm/recodebeam.cpp...

Yes, that's a cleaner approach — operating at the labels level in ExtractBestPathAsLabels is simpler and catches the issue earlier in the pipeline. I've adopted your suggested patch (with a minor fix: using size_t for the loop variable to avoid a signed/unsigned comparison warning). See commit fbc9086e.

Copilot finished work on behalf of stweil July 5, 2026 15:50
Copilot AI requested a review from stweil July 5, 2026 15:50
@stweil
stweil marked this pull request as ready for review July 5, 2026 15:54
@stweil

stweil commented Jul 7, 2026

Copy link
Copy Markdown
Member

@copilot, these conditions catch 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. Therefore the recodebeam_test fails now.

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.

Copilot AI commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

@copilot, these conditions catch legitimate cases in CJK, Indic, and Latin scripts (with combining characters) where characters legitimately have single-timestep width or touch each other without a nu...

You're right — the gap==1 heuristic is too broad and catches legitimate single-timestep characters in CJK, Indic, and combining character sequences. I've reverted the change entirely in the latest commit. A more targeted approach would need additional context (e.g., visual similarity between the two characters, or confidence-based filtering) to distinguish true diplopia from legitimate adjacent characters.

Copilot AI changed the title Fix diplopia: filter ghost duplicate characters from LSTM beam search output Revert diplopia fix: gap==1 heuristic is too aggressive Jul 7, 2026
Copilot finished work on behalf of stweil July 7, 2026 20:20

Copilot AI commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

@copilot, these conditions catch legitimate cases in CJK, Indic, and Latin scripts (with combining characters) where characters legitimately have single-timestep width or touch each other without a nu...

You're right. The gap==1 heuristic at the labels level catches legitimate multi-code sequences (CJK, Indic, combining characters) where intermediate codes naturally have gap==1 between them.

I've moved the fix to ExtractPathAsUnicharIds where we have fully decoded unichar_ids, and added a certainty threshold (difference > 2.5) so it only removes a character when one is clearly a ghost with much lower confidence. The conditions are now:

  1. No null gap between adjacent decoded characters (starts[i+1] == ends[i])
  2. At least one occupies a single timestep
  3. Significant certainty difference between the pair (> 2.5)
  4. Neither is a space

All 6 recodebeam_test cases and 3 lstm_recode_test cases pass. See commit in latest push.

Copilot AI changed the title Revert diplopia fix: gap==1 heuristic is too aggressive Fix diplopia: certainty-based ghost character filtering in LSTM beam search Jul 7, 2026
Copilot finished work on behalf of stweil July 7, 2026 20:35
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.
@stweil
stweil force-pushed the copilot/fix-duplicate-characters-output branch from 94ea32c to 45b99ed Compare July 8, 2026 04:52
@stweil stweil added the diplopia label Jul 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Duplicate Characters in Output Stream

2 participants