use std::collections::HashMap;
use ndarray::ArrayView2;
use super::charset::Charset;
use super::ctc::{self, BLANK_CLASS, IgnoreMask};
use super::recognizer::RecognizedText;
const HIGH_SCORE_FRACTION: f32 = 0.5;
#[derive(Debug, Default, Clone, Copy)]
struct BeamEntry {
total: f32,
non_blank: f32,
blank: f32,
}
pub(super) fn decode_beam_search(
logits: ArrayView2<f32>,
charset: &Charset,
ignore_mask: &IgnoreMask,
beam_width: usize,
) -> RecognizedText {
let probabilities = ctc::probability_matrix(logits, ignore_mask);
let confidence = confidence_from(logits, ignore_mask);
let labeling = ctc_beam_search(probabilities.view(), beam_width.max(1));
let text = ctc::collapse_classes(labeling, charset);
RecognizedText { text, confidence }
}
fn confidence_from(logits: ArrayView2<f32>, ignore_mask: &IgnoreMask) -> f32 {
let mut weights: Vec<f32> = Vec::with_capacity(logits.ncols());
let per_timestep: Vec<(usize, f32)> = logits
.rows()
.into_iter()
.map(|row| ctc::decode_row(row, ignore_mask, &mut weights))
.collect();
ctc::custom_mean(&ctc::collect_max_probs(&per_timestep))
}
fn ctc_beam_search(matrix: ArrayView2<f32>, beam_width: usize) -> Vec<usize> {
let (timesteps, num_classes) = matrix.dim();
let high_score_floor = HIGH_SCORE_FRACTION / num_classes as f32;
let mut beams: HashMap<Vec<usize>, BeamEntry> = HashMap::new();
beams.insert(
Vec::new(),
BeamEntry {
total: 1.0,
non_blank: 0.0,
blank: 1.0,
},
);
for t in 0..timesteps {
let mut next: HashMap<Vec<usize>, BeamEntry> = HashMap::new();
let mut ranked: Vec<(Vec<usize>, BeamEntry)> = beams.into_iter().collect();
ranked.sort_by(|(a_labeling, a), (b_labeling, b)| rank_beams(a.total, a_labeling, b.total, b_labeling));
ranked.truncate(beam_width);
for (labeling, entry) in &ranked {
extend_beam(&mut next, labeling, entry, matrix.row(t), high_score_floor);
}
beams = next;
}
beams
.into_iter()
.min_by(|(a_labeling, a), (b_labeling, b)| rank_beams(a.total, a_labeling, b.total, b_labeling))
.map(|(labeling, _)| labeling)
.unwrap_or_default()
}
fn rank_beams(a_total: f32, a_labeling: &[usize], b_total: f32, b_labeling: &[usize]) -> std::cmp::Ordering {
b_total
.partial_cmp(&a_total)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a_labeling.cmp(b_labeling))
}
fn extend_beam(
next: &mut HashMap<Vec<usize>, BeamEntry>,
labeling: &[usize],
entry: &BeamEntry,
timestep: ndarray::ArrayView1<f32>,
high_score_floor: f32,
) {
let last_class = labeling.last().copied();
let continuation_non_blank = last_class.map_or(0.0, |last| entry.non_blank * timestep[last]);
let continuation_blank = entry.total * timestep[BLANK_CLASS];
let continuation = next.entry(labeling.to_vec()).or_default();
continuation.non_blank += continuation_non_blank;
continuation.blank += continuation_blank;
continuation.total += continuation_blank + continuation_non_blank;
for (class, &probability) in timestep.iter().enumerate() {
if probability < high_score_floor {
continue;
}
let extended = fast_simplify_label(labeling, class, BLANK_CLASS);
let mass = if last_class == Some(class) {
probability * entry.blank
} else {
probability * entry.total
};
let extension = next.entry(extended).or_default();
extension.non_blank += mass;
extension.total += mass;
}
}
fn fast_simplify_label(labeling: &[usize], c: usize, blank: usize) -> Vec<usize> {
match labeling.last() {
Some(&last) if c == blank && last != blank => append(labeling, c),
Some(&last) if c != blank && last == blank => {
if labeling[labeling.len() - 2] == c {
append(labeling, c)
} else {
replace_last(labeling, c)
}
}
Some(&last) if c == blank && last == blank => labeling.to_vec(),
Some(_) => append(labeling, c),
None if c == blank => Vec::new(),
None => vec![c],
}
}
fn append(labeling: &[usize], c: usize) -> Vec<usize> {
let mut extended = Vec::with_capacity(labeling.len() + 1);
extended.extend_from_slice(labeling);
extended.push(c);
extended
}
fn replace_last(labeling: &[usize], c: usize) -> Vec<usize> {
let mut replaced = labeling[..labeling.len() - 1].to_vec();
replaced.push(c);
replaced
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Language;
use ndarray::arr2;
fn english() -> Charset {
Charset::for_language(Language::English)
}
fn no_ignore(charset: &Charset) -> IgnoreMask {
IgnoreMask::new(charset.num_classes(), &[])
}
#[test]
fn should_leave_empty_labeling_unchanged_on_blank() {
assert_eq!(fast_simplify_label(&[], 0, 0), Vec::<usize>::new());
}
#[test]
fn should_append_first_non_blank_to_empty_labeling() {
assert_eq!(fast_simplify_label(&[], 3, 0), vec![3]);
}
#[test]
fn should_append_blank_after_a_non_blank_class() {
assert_eq!(fast_simplify_label(&[5], 0, 0), vec![5, 0]);
}
#[test]
fn should_leave_consecutive_blanks_unchanged() {
assert_eq!(fast_simplify_label(&[5, 0], 0, 0), vec![5, 0]);
}
#[test]
fn should_append_same_class_across_a_separating_blank() {
assert_eq!(fast_simplify_label(&[5, 0], 5, 0), vec![5, 0, 5]);
}
#[test]
fn should_drop_a_separating_blank_between_different_classes() {
assert_eq!(fast_simplify_label(&[5, 0], 7, 0), vec![5, 7]);
}
#[test]
fn should_append_a_repeated_non_blank_class_without_a_separator() {
assert_eq!(fast_simplify_label(&[5], 5, 0), vec![5, 5]);
}
#[test]
fn should_append_a_different_non_blank_class() {
assert_eq!(fast_simplify_label(&[5], 7, 0), vec![5, 7]);
}
#[test]
fn should_decode_a_single_dominant_path_like_greedy() {
let charset = english();
let mask = no_ignore(&charset);
let logits = arr2(&[[0.0f32, 8.0, 0.0], [0.0, 0.0, 8.0]]);
let beam = decode_beam_search(logits.view(), &charset, &mask, 5);
let greedy = super::super::ctc::decode_greedy_with_mask(logits.view(), &charset, &mask);
assert_eq!(beam.text, greedy.text);
assert_eq!(beam.text, "01");
}
#[test]
fn should_recover_a_label_that_best_path_greedy_misses() {
let charset = english();
let mask = no_ignore(&charset);
let row = [(0.4f32).ln(), (0.35f32).ln(), (0.25f32).ln()];
let logits = arr2(&[row, row, row]);
let greedy = super::super::ctc::decode_greedy_with_mask(logits.view(), &charset, &mask);
assert_eq!(greedy.text, "", "the single most likely path is all-blank");
let beam = decode_beam_search(logits.view(), &charset, &mask, 5);
assert_eq!(beam.text, "0", "class 1 ('0') sums the most path mass across timesteps");
}
#[test]
fn should_use_the_same_confidence_as_greedy_decoding() {
let charset = english();
let mask = no_ignore(&charset);
let logits = arr2(&[[0.0f32, 8.0, 0.0], [0.0, 0.0, 8.0]]);
let beam = decode_beam_search(logits.view(), &charset, &mask, 5);
let greedy = super::super::ctc::decode_greedy_with_mask(logits.view(), &charset, &mask);
assert_eq!(beam.confidence.to_bits(), greedy.confidence.to_bits());
}
#[test]
fn should_respect_the_ignore_mask() {
let charset = english();
let ignored = [1usize];
let mask = IgnoreMask::new(charset.num_classes(), &ignored);
let logits = arr2(&[[(0.1f32).ln(), (0.6f32).ln(), (0.3f32).ln()]]);
let beam = decode_beam_search(logits.view(), &charset, &mask, 5);
assert_eq!(beam.text, "1");
}
#[test]
fn should_return_empty_text_for_an_all_blank_input() {
let charset = english();
let mask = no_ignore(&charset);
let logits = arr2(&[[5.0f32, 0.0, 0.0], [5.0, 0.0, 0.0]]);
let beam = decode_beam_search(logits.view(), &charset, &mask, 5);
assert_eq!(beam.text, "");
assert_eq!(beam.confidence, 0.0);
}
#[test]
fn should_decode_deterministically_across_repeated_calls() {
let charset = english();
let mask = no_ignore(&charset);
let row = [(0.2f32).ln(), (0.4f32).ln(), (0.4f32).ln()];
let logits = arr2(&[row, row, row, row]);
let first = decode_beam_search(logits.view(), &charset, &mask, 1);
for _ in 0..50 {
let repeat = decode_beam_search(logits.view(), &charset, &mask, 1);
assert_eq!(
repeat.text, first.text,
"beam search must decode the same crop identically every call"
);
}
}
#[test]
fn should_tolerate_a_beam_width_of_one() {
let charset = english();
let mask = no_ignore(&charset);
let logits = arr2(&[[0.0f32, 8.0, 0.0], [0.0, 0.0, 8.0]]);
let beam = decode_beam_search(logits.view(), &charset, &mask, 1);
assert_eq!(beam.text, "01");
}
}