Skip to main content

rlx_ocr/
ctc.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! CTC decoding (greedy + beam search).
17
18use crate::config::DecodeMethod;
19use rten_tensor::NdTensorView;
20use rten_tensor::prelude::*;
21use std::collections::HashMap;
22use std::num::NonZeroU32;
23
24/// Item in a decoded sequence.
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
26pub struct CtcStep {
27    pub label: u32,
28    pub pos: u32,
29}
30
31/// Decoded label sequence with log score.
32#[derive(Clone, Debug)]
33pub struct CtcHypothesis {
34    steps: Vec<CtcStep>,
35    score: f32,
36}
37
38impl CtcHypothesis {
39    pub fn steps(&self) -> &[CtcStep] {
40        &self.steps
41    }
42
43    pub fn score(&self) -> f32 {
44        self.score
45    }
46}
47
48/// Decode a `[seq, classes]` log-probability matrix.
49pub fn decode(input_seq: NdTensorView<f32, 2>, method: DecodeMethod) -> CtcHypothesis {
50    // Debug knob: `OCR_DECODE=greedy` or `OCR_DECODE=beam:<width>` overrides the
51    // configured method (lets the benches A/B greedy vs beam without rewiring).
52    let method = std::env::var("OCR_DECODE")
53        .ok()
54        .and_then(|s| {
55            let s = s.trim();
56            if s.eq_ignore_ascii_case("greedy") {
57                Some(DecodeMethod::Greedy)
58            } else if let Some(w) = s.strip_prefix("beam:") {
59                Some(DecodeMethod::BeamSearch {
60                    width: w.trim().parse().ok()?,
61                })
62            } else {
63                None
64            }
65        })
66        .unwrap_or(method);
67    match method {
68        DecodeMethod::Greedy => decode_greedy(input_seq),
69        DecodeMethod::BeamSearch { width } => {
70            decode_beam(input_seq, width.max(1)).unwrap_or_else(|| decode_greedy(input_seq))
71        }
72    }
73}
74
75fn decode_greedy(prob_seq: NdTensorView<f32, 2>) -> CtcHypothesis {
76    let mut last_label = 0;
77    let mut steps = Vec::new();
78    let mut score = 0.;
79
80    for pos in 0..prob_seq.size(0) {
81        let mut best_label = 0usize;
82        let mut best_lp = prob_seq[[pos, 0]];
83        for label in 1..prob_seq.size(1) {
84            let lp = prob_seq[[pos, label]];
85            if lp > best_lp {
86                best_lp = lp;
87                best_label = label;
88            }
89        }
90        let label = best_label;
91        score += best_lp;
92        if label == last_label {
93            continue;
94        }
95        last_label = label;
96        if label > 0 {
97            steps.push(CtcStep {
98                label: label as u32,
99                pos: pos as u32,
100            });
101        }
102    }
103
104    CtcHypothesis { steps, score }
105}
106
107#[derive(Debug)]
108struct BeamProbs {
109    prob_blank: f32,
110    prob_no_blank: f32,
111}
112
113fn log_sum_exp<const N: usize>(log_probs: [f32; N]) -> f32 {
114    if log_probs.iter().all(|&x| x == f32::NEG_INFINITY) {
115        f32::NEG_INFINITY
116    } else {
117        let lp_max = log_probs
118            .into_iter()
119            .reduce(f32::max)
120            .unwrap_or(f32::NEG_INFINITY);
121        lp_max
122            + log_probs
123                .iter()
124                .map(|x| (x - lp_max).exp())
125                .sum::<f32>()
126                .ln()
127    }
128}
129
130fn decode_beam(prob_seq: NdTensorView<f32, 2>, beam_size: u32) -> Option<CtcHypothesis> {
131    let beam_size = NonZeroU32::new(beam_size)?;
132    let mut states: HashMap<Vec<CtcStep>, BeamProbs> = HashMap::new();
133    states.insert(
134        Vec::new(),
135        BeamProbs {
136            prob_blank: 0.,
137            prob_no_blank: f32::NEG_INFINITY,
138        },
139    );
140
141    for t in 0..prob_seq.size(0) {
142        let mut next: HashMap<Vec<CtcStep>, BeamProbs> = HashMap::new();
143        let blank_lp = prob_seq[[t, 0]];
144        for (prefix, state) in &states {
145            let p_b = state.prob_blank;
146            let p_nb = state.prob_no_blank;
147            merge_beam(
148                &mut next,
149                prefix.clone(),
150                log_sum_exp([p_b + blank_lp, p_nb + blank_lp]),
151                f32::NEG_INFINITY,
152            );
153            for label in 1..prob_seq.size(1) {
154                let lp = prob_seq[[t, label]];
155                let mut new_prefix = prefix.clone();
156                let step = CtcStep {
157                    label: label as u32,
158                    pos: t as u32,
159                };
160                let last = new_prefix.last().map(|s| s.label);
161                if last != Some(step.label) {
162                    new_prefix.push(step);
163                }
164                let (nb_lp, b_lp) = if last == Some(step.label) {
165                    (p_nb + lp, p_b + lp)
166                } else {
167                    (log_sum_exp([p_b + lp, p_nb + lp]), f32::NEG_INFINITY)
168                };
169                merge_beam(&mut next, new_prefix, b_lp, nb_lp);
170            }
171        }
172        let mut ranked: Vec<_> = next.into_iter().collect();
173        ranked.sort_by(|(_, a), (_, b)| {
174            let sa = log_sum_exp([a.prob_blank, a.prob_no_blank]);
175            let sb = log_sum_exp([b.prob_blank, b.prob_no_blank]);
176            sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
177        });
178        ranked.truncate(beam_size.get() as usize);
179        states = ranked.into_iter().collect();
180    }
181
182    let (prefix, probs) = states.into_iter().max_by(|(_, a), (_, b)| {
183        let sa = log_sum_exp([a.prob_blank, a.prob_no_blank]);
184        let sb = log_sum_exp([b.prob_blank, b.prob_no_blank]);
185        sa.partial_cmp(&sb).unwrap_or(std::cmp::Ordering::Equal)
186    })?;
187    Some(CtcHypothesis {
188        steps: prefix,
189        score: log_sum_exp([probs.prob_blank, probs.prob_no_blank]),
190    })
191}
192
193fn merge_beam(map: &mut HashMap<Vec<CtcStep>, BeamProbs>, prefix: Vec<CtcStep>, pb: f32, pnb: f32) {
194    use std::collections::hash_map::Entry;
195    match map.entry(prefix) {
196        Entry::Vacant(e) => {
197            e.insert(BeamProbs {
198                prob_blank: pb,
199                prob_no_blank: pnb,
200            });
201        }
202        Entry::Occupied(mut e) => {
203            let s = e.get_mut();
204            s.prob_blank = log_sum_exp([s.prob_blank, pb]);
205            s.prob_no_blank = log_sum_exp([s.prob_no_blank, pnb]);
206        }
207    }
208}