/// Pure-Dart CTC (Connectionist Temporal Classification) greedy decoder. /// /// Decodes per-timestep class logits into a string by taking the argmax at /// each timestep, collapsing consecutive duplicate classes, dropping the /// blank class, and mapping the remaining class indices to characters. /// /// Index mapping note (PaddleOCR PP-OCR rec convention with [blankIndex] == 0): /// the CTC blank occupies class index 0, so the character dictionary is /// shifted by one. The character for class index `k` (k >= 1) is /// `charset[k - 1]`. If [blankIndex] != 0, this exact shift may not apply and /// the mapping should be reviewed for the specific exported model. class CtcDecoder { CtcDecoder(this.charset, {this.blankIndex = 0}); /// The character dictionary (without the blank entry). final List charset; /// The class index reserved for the CTC blank symbol. final int blankIndex; /// Decode `[T][C]` logits into a string. /// /// For each timestep the argmax over the `C` classes is taken; consecutive /// duplicate indices are collapsed and the blank index is dropped. Remaining /// indices are mapped to characters via the dictionary shift described in the /// class docs. Out-of-range indices are skipped. String decode(List> logits) { final buffer = StringBuffer(); var previousIndex = -1; for (final row in logits) { if (row.isEmpty) { previousIndex = -1; continue; } // argmax over the classes of this timestep. var bestIndex = 0; var bestValue = row[0]; for (var c = 1; c < row.length; c++) { if (row[c] > bestValue) { bestValue = row[c]; bestIndex = c; } } // Collapse consecutive duplicates. if (bestIndex == previousIndex) { continue; } previousIndex = bestIndex; // Drop the blank class. if (bestIndex == blankIndex) { continue; } final ch = _charForIndex(bestIndex); if (ch != null) { buffer.write(ch); } } return buffer.toString(); } /// Reshape a flat row-major `[T*C]` list into `[T][C]` and decode it. String decodeFlat(List flat, int timeSteps, int numClasses) { if (timeSteps <= 0 || numClasses <= 0) return ''; final logits = >[]; for (var t = 0; t < timeSteps; t++) { final start = t * numClasses; final end = start + numClasses; if (end > flat.length) break; logits.add(flat.sublist(start, end)); } return decode(logits); } /// Map a class index to its character, applying the blank shift. Returns null /// for the blank index or out-of-range indices. String? _charForIndex(int index) { if (index == blankIndex) return null; // With blankIndex == 0 the dictionary is shifted by one: class index k // maps to charset[k - 1]. For other blank positions we fall back to a // direct index, which may need adjustment per the exported model. final mapped = blankIndex == 0 ? index - 1 : index; if (mapped < 0 || mapped >= charset.length) return null; return charset[mapped]; } }