Skip to main content

rlx_ocr/model/
recognition.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//! ocrs text-recognition CRNN + bidirectional GRU.
17
18use super::weights::{OcrGraphBuilder, assert_weights_drained};
19use anyhow::Result;
20use rlx_core::vision_ops_ir::{avg_pool2d, conv2d_bias, max_pool2d_2x2};
21use rlx_core::weight_map::WeightMap;
22use rlx_ir::hir::{HirMut, HirNodeId};
23use rlx_ir::{DType, HirGraphExt, Shape};
24
25pub const RECOGNITION_HEIGHT: usize = 64;
26pub const NUM_CLASSES: usize = 97;
27const HIDDEN: usize = 256;
28const FEAT: usize = 128;
29
30#[derive(Clone, Copy, Debug)]
31pub struct RecognitionGraphConfig {
32    pub batch: usize,
33    pub width: usize,
34}
35
36/// Where `build_recognition_graph_inner` stops emitting — the bisect/stage
37/// tests build prefixes of the full recognition graph.
38#[derive(Clone, Copy, PartialEq, Eq)]
39enum Stage {
40    AfterG1,
41    AfterG2,
42    AfterLogits,
43}
44
45fn build_recognition_conv_front(
46    b: &mut OcrGraphBuilder,
47    wm: &mut WeightMap,
48    image: HirNodeId,
49    batch: usize,
50    mut h: usize,
51    mut w: usize,
52) -> Result<(HirNodeId, usize)> {
53    let mut x = conv_relu(
54        b,
55        wm,
56        image,
57        "conv.0.weight",
58        "conv.0.bias",
59        batch,
60        32,
61        1,
62        h,
63        w,
64    )?;
65    x = max_pool2d_2x2(&mut b.m(), x, batch, 32, h, w);
66    h /= 2;
67    w /= 2;
68
69    x = conv_relu(
70        b,
71        wm,
72        x,
73        "onnx::Conv_367",
74        "onnx::Conv_368",
75        batch,
76        64,
77        32,
78        h,
79        w,
80    )?;
81    x = max_pool2d_2x2(&mut b.m(), x, batch, 64, h, w);
82    h /= 2;
83    w /= 2;
84
85    x = conv_relu(
86        b,
87        wm,
88        x,
89        "conv.7.weight",
90        "conv.7.bias",
91        batch,
92        128,
93        64,
94        h,
95        w,
96    )?;
97    x = conv_relu(
98        b,
99        wm,
100        x,
101        "onnx::Conv_370",
102        "onnx::Conv_371",
103        batch,
104        128,
105        128,
106        h,
107        w,
108    )?;
109    x = pool_2x1(&mut b.m(), x, batch, 128, h, w);
110    h /= 2;
111
112    x = conv_relu(
113        b,
114        wm,
115        x,
116        "conv.13.weight",
117        "conv.13.bias",
118        batch,
119        128,
120        128,
121        h,
122        w,
123    )?;
124    x = conv_relu(
125        b,
126        wm,
127        x,
128        "onnx::Conv_373",
129        "onnx::Conv_374",
130        batch,
131        128,
132        128,
133        h,
134        w,
135    )?;
136    x = pool_2x1(&mut b.m(), x, batch, 128, h, w);
137    h /= 2;
138
139    x = fused_conv2x2(
140        b,
141        wm,
142        x,
143        "onnx::Conv_376",
144        "onnx::Conv_377",
145        batch,
146        128,
147        128,
148        h,
149        w,
150    )?;
151    h += 1;
152    w += 1;
153    x = avg_pool2d(&mut b.m(), x, [4, 1], [4, 1], batch, 128, h, w);
154    let seq = w;
155    let x = b
156        .m()
157        .reshape_(x, vec![batch as i64, FEAT as i64, seq as i64]);
158    let x = b.m().transpose_(x, vec![2, 0, 1]);
159    Ok((x, seq))
160}
161
162/// Conv stack only; output `[seq, batch, 128]` (GRU input layout).
163pub fn build_recognition_conv_graph(
164    wm: &mut WeightMap,
165    cfg: RecognitionGraphConfig,
166) -> Result<(rlx_ir::Graph, std::collections::HashMap<String, Vec<f32>>)> {
167    let mut b = OcrGraphBuilder::new("ocr_recognition_conv");
168    let batch = cfg.batch;
169    let h = RECOGNITION_HEIGHT;
170    let w = cfg.width;
171    let image = b
172        .m()
173        .input("image", Shape::new(&[batch, 1, h, w], DType::F32));
174    let (x, _seq) = build_recognition_conv_front(&mut b, wm, image, batch, h, w)?;
175    b.m().set_outputs(vec![x]);
176    b.finish()
177}
178
179/// Recognition graph ending after the first bidirectional GRU (`[seq, batch, 512]`).
180pub fn build_recognition_after_g1_graph(
181    wm: &mut WeightMap,
182    cfg: RecognitionGraphConfig,
183) -> Result<(rlx_ir::Graph, std::collections::HashMap<String, Vec<f32>>)> {
184    build_recognition_graph_inner(wm, cfg, Some(Stage::AfterG1))
185}
186
187/// Recognition graph ending after the second GRU (`[seq, batch, 512]`).
188pub fn build_recognition_after_g2_graph(
189    wm: &mut WeightMap,
190    cfg: RecognitionGraphConfig,
191) -> Result<(rlx_ir::Graph, std::collections::HashMap<String, Vec<f32>>)> {
192    build_recognition_graph_inner(wm, cfg, Some(Stage::AfterG2))
193}
194
195/// Recognition graph ending after the linear head (`[seq, batch, classes]` logits).
196pub fn build_recognition_after_logits_graph(
197    wm: &mut WeightMap,
198    cfg: RecognitionGraphConfig,
199) -> Result<(rlx_ir::Graph, std::collections::HashMap<String, Vec<f32>>)> {
200    build_recognition_graph_inner(wm, cfg, Some(Stage::AfterLogits))
201}
202
203pub fn build_recognition_graph(
204    wm: &mut WeightMap,
205    cfg: RecognitionGraphConfig,
206) -> Result<(rlx_ir::Graph, std::collections::HashMap<String, Vec<f32>>)> {
207    build_recognition_graph_inner(wm, cfg, None)
208}
209
210fn build_recognition_graph_inner(
211    wm: &mut WeightMap,
212    cfg: RecognitionGraphConfig,
213    stop: Option<Stage>,
214) -> Result<(rlx_ir::Graph, std::collections::HashMap<String, Vec<f32>>)> {
215    let mut b = OcrGraphBuilder::new("ocr_recognition");
216    let batch = cfg.batch;
217    let h = RECOGNITION_HEIGHT;
218    let w = cfg.width;
219
220    let image = b
221        .m()
222        .input("image", Shape::new(&[batch, 1, h, w], DType::F32));
223
224    let (x, seq) = build_recognition_conv_front(&mut b, wm, image, batch, h, w)?;
225
226    // Two stacked bidirectional GRUs on the native rlx `Op::Gru`. The conv front
227    // is seq-first `[seq, batch, FEAT]`; rlx GRU is batch-first, so transpose
228    // around it. ocrs ships ONNX-layout GRU weights (gate order z,r,h) which
229    // `gru_layer` repacks to rlx/PyTorch layout.
230    let xb = b.m().transpose_(x, vec![1, 0, 2]); // [batch, seq, FEAT]
231    let g1b = gru_layer(
232        &mut b,
233        wm,
234        xb,
235        "onnx::GRU_422",
236        "onnx::GRU_423",
237        "onnx::GRU_421",
238        batch,
239        seq,
240        FEAT,
241        HIDDEN,
242    )?; // [batch, seq, 2*HIDDEN]
243    if stop == Some(Stage::AfterG1) {
244        let g1 = b.m().transpose_(g1b, vec![1, 0, 2]); // [seq, batch, 2*HIDDEN]
245        b.m().set_outputs(vec![g1]);
246        return b.finish();
247    }
248
249    let g2b = gru_layer(
250        &mut b,
251        wm,
252        g1b,
253        "onnx::GRU_465",
254        "onnx::GRU_466",
255        "onnx::GRU_464",
256        batch,
257        seq,
258        2 * HIDDEN,
259        HIDDEN,
260    )?; // [batch, seq, 2*HIDDEN]
261    let g2 = b.m().transpose_(g2b, vec![1, 0, 2]); // [seq, batch, 2*HIDDEN]
262    if stop == Some(Stage::AfterG2) {
263        b.m().set_outputs(vec![g2]);
264        return b.finish();
265    }
266
267    let head_w = b.load_param(wm, "onnx::MatMul_467")?;
268    let head_b = b.load_param(wm, "output.0.bias")?;
269    let logits = b.m().mm(g2, head_w);
270    let logits = add_bias_seq(&mut b, logits, head_b, batch, seq, NUM_CLASSES)?;
271    if stop == Some(Stage::AfterLogits) {
272        b.m().set_outputs(vec![logits]);
273        return b.finish();
274    }
275    let out = b.m().transpose_(logits, vec![1, 0, 2]);
276    b.m().set_outputs(vec![out]);
277
278    assert_weights_drained(wm, "recognition graph")?;
279    b.finish()
280}
281
282fn conv_relu(
283    b: &mut OcrGraphBuilder,
284    wm: &mut WeightMap,
285    x: HirNodeId,
286    w_key: &str,
287    bias_key: &str,
288    batch: usize,
289    out_c: usize,
290    _in_c: usize,
291    h: usize,
292    w: usize,
293) -> Result<HirNodeId> {
294    let weight = b.load_param(wm, w_key)?;
295    let bias = b.load_param(wm, bias_key)?;
296    let y = conv2d_bias(
297        &mut b.m(),
298        x,
299        weight,
300        bias,
301        batch,
302        out_c,
303        3,
304        3,
305        [1, 1],
306        [1, 1],
307        h,
308        w,
309    );
310    Ok(b.m().relu(y))
311}
312
313/// Final 2×2 conv (no ReLU — ONNX feeds `AveragePool` directly).
314fn fused_conv2x2(
315    b: &mut OcrGraphBuilder,
316    wm: &mut WeightMap,
317    x: HirNodeId,
318    w_key: &str,
319    bias_key: &str,
320    batch: usize,
321    out_c: usize,
322    _in_c: usize,
323    h: usize,
324    w: usize,
325) -> Result<HirNodeId> {
326    let weight = b.load_param(wm, w_key)?;
327    let bias = b.load_param(wm, bias_key)?;
328    let out_h = h + 1;
329    let out_w = w + 1;
330    Ok(conv2d_bias(
331        &mut b.m(),
332        x,
333        weight,
334        bias,
335        batch,
336        out_c,
337        2,
338        2,
339        [1, 1],
340        [1, 1],
341        out_h,
342        out_w,
343    ))
344}
345
346fn pool_2x1(
347    g: &mut HirMut<'_>,
348    x: HirNodeId,
349    batch: usize,
350    c: usize,
351    h: usize,
352    w: usize,
353) -> HirNodeId {
354    use rlx_ir::op::{Op, ReduceOp};
355    let dt = g.shape(x).dtype();
356    let out_h = (h.saturating_sub(2)) / 2 + 1;
357    let out_w = w;
358    let out_shape = rlx_core::vision_ops_ir::nchw_shape(batch, c, out_h, out_w, dt);
359    g.add_node(
360        Op::Pool {
361            kind: ReduceOp::Max,
362            kernel_size: vec![2, 1],
363            stride: vec![2, 1],
364            padding: vec![0, 0],
365        },
366        vec![x],
367        out_shape,
368    )
369}
370
371/// Insert a constant f32 param `[len]` and return its node.
372fn gru_param(b: &mut OcrGraphBuilder, key: String, len: usize, data: Vec<f32>) -> HirNodeId {
373    debug_assert_eq!(data.len(), len);
374    let id = b.m().param(&key, Shape::new(&[len], DType::F32));
375    b.params.insert(key, data);
376    id
377}
378
379/// One bidirectional GRU layer via native `Op::Gru`. Loads ocrs ONNX-layout
380/// weights — `W` `[2,3h,in]`, `R` `[2,3h,h]`, `B` `[2,6h]`, gate order **z,r,h** —
381/// and repacks them to rlx's PyTorch layout (per-direction contiguous, gate order
382/// **r,z,n**, separate `b_ih`/`b_hh`). `x` is `[batch, seq, in]`; output
383/// `[batch, seq, 2*hidden]` (forward hidden then backward hidden).
384#[allow(clippy::too_many_arguments)]
385fn gru_layer(
386    b: &mut OcrGraphBuilder,
387    wm: &mut WeightMap,
388    x: HirNodeId,
389    w_key: &str,
390    r_key: &str,
391    b_key: &str,
392    batch: usize,
393    seq: usize,
394    in_size: usize,
395    hidden: usize,
396) -> Result<HirNodeId> {
397    use anyhow::Context;
398    const NUM_DIR: usize = 2;
399    // ONNX gate order is [z, r, h]; rlx wants [r, z, n]: rlx gate i ← onnx MAP[i].
400    const MAP: [usize; 3] = [1, 0, 2];
401    let g3 = 3 * hidden;
402
403    let (w_data, _) = wm
404        .take(w_key)
405        .with_context(|| format!("missing weight {w_key}"))?;
406    let (r_data, _) = wm
407        .take(r_key)
408        .with_context(|| format!("missing weight {r_key}"))?;
409    let (b_data, _) = wm
410        .take(b_key)
411        .with_context(|| format!("missing weight {b_key}"))?;
412
413    let mut w_ih = vec![0f32; NUM_DIR * g3 * in_size];
414    let mut w_hh = vec![0f32; NUM_DIR * g3 * hidden];
415    let mut b_ih = vec![0f32; NUM_DIR * g3];
416    let mut b_hh = vec![0f32; NUM_DIR * g3];
417    for d in 0..NUM_DIR {
418        for rg in 0..3 {
419            let og = MAP[rg];
420            let wblk = hidden * in_size;
421            let (ws, wd) = ((d * 3 + og) * wblk, (d * 3 + rg) * wblk);
422            w_ih[wd..wd + wblk].copy_from_slice(&w_data[ws..ws + wblk]);
423            let rblk = hidden * hidden;
424            let (rs, rd) = ((d * 3 + og) * rblk, (d * 3 + rg) * rblk);
425            w_hh[rd..rd + rblk].copy_from_slice(&r_data[rs..rs + rblk]);
426            // B per direction = [Wb(3h) | Rb(3h)], each gate `[hidden]`.
427            let bd = (d * 3 + rg) * hidden;
428            let wb = d * 6 * hidden + og * hidden;
429            let rb = d * 6 * hidden + g3 + og * hidden;
430            b_ih[bd..bd + hidden].copy_from_slice(&b_data[wb..wb + hidden]);
431            b_hh[bd..bd + hidden].copy_from_slice(&b_data[rb..rb + hidden]);
432        }
433    }
434
435    let wih = gru_param(b, format!("{w_key}.rlx_wih"), w_ih.len(), w_ih);
436    let whh = gru_param(b, format!("{r_key}.rlx_whh"), w_hh.len(), w_hh);
437    let bih = gru_param(b, format!("{b_key}.rlx_bih"), b_ih.len(), b_ih);
438    let bhh = gru_param(b, format!("{b_key}.rlx_bhh"), b_hh.len(), b_hh);
439
440    let shape = Shape::new(&[batch, seq, NUM_DIR * hidden], DType::F32);
441    // `gru` lives on `HirModule` (the public `.0` of `HirMut`).
442    Ok(b.m().0.gru(x, wih, whh, bih, bhh, hidden, 1, true, shape))
443}
444
445/// RTen-compatible log-softmax on the last axis of a row-major `[outer, classes]` buffer.
446pub fn log_softmax_last_axis(data: &mut [f32], classes: usize) {
447    assert!(classes > 0 && data.len().is_multiple_of(classes));
448    for lane in data.chunks_mut(classes) {
449        let max_val = lane.iter().copied().fold(f32::NEG_INFINITY, f32::max);
450        let log_exp_sum = lane.iter().map(|&x| (x - max_val).exp()).sum::<f32>().ln();
451        for el in lane.iter_mut() {
452            *el = (*el - max_val) - log_exp_sum;
453        }
454    }
455}
456
457fn add_bias_seq(
458    b: &mut OcrGraphBuilder,
459    y: HirNodeId,
460    bias: HirNodeId,
461    _batch: usize,
462    _seq: usize,
463    classes: usize,
464) -> Result<HirNodeId> {
465    let bias3 = b.m().reshape_(bias, vec![1, 1, classes as i64]);
466    Ok(b.m().add(y, bias3))
467}